#nullable enable using System; using System.Reflection; 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.Billing; using Wino.Mail.Api.Contracts.Common; using Wino.Messaging.UI; namespace Wino.Services; public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccountProfileService { private readonly IWinoAccountApiClient _apiClient; private readonly IStoreManagementService _storeManagementService; 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); var result = await PersistResponseAsync(response).ConfigureAwait(false); if (result.IsSuccess && result.Account != null) { ReportUIChange(new WinoAccountSignedInMessage(result.Account)); } return 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) { ReportUIChange(new WinoAccountSignedInMessage(result.Account)); } return result; } public async Task RefreshAsync(CancellationToken cancellationToken = default) { 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); } _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; } 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.AddMinutes(1)) { 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 HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) { return productId switch { WinoAddOnProductType.AI_PACK => await HasAiPackAsync(cancellationToken).ConfigureAwait(false), WinoAddOnProductType.UNLIMITED_ACCOUNTS => await HasUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false), _ => false }; } 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; } 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; } public async Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) { var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); if (account == null) { return ApiEnvelope.Failure("MissingAccessToken"); } var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false); if (!response.IsSuccess) { _logger.Warning("Failed to create checkout session for product {ProductId} and Wino account {Email}. Error code: {ErrorCode}", productId, account.Email, response.ErrorCode); } return response; } public async Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default) { var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); if (account == null) { return ApiEnvelope.Failure("MissingAccessToken"); } var response = await _apiClient.CreateCustomerPortalSessionAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccess) { _logger.Warning("Failed to create customer portal session for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode); } return response; } 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 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); } var account = Map(response.Result); await Connection.DeleteAllAsync().ConfigureAwait(false); await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false); return WinoAccountOperationResult.Success(account); } private async Task HasAiPackAsync(CancellationToken cancellationToken) { var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false); return response.IsSuccess && response.Result?.HasAiPack == true; } private async Task HasUnlimitedAccountsAsync(CancellationToken cancellationToken) { if (await HasRemoteUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false)) { return true; } return await _storeManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false); } private async Task HasRemoteUnlimitedAccountsAsync(CancellationToken cancellationToken) { var response = await GetCurrentUserAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccess || response.Result == null) { return false; } return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts; } [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflected contract property is a stable API field read from a concrete DTO instance.")] private static bool TryGetBooleanProperty(object instance, string propertyName, out bool value) { value = false; var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public); if (property?.PropertyType != typeof(bool)) { return false; } value = (bool)(property.GetValue(instance) ?? false); return true; } 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 }; }