#nullable enable using System; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; 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; namespace Wino.Services; public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable { private readonly HttpClient _httpClient; private readonly IDatabaseService _databaseService; private readonly SemaphoreSlim _tokenRefreshLock = new(1, 1); private readonly bool _ownsHttpClient; private const string ApiUrl = "https://localhost:7204/"; // private const string ApiUrl = "https://api.winomail.app/"; public WinoAccountApiClient(IDatabaseService databaseService, HttpClient? httpClient = null) { _databaseService = databaseService; if (httpClient != null) { _httpClient = httpClient; return; } var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = ValidateCertificate }; _httpClient = new HttpClient(handler) { BaseAddress = new Uri(ApiUrl) }; _ownsHttpClient = true; } public Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default) => SendAuthRequestAsync("api/v1/auth/register", new RegisterRequest(email, password), WinoAccountApiJsonContext.Default.RegisterRequest, cancellationToken); public Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default) => SendAuthRequestAsync("api/v1/auth/login", new LoginRequest(email, password), WinoAccountApiJsonContext.Default.LoginRequest, cancellationToken); public Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) => SendAuthRequestAsync("api/v1/auth/refresh", new RefreshRequest(refreshToken), WinoAccountApiJsonContext.Default.RefreshRequest, cancellationToken); public Task> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default) => SendAnonymousRequestAsync( HttpMethod.Post, endpoint, new ResendEmailConfirmationRequest(ticket), WinoAccountApiJsonContext.Default.ResendEmailConfirmationRequest, WinoAccountApiJsonContext.Default.ApiEnvelopeEmailConfirmationResendResultDto, cancellationToken); public Task> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default) => SendAnonymousRequestAsync( HttpMethod.Post, "api/v1/auth/forgot-password", new ForgotPasswordRequest(email), WinoAccountApiJsonContext.Default.ForgotPasswordRequest, WinoAccountApiJsonContext.Default.ApiEnvelopeJsonElement, cancellationToken); public async Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default) { try { using var response = await _httpClient.PostAsJsonAsync( "api/v1/auth/logout", new LogoutRequest(refreshToken), WinoAccountApiJsonContext.Default.LogoutRequest, cancellationToken).ConfigureAwait(false); var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var envelope = string.IsNullOrWhiteSpace(payload) ? null : JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeJsonElement); return envelope ?? ApiEnvelope.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim()); } catch (Exception ex) { return ApiEnvelope.Failure(ex.Message); } } public Task> GetCurrentUserAsync(CancellationToken cancellationToken = default) => SendAuthorizedRequestAsync("api/v1/auth/me", WinoAccountApiJsonContext.Default.ApiEnvelopeAuthUserDto, cancellationToken); public Task> GetAiStatusAsync(CancellationToken cancellationToken = default) => SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken); public Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) { var endpoint = productId switch { WinoAddOnProductType.AI_PACK => "api/v1/billing/ai-pack/checkout-session", WinoAddOnProductType.UNLIMITED_ACCOUNTS => "api/v1/billing/unlimited-accounts/checkout-session", _ => string.Empty }; return string.IsNullOrWhiteSpace(endpoint) ? Task.FromResult(ApiEnvelope.Failure("UnknownProduct")) : SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeCheckoutSessionResultDto, cancellationToken); } public Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default) => SendAuthorizedRequestAsync( HttpMethod.Post, "api/v1/billing/ai-pack/customer-portal-session", WinoAccountApiJsonContext.Default.ApiEnvelopeCustomerPortalResultDto, cancellationToken); public async Task GetSettingsAsync(CancellationToken cancellationToken = default) { try { using var response = await SendAuthorizedAsync( () => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings"), cancellationToken).ConfigureAwait(false); if (response == null) return null; if (response.StatusCode == System.Net.HttpStatusCode.NoContent) return null; if (!response.IsSuccessStatusCode) return null; return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } catch { return null; } } public async Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default) { try { using var response = await SendAuthorizedAsync( () => CreateAuthorizedRequestAsync( HttpMethod.Put, "api/v1/users/me/settings", () => new StringContent(settingsJson, Encoding.UTF8, "application/json")), cancellationToken).ConfigureAwait(false); if (response == null) return false; return response.IsSuccessStatusCode; } catch { return false; } } private async Task> SendAuthRequestAsync(string endpoint, TRequest request, JsonTypeInfo typeInfo, CancellationToken cancellationToken) { try { using var response = await _httpClient.PostAsJsonAsync( endpoint, request, typeInfo, cancellationToken).ConfigureAwait(false); var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var envelope = string.IsNullOrWhiteSpace(payload) ? null : JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeAuthResultDto); if (envelope?.IsSuccess == true && envelope.Result != null) { return WinoAccountApiResult.Success(envelope.Result); } var errorCode = envelope?.ErrorCode ?? $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim(); var errorMessage = ExtractErrorMessage(payload) ?? response.ReasonPhrase; var errorDetails = ExtractDetails(payload); return WinoAccountApiResult.Failure(errorCode, errorMessage, errorDetails); } catch (Exception ex) { return WinoAccountApiResult.Failure(ex.GetType().Name, ex.Message); } } private static string? ExtractErrorMessage(string? payload) { if (string.IsNullOrWhiteSpace(payload)) { return null; } try { using var document = JsonDocument.Parse(payload); return TryGetErrorMessage(document.RootElement); } catch (JsonException) { return null; } } private static JsonElement? ExtractDetails(string? payload) { if (string.IsNullOrWhiteSpace(payload)) { return null; } try { using var document = JsonDocument.Parse(payload); if (document.RootElement.ValueKind != JsonValueKind.Object || !document.RootElement.TryGetProperty("details", out var details)) { return null; } return details.Clone(); } catch (JsonException) { return null; } } private static string? TryGetErrorMessage(JsonElement element) { if (TryGetStringProperty(element, "errorMessage", out var errorMessage)) { return errorMessage; } if (TryGetStringProperty(element, "message", out var message)) { return message; } if (TryGetStringProperty(element, "detail", out var detail)) { return detail; } if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("error", out var errorElement)) { return TryGetErrorMessage(errorElement); } return null; } private static bool TryGetStringProperty(JsonElement element, string propertyName, out string? value) { value = null; if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property)) { return false; } if (property.ValueKind != JsonValueKind.String) { return false; } value = property.GetString(); return !string.IsNullOrWhiteSpace(value); } private Task> SendAuthorizedRequestAsync(string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken) => SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken); private async Task> SendAnonymousRequestAsync(HttpMethod method, string endpoint, TRequest requestBody, JsonTypeInfo requestTypeInfo, JsonTypeInfo> responseTypeInfo, CancellationToken cancellationToken) { try { using var request = new HttpRequestMessage(method, endpoint) { Content = JsonContent.Create(requestBody, requestTypeInfo) }; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var envelope = string.IsNullOrWhiteSpace(payload) ? null : JsonSerializer.Deserialize(payload, responseTypeInfo); return envelope ?? ApiEnvelope.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim()); } catch (Exception ex) { return ApiEnvelope.Failure(ex.Message); } } private async Task> SendAuthorizedRequestAsync(HttpMethod method, string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken) { try { using var response = await SendAuthorizedAsync( () => CreateAuthorizedRequestAsync(method, endpoint), cancellationToken).ConfigureAwait(false); if (response == null) return ApiEnvelope.Failure("MissingAccessToken"); var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var envelope = string.IsNullOrWhiteSpace(payload) ? null : JsonSerializer.Deserialize(payload, typeInfo); return envelope ?? ApiEnvelope.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim()); } catch (Exception ex) { return ApiEnvelope.Failure(ex.Message); } } private async Task CreateAuthorizedRequestAsync(HttpMethod method, string endpoint, Func? contentFactory = null) { var accessToken = await GetAccessTokenAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(accessToken)) return null; var request = new HttpRequestMessage(method, endpoint); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); request.Content = contentFactory?.Invoke(); return request; } private async Task SendAuthorizedAsync(Func> requestFactory, CancellationToken cancellationToken) { using var initialRequest = await requestFactory().ConfigureAwait(false); if (initialRequest == null) { return null; } var response = await _httpClient.SendAsync(initialRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) { return response; } if (!await TryRefreshAccessTokenAsync(cancellationToken).ConfigureAwait(false)) { return response; } response.Dispose(); using var retryRequest = await requestFactory().ConfigureAwait(false); if (retryRequest == null) { return null; } return await _httpClient.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false); } private async Task GetAccessTokenAsync() { var account = await _databaseService.Connection.Table().FirstOrDefaultAsync().ConfigureAwait(false); return string.IsNullOrWhiteSpace(account?.AccessToken) ? null : account.AccessToken; } private async Task TryRefreshAccessTokenAsync(CancellationToken cancellationToken) { await _tokenRefreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { var account = await _databaseService.Connection.Table().FirstOrDefaultAsync().ConfigureAwait(false); if (account == null || string.IsNullOrWhiteSpace(account.RefreshToken)) { return false; } if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1)) { return true; } var refreshResult = await RefreshAsync(account.RefreshToken, cancellationToken).ConfigureAwait(false); if (!refreshResult.IsSuccess || refreshResult.Result == null) { return false; } var refreshedAccount = MapAccount(refreshResult.Result, account.LastAuthenticatedUtc); await _databaseService.Connection.DeleteAllAsync().ConfigureAwait(false); await _databaseService.Connection.InsertOrReplaceAsync(refreshedAccount, typeof(WinoAccount)).ConfigureAwait(false); return true; } finally { _tokenRefreshLock.Release(); } } private static WinoAccount MapAccount(AuthResultDto result, DateTime lastAuthenticatedUtc) => 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 = lastAuthenticatedUtc == default ? DateTime.UtcNow : lastAuthenticatedUtc }; private static bool ValidateCertificate(HttpRequestMessage requestMessage, X509Certificate2? certificate, X509Chain? chain, System.Net.Security.SslPolicyErrors sslPolicyErrors) { if (requestMessage.RequestUri?.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) == true) { return true; } return sslPolicyErrors == System.Net.Security.SslPolicyErrors.None; } public void Dispose() { if (_ownsHttpClient) { _httpClient.Dispose(); } _tokenRefreshLock.Dispose(); } } [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] [JsonSerializable(typeof(RegisterRequest))] [JsonSerializable(typeof(LoginRequest))] [JsonSerializable(typeof(RefreshRequest))] [JsonSerializable(typeof(LogoutRequest))] [JsonSerializable(typeof(ResendEmailConfirmationRequest))] [JsonSerializable(typeof(ForgotPasswordRequest))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;