#nullable enable using System; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Serilog; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Users; using Wino.Messaging.UI; namespace Wino.Services; public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccountProfileService { private readonly IWinoAccountApiClient _apiClient; private readonly IStoreManagementService _storeManagementService; private readonly SemaphoreSlim _billingCallbackLock = new(1, 1); private readonly SemaphoreSlim _tokenRefreshLock = new(1, 1); private readonly ILogger _logger = Log.ForContext(); public WinoAccountProfileService(IDatabaseService databaseService, IWinoAccountApiClient apiClient, IStoreManagementService storeManagementService) : base(databaseService) { _apiClient = apiClient; _storeManagementService = storeManagementService; } public async Task RegisterAsync(string email, string password, CancellationToken cancellationToken = default) { var response = await _apiClient.RegisterAsync(email, password, cancellationToken).ConfigureAwait(false); if (!response.IsSuccess || response.Result == null) { _logger.Warning("Wino account registration failed. Error code: {ErrorCode}. Error message: {ErrorMessage}", response.ErrorCode, response.ErrorMessage); return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage, response.ErrorDetails); } // Registration no longer signs the user in locally until the email address is confirmed. return WinoAccountOperationResult.Success(Map(response.Result)); } public async Task LoginAsync(string email, string password, CancellationToken cancellationToken = default) { var response = await _apiClient.LoginAsync(email, password, cancellationToken).ConfigureAwait(false); var result = await PersistResponseAsync(response).ConfigureAwait(false); if (result.IsSuccess && result.Account != null) { PublishProfileUpdated(result.Account); ReportUIChange(new WinoAccountSignedInMessage(result.Account)); } return result; } public Task> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default) => _apiClient.ResendEmailConfirmationAsync(endpoint, ticket, cancellationToken); public Task> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default) => _apiClient.ForgotPasswordAsync(email, cancellationToken); public async Task RefreshAsync(CancellationToken cancellationToken = default) { await _tokenRefreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { var account = await GetActiveAccountAsync().ConfigureAwait(false); if (account == null || string.IsNullOrWhiteSpace(account.RefreshToken)) { _logger.Warning("Wino account token refresh skipped because there is no active account or refresh token."); return WinoAccountOperationResult.Failure(ApiErrorCodes.RefreshTokenInvalid); } if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow) { return WinoAccountOperationResult.Success(account); } _logger.Information("Refreshing Wino account token for {Email}", account.Email); var response = await _apiClient.RefreshAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false); var result = await PersistResponseAsync(response).ConfigureAwait(false); if (!result.IsSuccess) { _logger.Warning("Wino account token refresh failed for {Email}. Error code: {ErrorCode}", account.Email, result.ErrorCode); return result; } if (result.Account != null && !AreEquivalentProfiles(account, result.Account)) { PublishProfileUpdated(result.Account); } return result; } finally { _tokenRefreshLock.Release(); } } public async Task RefreshProfileAsync(CancellationToken cancellationToken = default) { var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); if (account == null) { return WinoAccountOperationResult.Failure("MissingAccessToken"); } var response = await _apiClient.GetCurrentUserAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccess || response.Result == null) { _logger.Warning("Failed to refresh Wino account profile for {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode); return WinoAccountOperationResult.Failure(response.ErrorCode); } var refreshedAccount = MergeAccountProfile(account, response.Result); if (AreEquivalentProfiles(account, refreshedAccount)) { return WinoAccountOperationResult.Success(account); } await PersistAccountAsync(refreshedAccount).ConfigureAwait(false); PublishProfileUpdated(refreshedAccount); return WinoAccountOperationResult.Success(refreshedAccount); } public async Task GetActiveAccountAsync() { var account = await Connection.Table().FirstOrDefaultAsync().ConfigureAwait(false); return account; } public async Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default) { var account = await GetActiveAccountAsync().ConfigureAwait(false); if (account == null) { return null; } if (string.IsNullOrWhiteSpace(account.AccessToken)) { _logger.Warning("Wino account {Email} is missing an access token.", account.Email); return null; } if (account.AccessTokenExpiresAtUtc > DateTime.UtcNow) { return account; } var refreshResult = await RefreshAsync(cancellationToken).ConfigureAwait(false); if (!refreshResult.IsSuccess) { return null; } return refreshResult.Account ?? await GetActiveAccountAsync().ConfigureAwait(false); } public async Task HasActiveAccountAsync() => await Connection.Table().CountAsync().ConfigureAwait(false) > 0; public async Task> GetCurrentUserAsync(CancellationToken cancellationToken = default) { var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); if (account == null) { return ApiEnvelope.Failure("MissingAccessToken"); } var response = await _apiClient.GetCurrentUserAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccess) { _logger.Warning("Failed to load Wino account profile for {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode); return response; } if (response.Result != null) { var refreshedAccount = MergeAccountProfile(account, response.Result); await PersistProfileDataAsync(account, refreshedAccount).ConfigureAwait(false); } return response; } public async Task> GetAiStatusAsync(CancellationToken cancellationToken = default) { var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); if (account == null) { return ApiEnvelope.Failure("MissingAccessToken"); } var response = await _apiClient.GetAiStatusAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccess) { _logger.Warning("Failed to load AI status for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode); return response; } return response; } public async Task> SummarizeAsync(string html, string targetLanguage, CancellationToken cancellationToken = default) => await ExecuteAiOperationAsync(account => _apiClient.SummarizeAsync(html, targetLanguage, cancellationToken), "summarize", cancellationToken).ConfigureAwait(false); public async Task> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default) => await ExecuteAiOperationAsync(account => _apiClient.TranslateAsync(html, targetLanguage, cancellationToken), "translate", cancellationToken).ConfigureAwait(false); public async Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default) => await ExecuteAiOperationAsync(account => _apiClient.RewriteAsync(html, mode, cancellationToken), "rewrite", cancellationToken).ConfigureAwait(false); public async Task> SyncStoreEntitlementsAsync(CancellationToken cancellationToken = default) { var account = await GetActiveAccountAsync().ConfigureAwait(false); if (account == null || string.IsNullOrWhiteSpace(account.AccessToken)) { return ApiEnvelope.Failure("MissingAccessToken"); } string? storeIdKey = null; string? purchaseIdKey = null; var collectionsTicketResponse = await _apiClient.CreateCollectionsIdTicketAsync(cancellationToken).ConfigureAwait(false); if (collectionsTicketResponse.IsSuccess && collectionsTicketResponse.Result != null) { storeIdKey = await _storeManagementService.GetCustomerCollectionsIdAsync( collectionsTicketResponse.Result.ServiceTicket, collectionsTicketResponse.Result.PublisherUserId).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(storeIdKey)) { _logger.Warning("Failed to obtain Microsoft Store collections ID key for Wino account {Email}.", account.Email); } } else { _logger.Warning("Failed to create Microsoft Store collections ticket for Wino account {Email}. Error code: {ErrorCode}", account.Email, collectionsTicketResponse.ErrorCode); } var purchaseTicketResponse = await _apiClient.CreatePurchaseIdTicketAsync(cancellationToken).ConfigureAwait(false); if (purchaseTicketResponse.IsSuccess && purchaseTicketResponse.Result != null) { purchaseIdKey = await _storeManagementService.GetCustomerPurchaseIdAsync( purchaseTicketResponse.Result.ServiceTicket, purchaseTicketResponse.Result.PublisherUserId).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(purchaseIdKey)) { _logger.Warning("Failed to obtain Microsoft Store purchase ID key for Wino account {Email}.", account.Email); } } else { _logger.Warning("Failed to create Microsoft Store purchase ticket for Wino account {Email}. Error code: {ErrorCode}", account.Email, purchaseTicketResponse.ErrorCode); } if (string.IsNullOrWhiteSpace(storeIdKey) && string.IsNullOrWhiteSpace(purchaseIdKey)) { return ApiEnvelope.Failure("StoreEntitlementKeysMissing"); } var response = await _apiClient.SyncStoreEntitlementsAsync(storeIdKey, purchaseIdKey, cancellationToken).ConfigureAwait(false); if (!response.IsSuccess) { _logger.Warning("Failed to sync Microsoft Store entitlements for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode); return response; } await RefreshProfileAsync(cancellationToken).ConfigureAwait(false); await GetAiStatusAsync(cancellationToken).ConfigureAwait(false); return response; } public async Task GetSettingsAsync(CancellationToken cancellationToken = default) { _ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("MissingAccessToken"); return await _apiClient.GetSettingsAsync(cancellationToken).ConfigureAwait(false); } public async Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default) { _ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("MissingAccessToken"); await _apiClient.SaveSettingsAsync(settingsJson, cancellationToken).ConfigureAwait(false); } public async Task GetMailboxesAsync(CancellationToken cancellationToken = default) { _ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("MissingAccessToken"); return await _apiClient.GetMailboxesAsync(cancellationToken).ConfigureAwait(false); } public async Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default) { _ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("MissingAccessToken"); await _apiClient.ReplaceMailboxesAsync(request, cancellationToken).ConfigureAwait(false); } public async Task ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default) { await _billingCallbackLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { var targetProductType = ResolveProductType(callbackUri); if (targetProductType == null) { _logger.Warning("Billing callback was ignored because productCode is missing or unsupported. Uri: {Uri}", callbackUri); return false; } if (await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) == null) { _logger.Warning("Billing callback was ignored because there is no authenticated Wino account."); return false; } const int maxAttempts = 15; for (var attempt = 0; attempt < maxAttempts; attempt++) { cancellationToken.ThrowIfCancellationRequested(); var refreshResult = await RefreshProfileAsync(cancellationToken).ConfigureAwait(false); if (refreshResult.IsSuccess && await _storeManagementService.HasProductAsync(targetProductType.Value).ConfigureAwait(false)) { ReportUIChange(new WinoAccountAddOnPurchasedMessage(targetProductType.Value)); return true; } if (attempt < maxAttempts - 1) { await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); } } return false; } finally { _billingCallbackLock.Release(); } } public async Task SignOutAsync(CancellationToken cancellationToken = default) { var account = await GetActiveAccountAsync().ConfigureAwait(false); if (account != null && !string.IsNullOrWhiteSpace(account.RefreshToken)) { try { var result = await _apiClient.LogoutAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false); if (!result.IsSuccess && !string.IsNullOrWhiteSpace(result.ErrorCode)) { _logger.Warning("Wino account remote sign-out failed with error code {ErrorCode}", result.ErrorCode); } } catch (Exception ex) { _logger.Warning(ex, "Wino account remote sign-out failed."); } } await Connection.DeleteAllAsync().ConfigureAwait(false); if (account != null) { ReportUIChange(new WinoAccountProfileDeletedMessage(account)); ReportUIChange(new WinoAccountSignedOutMessage(account)); } } private async Task PersistResponseAsync(WinoAccountApiResult response) { if (!response.IsSuccess || response.Result == null) { _logger.Warning("Wino account operation failed. Error code: {ErrorCode}. Error message: {ErrorMessage}", response.ErrorCode, response.ErrorMessage); return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage, response.ErrorDetails); } var account = Map(response.Result); await PersistAccountAsync(account).ConfigureAwait(false); return WinoAccountOperationResult.Success(account); } private async Task PersistAccountAsync(WinoAccount account) { await Connection.DeleteAllAsync().ConfigureAwait(false); await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false); } private async Task PersistProfileDataAsync(WinoAccount originalAccount, WinoAccount refreshedAccount) { if (!AreEquivalentProfiles(originalAccount, refreshedAccount)) { await PersistAccountAsync(refreshedAccount).ConfigureAwait(false); PublishProfileUpdated(refreshedAccount); } } private void PublishProfileUpdated(WinoAccount account) => ReportUIChange(new WinoAccountProfileUpdatedMessage(account)); private async Task> ExecuteAiOperationAsync(Func>> executeAsync, string operationName, CancellationToken cancellationToken) { var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); if (account == null) { return ApiEnvelope.Failure("MissingAccessToken"); } var response = await executeAsync(account).ConfigureAwait(false); if (!response.IsSuccess) { _logger.Warning("Failed to {Operation} HTML with AI for Wino account {Email}. Error code: {ErrorCode}", operationName, account.Email, response.ErrorCode); } return response; } private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right) => left.Id == right.Id && string.Equals(left.Email, right.Email, StringComparison.Ordinal) && string.Equals(left.AccountStatus, right.AccountStatus, StringComparison.Ordinal) && left.HasPassword == right.HasPassword && left.HasGoogleLogin == right.HasGoogleLogin && left.HasFacebookLogin == right.HasFacebookLogin; private static WinoAccount MergeAccountProfile(WinoAccount existingAccount, AuthUserDto profile) => new() { Id = profile.UserId, Email = profile.Email, AccountStatus = profile.AccountStatus, HasPassword = profile.HasPassword, HasGoogleLogin = profile.HasGoogleLogin, HasFacebookLogin = profile.HasFacebookLogin, AccessToken = existingAccount.AccessToken, AccessTokenExpiresAtUtc = existingAccount.AccessTokenExpiresAtUtc, RefreshToken = existingAccount.RefreshToken, RefreshTokenExpiresAtUtc = existingAccount.RefreshTokenExpiresAtUtc, LastAuthenticatedUtc = existingAccount.LastAuthenticatedUtc }; private static WinoAddOnProductType? ResolveProductType(Uri callbackUri) { var productCode = GetQueryParameter(callbackUri, "productCode"); if (string.IsNullOrWhiteSpace(productCode)) { return null; } return productCode.Trim().ToUpperInvariant() switch { "AI_PACK" => WinoAddOnProductType.AI_PACK, "UNLIMITED_ACCOUNTS" => WinoAddOnProductType.UNLIMITED_ACCOUNTS, _ => null }; } private static string GetQueryParameter(Uri uri, string key) { var query = uri.Query; if (string.IsNullOrWhiteSpace(query)) { return string.Empty; } foreach (var part in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) { var pieces = part.Split('=', 2); if (pieces.Length == 0 || !string.Equals(pieces[0], key, StringComparison.OrdinalIgnoreCase)) { continue; } return pieces.Length > 1 ? Uri.UnescapeDataString(pieces[1]) : string.Empty; } return string.Empty; } private static WinoAccount Map(AuthResultDto result) => new() { Id = result.User.UserId, Email = result.User.Email, AccountStatus = result.User.AccountStatus, HasPassword = result.User.HasPassword, HasGoogleLogin = result.User.HasGoogleLogin, HasFacebookLogin = result.User.HasFacebookLogin, AccessToken = result.AccessToken, AccessTokenExpiresAtUtc = result.AccessTokenExpiresAtUtc.UtcDateTime, RefreshToken = result.RefreshToken, RefreshTokenExpiresAtUtc = result.RefreshTokenExpiresAtUtc.UtcDateTime, LastAuthenticatedUtc = DateTime.UtcNow }; }