#nullable enable
using Baya.Application.Contracts;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Identity;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;

namespace Baya.Application.Features.Identity.Commands.VerifyOtp;

internal sealed class VerifyOtpCommandHandler(
    IAppUserManager userManager,
    IJwtService jwtService,
    IUnitOfWork unitOfWork,
    IPlatformConfig platformConfig,
    IFieldEncryptor fieldEncryptor,
    IDateTimeProvider clock,
    ICurrentUser currentUser)
    : IRequestHandler<VerifyOtpCommand, OperationResult<AuthTokensResult>>
{
    // One safe message for every wrong-phone/wrong-code combination — no account enumeration.
    private const string InvalidCodeMessage = "The code is invalid or expired.";

    public async ValueTask<OperationResult<AuthTokensResult>> Handle(VerifyOtpCommand request, CancellationToken cancellationToken)
    {
        var phone = IranianPhone.Normalize(request.Phone);
        if (phone is null)
            return OperationResult<AuthTokensResult>.FailureResult(nameof(request.Phone), InvalidCodeMessage);

        var user = await userManager.GetUserByPhoneNumber(phone);
        if (user is null)
            return OperationResult<AuthTokensResult>.FailureResult(InvalidCodeMessage);

        var maxAttempts = await platformConfig.GetConfig<int>(IdentityDefaults.OtpMaxAttemptsKey, cancellationToken);
        if (user.AccessFailedCount >= maxAttempts)
        {
            // Lockout is the one machine-distinguishable state (safe — it reveals nothing about the code or
            // account). The client shows the unlock countdown; a fresh code is gated by the resend window.
            var retryAfterSeconds = await platformConfig.GetConfig<int>(IdentityDefaults.OtpResendSecondsKey, cancellationToken);
            return OperationResult<AuthTokensResult>.CodedFailureResult(
                "otp_locked", "Too many failed attempts. Request a new code.", new { retryAfterSeconds });
        }

        // First-ever verify confirms the phone (ChangePhoneNumber to the same number); afterwards the
        // passwordless TOTP path applies. Both rotate the security stamp, so the token is minted after.
        var wasPhoneConfirmed = user.PhoneNumberConfirmed;
        var verifyResult = wasPhoneConfirmed
            ? await userManager.VerifyUserCode(user, request.Code)
            : await userManager.ChangePhoneNumber(user, phone, request.Code);

        if (!verifyResult.Succeeded)
        {
            await userManager.IncrementAccessFailedCountAsync(user);
            // Wrong and expired stay collapsed behind one code + message (anti-enumeration); only lockout is
            // distinguished (above).
            return OperationResult<AuthTokensResult>.CodedFailureResult("otp_invalid", InvalidCodeMessage);
        }

        var now = clock.UtcNow;
        user.IsActive = true;
        user.PhoneVerifiedAt ??= now;
        await userManager.ResetUserLockoutAsync(user);
        await userManager.UpdateUserAsync(user);

        var roles = await userManager.GetRoleAsync(user);
        var accessToken = await jwtService.GenerateAccessTokenAsync(user);

        var sessionTtlDays = await platformConfig.GetConfig<int>(IdentityDefaults.SessionTtlDaysKey, cancellationToken);
        var (refreshToken, session) = IdentityDefaults.MintSession(
            fieldEncryptor, user.Id, now, sessionTtlDays, request.DeviceInfo, currentUser.IpAddress);

        await unitOfWork.UserSessionRepository.AddAsync(session, cancellationToken);
        await unitOfWork.CommitAsync();

        return OperationResult<AuthTokensResult>.SuccessResult(new AuthTokensResult(
            accessToken.Token,
            refreshToken,
            accessToken.ExpiresAt,
            session.ExpiresAt,
            IsNewUser: !wasPhoneConfirmed,
            Roles: roles));
    }
}