using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Serilog; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Authentication; using Wino.Core.Domain.Models.Connectivity; using Wino.Core.Domain.Models.Synchronization; using Wino.Messaging.UI; namespace Wino.Core.Services; /// /// Singleton manager that handles synchronizer instances and operations for all accounts. /// Replaces the old WinoServerConnectionManager functionality. /// public class SynchronizationManager : ISynchronizationManager { private static readonly Lazy _instance = new(() => new SynchronizationManager()); public static SynchronizationManager Instance => _instance.Value; private readonly ConcurrentDictionary _synchronizerCache = new(); private readonly ConcurrentDictionary _accountSynchronizationCancellationSources = new(); private readonly ConcurrentDictionary _calendarSynchronizationLocks = new(); private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly ILogger _logger = Log.ForContext(); private SynchronizerFactory _concreteSynchronizerFactory; private IImapTestService _imapTestService; private IAccountService _accountService; private IAuthenticationProvider _authenticationProvider; private INotificationBuilder _notificationBuilder; private bool _isInitialized = false; private SynchronizationManager() { } /// /// Initializes the SynchronizationManager with required dependencies. /// This must be called before using any other methods. /// Note: Synchronizers are created lazily to avoid requiring window handles during app initialization. /// /// Factory for creating synchronizers /// Service for testing IMAP connectivity /// Service for account operations /// Provider for OAuth authentication public async Task InitializeAsync(ISynchronizerFactory synchronizerFactory, IImapTestService imapTestService, IAccountService accountService, INotificationBuilder notificationBuilder, IAuthenticationProvider authenticationProvider) { await _initializationSemaphore.WaitAsync(); try { if (_isInitialized) return; _concreteSynchronizerFactory = synchronizerFactory as SynchronizerFactory ?? throw new ArgumentException("SynchronizerFactory must be the concrete implementation"); _imapTestService = imapTestService ?? throw new ArgumentNullException(nameof(imapTestService)); _accountService = accountService ?? throw new ArgumentNullException(nameof(accountService)); _authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider)); _notificationBuilder = notificationBuilder ?? throw new ArgumentNullException(nameof(notificationBuilder)); // DO NOT create synchronizers here to avoid requiring window handles during initialization. // Synchronizers will be created lazily when first accessed via GetOrCreateSynchronizerAsync. _isInitialized = true; _logger.Information("SynchronizationManager dependencies initialized. Synchronizers will be created lazily."); } finally { _initializationSemaphore.Release(); } } /// /// Tests IMAP server connectivity for the given server information. /// /// Server information to test /// Whether to allow SSL handshake /// Test results indicating success or failure with details public async Task TestImapConnectivityAsync(CustomServerInformation serverInformation, bool allowSSLHandshake) { EnsureInitialized(); try { _logger.Information("Testing IMAP connectivity for {Server}:{Port}", serverInformation.IncomingServer, serverInformation.IncomingServerPort); await _imapTestService.TestImapConnectionAsync(serverInformation, allowSSLHandshake); _logger.Information("IMAP connectivity test successful"); return ImapConnectivityTestResults.Success(); } catch (ImapTestSSLCertificateException sslTestException) { _logger.Warning("IMAP connectivity test requires SSL certificate confirmation"); return ImapConnectivityTestResults.CertificateUIRequired( sslTestException.Issuer, sslTestException.ExpirationDateString, sslTestException.ValidFromDateString); } catch (ImapClientPoolException clientPoolException) { _logger.Error(clientPoolException, "IMAP connectivity test failed"); return ImapConnectivityTestResults.Failure(clientPoolException); } catch (Exception exception) { _logger.Error(exception, "IMAP connectivity test failed"); return ImapConnectivityTestResults.Failure(exception); } } /// /// Starts a new mail synchronization for the given account. /// /// Mail synchronization options /// Cancellation token /// Synchronization result public async Task SynchronizeMailAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default) { EnsureInitialized(); if (await IsSynchronizationBlockedByAttentionAsync(options.AccountId).ConfigureAwait(false)) { _logger.Information("Skipping mail synchronization for account {AccountId} because it requires credential attention.", options.AccountId); return MailSynchronizationResult.Canceled; } var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId); if (synchronizer == null) { _logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId); var exception = new InvalidOperationException("Can't create/get synchronizer."); return MailSynchronizationResult .Failed(exception) .MergeIssues([SynchronizationIssue.FromException(exception, "MailSync")]); } _logger.Information("Starting mail synchronization for account {AccountId} with type {SyncType}", options.AccountId, options.Type); var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource()); using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, accountCancellationSource.Token); try { var result = await synchronizer.SynchronizeMailsAsync(options, linkedCancellationTokenSource.Token); _logger.Information("Mail synchronization completed for account {AccountId} with state {State}", options.AccountId, result.CompletedState); // Create notifications. if (result.DownloadedMessages?.Any() ?? false) await _notificationBuilder.CreateNotificationsAsync(result.DownloadedMessages); await _notificationBuilder.UpdateTaskbarIconBadgeAsync(); return result; } catch (OperationCanceledException) { _logger.Information("Mail synchronization canceled for account {AccountId}", options.AccountId); return MailSynchronizationResult.Canceled; } catch (AuthenticationAttentionException authEx) { _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); await SetInvalidCredentialAttentionAsync(authEx.Account).ConfigureAwait(false); // Create app notification for authentication attention _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); return MailSynchronizationResult .Failed(authEx) .MergeIssues([SynchronizationIssue.FromException(authEx, "MailSync", SynchronizerErrorSeverity.AuthRequired)]); } catch (Exception ex) { _logger.Error(ex, "Mail synchronization failed for account {AccountId}", options.AccountId); return MailSynchronizationResult .Failed(ex) .MergeIssues([SynchronizationIssue.FromException(ex, "MailSync")]); } } /// /// Checks if there is an ongoing synchronization for the given account. /// /// Account ID to check /// True if synchronization is ongoing, false otherwise public bool IsAccountSynchronizing(Guid accountId) { EnsureInitialized(); if (_synchronizerCache.TryGetValue(accountId, out var synchronizer)) { return synchronizer.State == AccountSynchronizerState.Synchronizing || synchronizer.State == AccountSynchronizerState.ExecutingRequests; } return false; } /// /// Queues a request to the corresponding account's synchronizer with optional synchronization triggering. /// Automatically determines whether to trigger mail or calendar synchronization based on the request type. /// /// Request to queue /// Account ID to queue the request for /// Whether to automatically trigger synchronization after queuing the request public async Task QueueRequestAsync(IRequestBase request, Guid accountId, bool triggerSynchronization) { EnsureInitialized(); var synchronizer = await GetOrCreateSynchronizerAsync(accountId); if (synchronizer == null) { _logger.Error("Could not find or create synchronizer for account {AccountId} to queue request", accountId); return; } _logger.Debug("Queuing request {RequestType} for account {AccountId}", request.GetType().Name, accountId); synchronizer.QueueRequest(request); if (triggerSynchronization) { // Determine if this is a calendar or mail operation bool isCalendarOperation = request is ICalendarActionRequest; if (isCalendarOperation) { // Trigger calendar synchronization _logger.Debug("Triggering calendar synchronization to execute queued request for account {AccountId}", accountId); var calendarSyncOptions = new CalendarSynchronizationOptions() { AccountId = accountId }; // Trigger synchronization asynchronously without waiting for completion _ = Task.Run(async () => { try { await SynchronizeCalendarAsync(calendarSyncOptions); } catch (Exception ex) { _logger.Error(ex, "Failed to execute calendar synchronization after queuing request for account {AccountId}", accountId); } }); } else { // Trigger mail synchronization (includes mail and folder operations) _logger.Debug("Triggering mail synchronization to execute queued request for account {AccountId}", accountId); var mailSyncOptions = new MailSynchronizationOptions() { AccountId = accountId, Type = MailSynchronizationType.ExecuteRequests }; // Trigger synchronization asynchronously without waiting for completion // This matches the pattern used in WinoRequestDelegator _ = Task.Run(async () => { try { await SynchronizeMailAsync(mailSyncOptions); } catch (Exception ex) { _logger.Error(ex, "Failed to execute mail synchronization after queuing request for account {AccountId}", accountId); } }); } } } /// /// Handles folder synchronization for the given account. /// /// Account ID to synchronize folders for /// Cancellation token /// Synchronization result public async Task SynchronizeFoldersAsync(Guid accountId, CancellationToken cancellationToken = default) { EnsureInitialized(); var options = new MailSynchronizationOptions { AccountId = accountId, Type = MailSynchronizationType.FoldersOnly }; return await SynchronizeMailAsync(options, cancellationToken); } /// /// Handles alias synchronization for the given account. /// /// Account ID to synchronize aliases for /// Cancellation token /// Synchronization result public async Task SynchronizeAliasesAsync(Guid accountId, CancellationToken cancellationToken = default) { EnsureInitialized(); var options = new MailSynchronizationOptions { AccountId = accountId, Type = MailSynchronizationType.Alias }; return await SynchronizeMailAsync(options, cancellationToken); } /// /// Handles profile synchronization for the given account. /// /// Account ID to synchronize profile for /// Cancellation token /// Synchronization result public async Task SynchronizeProfileAsync(Guid accountId, CancellationToken cancellationToken = default) { EnsureInitialized(); var options = new MailSynchronizationOptions { AccountId = accountId, Type = MailSynchronizationType.UpdateProfile }; return await SynchronizeMailAsync(options, cancellationToken); } /// /// Handles calendar synchronization for the given account. /// /// Calendar synchronization options /// Cancellation token /// Synchronization result public async Task SynchronizeCalendarAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) => options.Type == CalendarSynchronizationType.Strict ? await SynchronizeCalendarStrictAsync(options, cancellationToken).ConfigureAwait(false) : await RunCalendarSynchronizationWithLockAsync( options.AccountId, cancellationToken, () => SynchronizeCalendarCoreAsync(options, cancellationToken, reportState: true)).ConfigureAwait(false); private async Task SynchronizeCalendarStrictAsync( CalendarSynchronizationOptions options, CancellationToken cancellationToken) { var metadataOptions = new CalendarSynchronizationOptions { AccountId = options.AccountId, Type = CalendarSynchronizationType.CalendarMetadata, SynchronizationCalendarIds = options.SynchronizationCalendarIds }; var eventOptions = new CalendarSynchronizationOptions { AccountId = options.AccountId, Type = CalendarSynchronizationType.CalendarEvents, SynchronizationCalendarIds = options.SynchronizationCalendarIds }; return await RunCalendarSynchronizationWithLockAsync(options.AccountId, cancellationToken, async () => { try { PublishCalendarSynchronizationState( options.AccountId, CalendarSynchronizationType.Strict, isSynchronizationInProgress: true, Translator.SyncAction_SynchronizingCalendarMetadata); var metadataResult = await SynchronizeCalendarCoreAsync(metadataOptions, cancellationToken, reportState: false).ConfigureAwait(false); if (metadataResult.CompletedState is SynchronizationCompletedState.Failed or SynchronizationCompletedState.Canceled) { return metadataResult; } PublishCalendarSynchronizationState( options.AccountId, CalendarSynchronizationType.Strict, isSynchronizationInProgress: true, Translator.SyncAction_SynchronizingCalendarEvents); return await SynchronizeCalendarCoreAsync(eventOptions, cancellationToken, reportState: false).ConfigureAwait(false); } finally { PublishCalendarSynchronizationState(options.AccountId, CalendarSynchronizationType.Strict, isSynchronizationInProgress: false); } }).ConfigureAwait(false); } private async Task SynchronizeCalendarCoreAsync( CalendarSynchronizationOptions options, CancellationToken cancellationToken, bool reportState) { EnsureInitialized(); if (await IsSynchronizationBlockedByAttentionAsync(options.AccountId).ConfigureAwait(false)) { _logger.Information("Skipping calendar synchronization for account {AccountId} because it requires credential attention.", options.AccountId); return CalendarSynchronizationResult.Canceled; } var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId); if (synchronizer == null) { _logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId); var exception = new InvalidOperationException("Can't create/get synchronizer."); return CalendarSynchronizationResult .Failed(exception) .MergeIssues([SynchronizationIssue.FromException(exception, "CalendarSync")]); } _logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}", options.AccountId, options.Type); if (reportState) { PublishCalendarSynchronizationState( options.AccountId, options.Type, isSynchronizationInProgress: true, GetCalendarSynchronizationStatus(options.Type)); } var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource()); using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, accountCancellationSource.Token); try { var result = await synchronizer.SynchronizeCalendarEventsAsync(options, linkedCancellationTokenSource.Token); _logger.Information("Calendar synchronization completed for account {AccountId} with state {State}", options.AccountId, result.CompletedState); // TODO: Create notifications for new calendar events when INotificationBuilder supports it // if (result.DownloadedEvents?.Any() ?? false) // await _notificationBuilder.CreateCalendarNotificationsAsync(result.DownloadedEvents); return result; } catch (OperationCanceledException) { _logger.Information("Calendar synchronization canceled for account {AccountId}", options.AccountId); return CalendarSynchronizationResult.Canceled; } catch (AuthenticationAttentionException authEx) { _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); await SetInvalidCredentialAttentionAsync(authEx.Account).ConfigureAwait(false); // Create app notification for authentication attention _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); return CalendarSynchronizationResult .Failed(authEx) .MergeIssues([SynchronizationIssue.FromException(authEx, "CalendarSync", SynchronizerErrorSeverity.AuthRequired)]); } catch (Exception ex) { _logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId); return CalendarSynchronizationResult .Failed(ex) .MergeIssues([SynchronizationIssue.FromException(ex, "CalendarSync")]); } finally { if (reportState) { PublishCalendarSynchronizationState(options.AccountId, options.Type, isSynchronizationInProgress: false); } } } /// /// Downloads a MIME message for the given mail item. /// /// Mail item to download /// Account ID that owns the mail item /// Cancellation token /// Downloaded MIME content path public async Task DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId, CancellationToken cancellationToken = default) { EnsureInitialized(); var synchronizer = await GetOrCreateSynchronizerAsync(accountId); if (synchronizer == null) { _logger.Error("Could not find or create synchronizer for account {AccountId} to download MIME", accountId); return null; } _logger.Debug("Downloading MIME message for mail item {MailItemId}", mailItem.Id); try { await synchronizer.DownloadMissingMimeMessageAsync(mailItem, null, cancellationToken); return mailItem.Id.ToString(); // Return some identifier, actual implementation might be different } catch (SynchronizerEntityNotFoundException) { _logger.Warning("MIME message for mail item {MailItemId} no longer exists on server. Removed locally.", mailItem.Id); return null; } catch (Exception ex) { _logger.Error(ex, "Failed to download MIME message for mail item {MailItemId}", mailItem.Id); return null; } } /// /// Downloads a calendar attachment using the appropriate synchronizer. /// public async Task DownloadCalendarAttachmentAsync( Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem, Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment, string localFilePath, CancellationToken cancellationToken = default) { EnsureInitialized(); if (calendarItem == null) throw new ArgumentNullException(nameof(calendarItem)); if (attachment == null) throw new ArgumentNullException(nameof(attachment)); var accountId = calendarItem.AssignedCalendar?.AccountId ?? Guid.Empty; if (accountId == Guid.Empty) throw new InvalidOperationException("Calendar item does not have an assigned account."); var synchronizer = await GetOrCreateSynchronizerAsync(accountId); if (synchronizer == null) { _logger.Error("Could not find or create synchronizer for account {AccountId} to download calendar attachment", accountId); throw new InvalidOperationException("No synchronizer available for downloading calendar attachment."); } _logger.Debug("Downloading calendar attachment {AttachmentId} for calendar item {CalendarItemId}", attachment.Id, calendarItem.Id); try { await synchronizer.DownloadCalendarAttachmentAsync( calendarItem, attachment, localFilePath, cancellationToken); } catch (Exception ex) { _logger.Error(ex, "Failed to download calendar attachment {AttachmentId}", attachment.Id); throw; } } /// /// Creates a new synchronizer for a newly added account. /// /// Account to create synchronizer for /// Created synchronizer public IWinoSynchronizerBase CreateSynchronizerForAccount(MailAccount account) { EnsureInitialized(); try { var synchronizer = _concreteSynchronizerFactory.CreateNewSynchronizer(account); _synchronizerCache.TryAdd(account.Id, synchronizer); _logger.Information("Created new synchronizer for account {AccountName} ({AccountId})", account.Name, account.Id); return synchronizer; } catch (Exception ex) { _logger.Error(ex, "Failed to create synchronizer for account {AccountName} ({AccountId})", account.Name, account.Id); return null; } } /// /// Cancels all in-flight synchronizations for the given account. /// /// Account ID to cancel synchronizations for public Task CancelSynchronizationsAsync(Guid accountId) { EnsureInitialized(); if (_accountSynchronizationCancellationSources.TryRemove(accountId, out var cancellationSource)) { try { if (!cancellationSource.IsCancellationRequested) { cancellationSource.Cancel(); } } catch (ObjectDisposedException) { // no-op } finally { cancellationSource.Dispose(); } _logger.Information("Canceled ongoing synchronizations for account {AccountId}", accountId); } return Task.CompletedTask; } /// /// Destroys the synchronizer for the given account. /// /// Account ID to destroy synchronizer for public async Task DestroySynchronizerAsync(Guid accountId) { EnsureInitialized(); await CancelSynchronizationsAsync(accountId); if (_synchronizerCache.TryRemove(accountId, out var synchronizer)) { try { await synchronizer.KillSynchronizerAsync(); _logger.Information("Destroyed synchronizer for account {AccountId}", accountId); } catch (OperationCanceledException) { _logger.Information("Synchronizer destruction canceled for account {AccountId}", accountId); } catch (Exception ex) { _logger.Error(ex, "Failed to destroy synchronizer for account {AccountId}", accountId); } } } /// /// Gets all cached synchronizers. /// /// Collection of all cached synchronizers public IEnumerable GetAllSynchronizers() { EnsureInitialized(); return _synchronizerCache.Values.ToList(); } /// /// Gets a synchronizer for the given account ID. /// /// Account ID /// Synchronizer if found, null otherwise public async Task GetSynchronizerAsync(Guid accountId) { EnsureInitialized(); return await GetOrCreateSynchronizerAsync(accountId); } private async Task GetOrCreateSynchronizerAsync(Guid accountId) { if (_synchronizerCache.TryGetValue(accountId, out var existingSynchronizer)) { return existingSynchronizer; } // Try to create a new synchronizer if not found var account = await _accountService.GetAccountAsync(accountId); if (account != null) { return CreateSynchronizerForAccount(account); } return null; } /// /// Handles OAuth authentication for the specified provider. /// /// The mail provider type to authenticate /// Optional account to authenticate (null for initial authentication) /// Whether to propose copying auth URL for Gmail /// Token information containing access token and username public async Task HandleAuthorizationAsync(MailProviderType providerType, MailAccount account = null, bool proposeCopyAuthorizationURL = false) { EnsureInitialized(); try { var authenticator = _authenticationProvider.GetAuthenticator(providerType); // Some users are having issues with Gmail authentication. // Their browsers may never launch to complete authentication. // Offer to copy auth url for them to complete it manually. // Redirection will occur to the app and the token will be saved. if (proposeCopyAuthorizationURL && authenticator is IGmailAuthenticator gmailAuthenticator) { gmailAuthenticator.ProposeCopyAuthURL = true; } TokenInformationEx tokenInfo; if (account != null) { // Get token for existing account (may trigger interactive auth if token is expired) tokenInfo = await authenticator.GetTokenInformationAsync(account); _logger.Information("Retrieved token for existing account {AccountAddress}", account.Address); } else { // Initial authentication request - there is no account to get token for // This will always trigger interactive authentication tokenInfo = await authenticator.GenerateTokenInformationAsync(null); _logger.Information("Generated new token for {ProviderType} authentication", providerType); } return tokenInfo; } catch (Exception ex) { _logger.Error(ex, "Failed to handle authorization for {ProviderType}", providerType); throw; } } private void EnsureInitialized() { if (!_isInitialized) { throw new InvalidOperationException("SynchronizationManager must be initialized before use. Call InitializeAsync first."); } } private async Task SetInvalidCredentialAttentionAsync(MailAccount account) { if (account == null || _accountService == null) return; var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false); if (persistedAccount == null) return; if (persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials) return; persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials; await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false); } private async Task IsSynchronizationBlockedByAttentionAsync(Guid accountId) { if (_accountService == null) return false; var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); return account?.AttentionReason == AccountAttentionReason.InvalidCredentials; } private void PublishCalendarSynchronizationState( Guid accountId, CalendarSynchronizationType synchronizationType, bool isSynchronizationInProgress, string synchronizationStatus = "") { WeakReferenceMessenger.Default.Send(new AccountCalendarSynchronizationStateChanged( accountId, synchronizationType, isSynchronizationInProgress, synchronizationStatus)); } private static string GetCalendarSynchronizationStatus(CalendarSynchronizationType synchronizationType) => synchronizationType switch { CalendarSynchronizationType.CalendarMetadata => Translator.SyncAction_SynchronizingCalendarMetadata, CalendarSynchronizationType.Strict => Translator.SyncAction_SynchronizingCalendarData, _ => Translator.SyncAction_SynchronizingCalendarEvents }; private async Task RunCalendarSynchronizationWithLockAsync( Guid accountId, CancellationToken cancellationToken, Func> synchronizationFactory) { var calendarSemaphore = _calendarSynchronizationLocks.GetOrAdd(accountId, _ => new SemaphoreSlim(1, 1)); await calendarSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { return await synchronizationFactory().ConfigureAwait(false); } finally { calendarSemaphore.Release(); } } }