using FozFc.Api.Application.Common; using FozFc.Api.Application.Progress; using FozFc.Api.Domain; using FozFc.Api.Infrastructure.Mongo; using FozFc.Api.Infrastructure.Security; using MongoDB.Bson; namespace FozFc.Api.Application.Auth; public sealed class AuthService : IAuthService { private readonly IDocumentRepository _repository; private readonly IPasswordHasher _passwordHasher; private readonly IJwtTokenService _jwt; private readonly IFirebaseTokenVerifier _firebase; private readonly IXpCalculator _xp; private readonly ILogger _logger; public AuthService( IDocumentRepository repository, IPasswordHasher passwordHasher, IJwtTokenService jwt, IFirebaseTokenVerifier firebase, IXpCalculator xp, ILogger logger) { _repository = repository; _passwordHasher = passwordHasher; _jwt = jwt; _firebase = firebase; _xp = xp; _logger = logger; } public async Task RegisterAsync(RegisterRequest request, CancellationToken ct = default) { var email = (request.Email ?? "").Trim(); var password = request.Password ?? ""; if (string.IsNullOrEmpty(email) || password.Length < 6) throw ApiException.BadRequest("Email and a password of 6+ characters are required."); if (await _repository.FindOneByFieldAsync(Collections.Users, "email", email.ToLowerInvariant(), ct) is not null) throw ApiException.Conflict("Email already registered."); var uid = DocumentRepository.NewId(); var user = NewUserDocument(uid, email, request.DisplayName ?? "", "", "member", "password"); user["passwordHash"] = _passwordHasher.Hash(password); await _repository.InsertAsync(Collections.Users, user, ct); return BuildAuthResponse(user); } public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) { var email = (request.Email ?? "").Trim(); var password = request.Password ?? ""; if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password)) throw ApiException.BadRequest("Email and password are required."); var user = await _repository.FindOneByFieldAsync(Collections.Users, "email", email.ToLowerInvariant(), ct); if (user is null || user.GetValue("provider", "").ToString() != "password" || !user.Contains("passwordHash") || !_passwordHasher.Verify(password, user["passwordHash"].AsString)) { throw ApiException.Unauthorized("Invalid credentials."); } return BuildAuthResponse(user); } public async Task LoginWithGoogleAsync(GoogleRequest request, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(request.IdToken)) throw ApiException.BadRequest("idToken is required."); GoogleUser google; try { google = await _firebase.VerifyAsync(request.IdToken, ct); } catch (Exception ex) { _logger.LogWarning(ex, "Google token verification failed."); throw ApiException.Unauthorized("Google token verification failed."); } var user = await _repository.GetByIdAsync(Collections.Users, google.Uid, ct); if (user is null) { user = NewUserDocument(google.Uid, google.Email, google.DisplayName, google.PhotoUrl, "member", "google"); await _repository.InsertAsync(Collections.Users, user, ct); } else { var update = new BsonDocument { { "provider", "google" } }; if (!string.IsNullOrEmpty(google.DisplayName)) update["displayName"] = google.DisplayName; if (!string.IsNullOrEmpty(google.PhotoUrl)) update["photoURL"] = google.PhotoUrl; if (!string.IsNullOrEmpty(google.Email)) update["email"] = google.Email.ToLowerInvariant(); await _repository.UpdateAsync(Collections.Users, google.Uid, update, ct); user = await _repository.GetByIdAsync(Collections.Users, google.Uid, ct) ?? user; } return BuildAuthResponse(user); } public async Task GetProfileAsync(string uid, CancellationToken ct = default) { var user = await _repository.GetByIdAsync(Collections.Users, uid, ct) ?? throw ApiException.NotFound("User not found."); return ToProfile(user); } public async Task GetRoleAsync(string uid, CancellationToken ct = default) { var user = await _repository.GetByIdAsync(Collections.Users, uid, ct); return user?.GetValue("role", "member").ToString() == "admin" ? "admin" : "member"; } public async Task RefreshProgressAsync(string uid, CancellationToken ct = default) { var user = await _repository.GetByIdAsync(Collections.Users, uid, ct) ?? throw ApiException.NotFound("User not found."); var snapshot = await _xp.ComputeSnapshotAsync(uid, user.GetValue("email", "").ToString() ?? "", ct); await _repository.UpdateAsync(Collections.Users, uid, snapshot, ct); var refreshed = await _repository.GetByIdAsync(Collections.Users, uid, ct) ?? user; return ToProfile(refreshed); } public async Task ChangePasswordAsync(string uid, ChangePasswordRequest request, CancellationToken ct = default) { if ((request.NewPassword ?? "").Length < 6) throw ApiException.BadRequest("New password must be 6+ characters."); var user = await _repository.GetByIdAsync(Collections.Users, uid, ct) ?? throw ApiException.NotFound("User not found."); if (user.GetValue("provider", "").ToString() != "password") throw ApiException.BadRequest("Password change is not available for social logins."); if (!user.Contains("passwordHash") || !_passwordHasher.Verify(request.CurrentPassword ?? "", user["passwordHash"].AsString)) throw ApiException.Unauthorized("Current password is incorrect."); await _repository.UpdateAsync(Collections.Users, uid, new BsonDocument("passwordHash", _passwordHasher.Hash(request.NewPassword!)), ct); } // ---------------- helpers ---------------- private static string IsoNow() => DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); private static BsonDocument NewUserDocument( string uid, string email, string displayName, string photoUrl, string role, string provider) { var rank = FanRank.Compute(0); return new BsonDocument { { "_id", uid }, { "uid", uid }, { "email", email.ToLowerInvariant() }, { "displayName", displayName }, { "photoURL", photoUrl }, { "role", role }, { "provider", provider }, { "xp", 0 }, { "fanRank", rank.FanRank }, { "nextRank", rank.NextRank is null ? BsonNull.Value : rank.NextRank }, { "xpProgressPercent", rank.XpProgressPercent }, { "xpToNextRank", rank.XpToNextRank }, { "totalSpentEUR", 0 }, { "membershipMonths", 0 }, { "createdAt", IsoNow() }, { "xpLastCalculatedAt", IsoNow() }, }; } private BsonDocument BuildAuthResponse(BsonDocument user) { var uid = user["_id"].AsString; var email = user.GetValue("email", "").ToString() ?? ""; var role = user.GetValue("role", "member").ToString() ?? "member"; return new BsonDocument { { "token", _jwt.Issue(uid, email, role) }, { "user", ToPublicUser(user) }, { "profile", ToProfile(user) }, }; } private static BsonDocument ToPublicUser(BsonDocument user) => new() { { "uid", user.GetValue("_id", user.GetValue("uid", "")) }, { "email", user.GetValue("email", "") }, { "displayName", user.GetValue("displayName", "") }, { "photoURL", user.GetValue("photoURL", "") }, }; private static BsonDocument ToProfile(BsonDocument user) { var clone = (BsonDocument)user.DeepClone(); BsonJson.NormalizeForOutput(clone); if (!clone.Contains("uid") && clone.Contains("id")) clone["uid"] = clone["id"]; return clone; } }