using System.Security.Claims;
using System.Net;
using System.Net.Mail;
using System.Net.Mime;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;

namespace PlotLine.Services;

public sealed class PasswordService : IPasswordService
{
    private readonly PasswordHasher<AppUser> _passwordHasher = new();

    public string HashPassword(string password)
    {
        return _passwordHasher.HashPassword(new AppUser(), password);
    }

    public bool VerifyPassword(string password, string passwordHash)
    {
        var result = _passwordHasher.VerifyHashedPassword(new AppUser(), passwordHash, password);
        return result is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded;
    }
}

public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService
{
    private ClaimsPrincipal? User => httpContextAccessor.HttpContext?.User;

    public int? UserId
    {
        get
        {
            var value = User?.FindFirstValue(ClaimTypes.NameIdentifier);
            return int.TryParse(value, out var userId) ? userId : null;
        }
    }

    public string? Email => User?.FindFirstValue(ClaimTypes.Email);

    public string? DisplayName => User?.FindFirstValue(ClaimTypes.Name);

    public bool IsAuthenticated => User?.Identity?.IsAuthenticated == true;
}

public sealed class EmailService(
    IOptions<EmailSettings> emailOptions,
    IConfiguration configuration,
    IEmailQueueRepository emailQueue,
    IHttpContextAccessor httpContextAccessor,
    ILogger<EmailService> logger) : IEmailService
{
    private readonly EmailSettings _settings = emailOptions.Value;

    public Task SendVerificationEmailAsync(string email, string displayName, Guid token)
    {
        var link = BuildAccountUrl("VerifyEmail", token);
        var (plainText, html) = BuildTemplate(
            "Verify your PlotDirector account",
            displayName,
            "Please verify your email address to finish setting up your PlotDirector account.",
            "Verify account",
            link,
            "This link expires in 24 hours.");

        return QueueAsync(email, displayName, "Verify your PlotDirector account", plainText, html);
    }

    public Task SendPasswordResetEmailAsync(string email, string displayName, Guid token)
    {
        var link = BuildAccountUrl("ResetPassword", token);
        var (plainText, html) = BuildTemplate(
            "Reset your PlotDirector password",
            displayName,
            "Use the link below to choose a new password for your PlotDirector account.",
            "Reset password",
            link,
            "This link expires in 24 hours. If you did not request a reset, you can ignore this email.");

        return QueueAsync(email, displayName, "Reset your PlotDirector password", plainText, html);
    }

    public Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
    {
        if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
        {
            return Task.CompletedTask;
        }

        var body = $"""
            Hi {DisplayName(request.UserDisplayName)},

            There is a new reply on your PlotDirector feature request:

            "{request.Title}"

            {messagePreview}

            You can view and reply here:
            {link}

            Thanks,
            The PlotDirector team
            """;

        return QueueAsync(
            request.UserEmail ?? string.Empty,
            DisplayName(request.UserDisplayName),
            $"PlotDirector feature request update: {request.Title}",
            body,
            html: null);
    }

    public Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail)
    {
        if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
        {
            return Task.CompletedTask;
        }

        var body = $"""
            Hi {DisplayName(request.UserDisplayName)},

            The status of your feature request has changed:

            "{request.Title}"

            New status: {request.Status}

            {request.StatusExplanation}

            You can view the request here:
            {link}

            Thanks,
            The PlotDirector team
            """;

        return QueueAsync(
            request.UserEmail ?? string.Empty,
            DisplayName(request.UserDisplayName),
            $"PlotDirector feature request status changed: {request.Title}",
            body,
            html: null);
    }

    public async Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
    {
        var adminEmails = configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
        foreach (var adminEmail in adminEmails
            .Where(email => !string.IsNullOrWhiteSpace(email))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Where(email => !string.Equals(email, actorEmail, StringComparison.OrdinalIgnoreCase)))
        {
            var body = $"""
                A user replied to a PlotDirector feature request:

                "{request.Title}"

                User: {request.UserDisplayName} ({request.UserEmail})

                {messagePreview}

                View the request here:
                {link}
                """;

            await QueueAsync(
                adminEmail,
                "PlotDirector admin",
                $"PlotDirector feature request reply: {request.Title}",
                body,
                html: null);
        }
    }

    private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html)
    {
        if (string.IsNullOrWhiteSpace(toEmail))
        {
            return;
        }

        var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem
        {
            ToEmail = toEmail,
            ToName = CleanDisplayName(displayName),
            FromEmail = GetFromAddress(),
            FromName = CleanDisplayName(_settings.FromName),
            Subject = subject,
            PlainTextBody = plainText,
            HtmlBody = html,
            MaxAttempts = 5
        });

        logger.LogInformation("Queued email {EmailQueueID} with subject '{Subject}' for {Email}.", emailQueueId, subject, toEmail);
    }

    private string BuildAccountUrl(string action, Guid token)
    {
        var request = httpContextAccessor.HttpContext?.Request;
        var host = request?.Host.Value;
        if (string.IsNullOrWhiteSpace(host))
        {
            throw new InvalidOperationException("Cannot build account email link without an active request host.");
        }

        return $"https://{host}/Account/{action}?token={Uri.EscapeDataString(token.ToString("D"))}";
    }

    private static (string PlainText, string Html) BuildTemplate(string title, string displayName, string intro, string buttonText, string link, string footnote)
    {
        var safeName = WebUtility.HtmlEncode(displayName);
        var safeTitle = WebUtility.HtmlEncode(title);
        var safeIntro = WebUtility.HtmlEncode(intro);
        var safeButton = WebUtility.HtmlEncode(buttonText);
        var safeLink = WebUtility.HtmlEncode(link);
        var safeFootnote = WebUtility.HtmlEncode(footnote);

        var plainText = $"""
            {title}

            Hello {displayName},

            {intro}

            {buttonText}: {link}

            {footnote}
            """;

        var html = $"""
            <!doctype html>
            <html>
            <body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
                <div style="max-width:620px;margin:0 auto;background:#fffaf2;border:1px solid #e4d4bd;border-radius:10px;padding:28px;">
                    <p style="margin:0 0 8px;text-transform:uppercase;letter-spacing:.08em;color:#7a5b3b;font-size:12px;font-weight:700;">PlotDirector</p>
                    <h1 style="margin:0 0 16px;font-size:26px;">{safeTitle}</h1>
                    <p>Hello {safeName},</p>
                    <p>{safeIntro}</p>
                    <p style="margin:28px 0;">
                        <a href="{safeLink}" style="display:inline-block;background:#2f6f63;color:#fff;text-decoration:none;padding:12px 18px;border-radius:6px;font-weight:700;">{safeButton}</a>
                    </p>
                    <p>If the button does not work, copy and paste this link into your browser:</p>
                    <p style="word-break:break-all;color:#4d625c;">{safeLink}</p>
                    <p style="color:#6f6256;font-size:14px;">{safeFootnote}</p>
                </div>
            </body>
            </html>
            """;

        return (plainText, html);
    }

    private static string CleanDisplayName(string value)
    {
        return string.IsNullOrWhiteSpace(value) ? "PlotDirector" : value.Trim();
    }

    private static string DisplayName(string? value)
    {
        return string.IsNullOrWhiteSpace(value) ? "there" : value.Trim();
    }

    private string GetFromAddress()
    {
        return string.IsNullOrWhiteSpace(_settings.FromAddress)
            ? "hello@plotdirector.com"
            : _settings.FromAddress.Trim();
    }
}

public interface IQueuedEmailSender
{
    Task SendAsync(EmailQueueItem email, CancellationToken cancellationToken);
}

public sealed class QueuedEmailSender(
    IOptions<EmailSettings> emailOptions,
    ILogger<QueuedEmailSender> logger) : IQueuedEmailSender
{
    private readonly EmailSettings _settings = emailOptions.Value;

    public async Task SendAsync(EmailQueueItem email, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(_settings.SmtpServer)
            || string.IsNullOrWhiteSpace(_settings.Username)
            || string.IsNullOrWhiteSpace(_settings.Password))
        {
            throw new InvalidOperationException("SMTP settings are not configured.");
        }

        using var message = new MailMessage
        {
            From = new MailAddress(email.FromEmail, CleanDisplayName(email.FromName)),
            Subject = email.Subject,
            Body = email.PlainTextBody,
            IsBodyHtml = false
        };
        message.To.Add(new MailAddress(email.ToEmail, CleanDisplayName(email.ToName)));
        message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.PlainTextBody, null, MediaTypeNames.Text.Plain));

        if (!string.IsNullOrWhiteSpace(email.HtmlBody))
        {
            message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.HtmlBody, null, MediaTypeNames.Text.Html));
        }

        using var client = new SmtpClient(_settings.SmtpServer, _settings.Port)
        {
            EnableSsl = _settings.EnableSsl,
            Credentials = new NetworkCredential(_settings.Username, _settings.Password),
            Timeout = 30000
        };

        cancellationToken.ThrowIfCancellationRequested();
        await client.SendMailAsync(message, cancellationToken);
        logger.LogInformation("Queued email {EmailQueueID} sent to {Email}.", email.EmailQueueID, email.ToEmail);
    }

    private static string CleanDisplayName(string? value)
    {
        return string.IsNullOrWhiteSpace(value) ? "PlotDirector" : value.Trim();
    }
}

public sealed class AuthService(
    IUserRepository users,
    IAuthenticationRepository authentication,
    IPasswordService passwords,
    ITwoFactorService twoFactor,
    IEmailService emails,
    IHttpContextAccessor httpContextAccessor,
    ILogger<AuthService> logger) : IAuthService
{
    private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
    private static readonly TimeSpan PendingTwoFactorLifetime = TimeSpan.FromMinutes(10);
    private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
    private const int MaxFailedLoginAttempts = 5;
    private const string SecurityStampClaimType = "PlotLine.SecurityStamp";
    private const string PendingTwoFactorUserIdKey = "PlotLine.PendingTwoFactor.UserID";
    private const string PendingTwoFactorRememberMeKey = "PlotLine.PendingTwoFactor.RememberMe";
    private const string PendingTwoFactorReturnUrlKey = "PlotLine.PendingTwoFactor.ReturnUrl";
    private const string PendingTwoFactorExpiresUtcKey = "PlotLine.PendingTwoFactor.ExpiresUtc";

    public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
    {
        var email = Clean(model.Email);
        var displayName = Clean(model.DisplayName);

        var existingUser = await users.GetByEmailAsync(email);
        if (existingUser is not null)
        {
            return RegistrationResult.Failed("An account already exists for that email address.");
        }

        var passwordHash = passwords.HashPassword(model.Password);
        var created = await users.CreateAsync(email, displayName, passwordHash, emailConfirmed: false);
        var token = Guid.NewGuid();
        await authentication.CreateEmailVerificationTokenAsync(created.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
        await emails.SendVerificationEmailAsync(created.Email, created.DisplayName, token);
        await InsertLoginAuditAsync(created.UserID, created.Email, true, "Email verification requested");
        logger.LogInformation("User {UserID} registered and was sent an email verification token.", created.UserID);

        return RegistrationResult.Succeeded(created.UserID);
    }

    public async Task<AuthenticationResult> LoginAsync(LoginViewModel model)
    {
        var email = Clean(model.Email);
        var user = await users.GetByEmailAsync(email);
        if (user is null)
        {
            await InsertLoginAuditAsync(null, email, false, "Login failed, unknown email");
            return AuthenticationResult.Failed("Invalid email address or password.");
        }

        if (IsLockedOut(user))
        {
            await InsertLoginAuditAsync(user.UserID, email, false, "Login blocked, account locked");
            return AuthenticationResult.Failed("This account is temporarily locked. Please try again later.");
        }

        if (!user.EmailConfirmed)
        {
            await InsertLoginAuditAsync(user.UserID, email, false, "Login blocked, email not verified");
            return AuthenticationResult.Failed("Please verify your email address before signing in.");
        }

        if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
        {
            var lockoutEndUtc = user.FailedLoginAttempts + 1 >= MaxFailedLoginAttempts
                ? DateTime.UtcNow.Add(LockoutDuration)
                : (DateTime?)null;
            await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc);
            await InsertLoginAuditAsync(user.UserID, email, false, lockoutEndUtc.HasValue ? "Login failed, account locked" : "Login failed, bad password");
            return lockoutEndUtc.HasValue
                ? AuthenticationResult.Failed("This account is temporarily locked. Please try again later.")
                : AuthenticationResult.Failed("Invalid email address or password.");
        }

        if (user.TwoFactorEnabled)
        {
            SetPendingTwoFactor(user.UserID, model.RememberMe, model.ReturnUrl);
            await InsertLoginAuditAsync(user.UserID, email, false, "Two-factor required");
            return AuthenticationResult.TwoFactorRequired();
        }

        var updatedUser = await users.UpdateLoginSuccessAsync(user.UserID) ?? user;
        await InsertLoginAuditAsync(user.UserID, email, true, "Login successful");
        await SignInAsync(updatedUser, model.RememberMe);

        return AuthenticationResult.Succeeded();
    }

    public bool HasPendingTwoFactorLogin() => GetPendingTwoFactor() is not null;

    public string? GetPendingTwoFactorReturnUrl() => GetPendingTwoFactor()?.ReturnUrl;

    public async Task LogoutAsync()
    {
        var httpContext = httpContextAccessor.HttpContext;
        if (httpContext is not null)
        {
            var userId = CurrentUserId();
            if (userId.HasValue)
            {
                var email = httpContext.User.FindFirstValue(ClaimTypes.Email);
                await InsertLoginAuditAsync(userId.Value, email ?? string.Empty, true, "Logout");
            }

            await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }

    public async Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorCodeViewModel model)
    {
        var context = await GetPendingTwoFactorContextAsync();
        if (context is null)
        {
            return AuthenticationResult.Failed("Your two-factor login has expired. Please log in again.");
        }

        if (!twoFactor.ValidateCode(context.Value.Settings.SecretKey, model.Code, context.Value.Settings.LastUsedTimeStep, out var matchedTimeStep)
            || !await authentication.UpdateLastUsedTimeStepAsync(context.Value.User.UserID, matchedTimeStep))
        {
            await InsertLoginAuditAsync(context.Value.User.UserID, context.Value.User.Email, false, "2FA challenge failed");
            return AuthenticationResult.Failed("The authentication code was not recognised.");
        }

        return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "2FA challenge succeeded");
    }

    public async Task<AuthenticationResult> CompleteTwoFactorRecoveryLoginAsync(TwoFactorRecoveryViewModel model)
    {
        var context = await GetPendingTwoFactorContextAsync();
        if (context is null)
        {
            return AuthenticationResult.Failed("Your two-factor login has expired. Please log in again.");
        }

        if (!await TryUseRecoveryCodeAsync(context.Value.User.UserID, model.RecoveryCode))
        {
            await InsertLoginAuditAsync(context.Value.User.UserID, context.Value.User.Email, false, "2FA challenge failed");
            return AuthenticationResult.Failed("The recovery code was not recognised.");
        }

        return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "Recovery code used");
    }

    public async Task<AuthenticationResult> VerifyEmailAsync(Guid token)
    {
        var storedToken = await authentication.GetEmailVerificationTokenAsync(token);
        if (storedToken is null)
        {
            logger.LogWarning("Email verification failed because token {Token} was not found.", token);
            return AuthenticationResult.Failed("This verification link is not valid.");
        }

        if (storedToken.UsedUtc.HasValue)
        {
            return AuthenticationResult.Failed("This verification link has already been used.");
        }

        if (storedToken.ExpiryUtc < DateTime.UtcNow)
        {
            return AuthenticationResult.Failed("This verification link has expired.");
        }

        var confirmed = await users.ConfirmEmailAsync(storedToken.UserID, token);
        if (confirmed)
        {
            var verifiedUser = await users.GetByIdAsync(storedToken.UserID);
            await InsertLoginAuditAsync(storedToken.UserID, verifiedUser?.Email ?? string.Empty, true, "Email verified");
        }

        return confirmed
            ? AuthenticationResult.Succeeded()
            : AuthenticationResult.Failed("This verification link could not be used.");
    }

    public async Task<AuthenticationResult> ResendVerificationEmailAsync(string email)
    {
        var cleanedEmail = Clean(email);
        var user = await users.GetByEmailAsync(cleanedEmail);
        if (user is null)
        {
            logger.LogInformation("Verification resend requested for unknown email address {Email}.", cleanedEmail);
            return AuthenticationResult.Succeeded();
        }

        if (user.EmailConfirmed)
        {
            logger.LogInformation("Verification resend requested for already verified user {UserID}.", user.UserID);
            return AuthenticationResult.Succeeded();
        }

        try
        {
            var token = Guid.NewGuid();
            await authentication.CreateEmailVerificationTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
            await emails.SendVerificationEmailAsync(user.Email, user.DisplayName, token);
            await InsertLoginAuditAsync(user.UserID, user.Email, true, "Email verification requested");
            logger.LogInformation("Verification email resent to user {UserID}.", user.UserID);
            return AuthenticationResult.Succeeded();
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Verification email resend failed for user {UserID}.", user.UserID);
            return AuthenticationResult.Failed("We could not send the verification email right now. Please try again in a few minutes.");
        }
    }

    public async Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model)
    {
        var email = Clean(model.Email);
        var user = await users.GetByEmailAsync(email);
        if (user is null)
        {
            logger.LogInformation("Password reset requested for unknown email address {Email}.", email);
            return AuthenticationResult.Succeeded();
        }

        var token = Guid.NewGuid();
        await authentication.CreatePasswordResetTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
        await emails.SendPasswordResetEmailAsync(user.Email, user.DisplayName, token);
        await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password reset requested");
        return AuthenticationResult.Succeeded();
    }

    public async Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model)
    {
        var storedToken = await authentication.GetPasswordResetTokenAsync(model.Token);
        if (storedToken is null)
        {
            logger.LogWarning("Password reset failed because token {Token} was not found.", model.Token);
            return AuthenticationResult.Failed("This password reset link is not valid.");
        }

        if (storedToken.UsedUtc.HasValue)
        {
            return AuthenticationResult.Failed("This password reset link has already been used.");
        }

        if (storedToken.ExpiryUtc < DateTime.UtcNow)
        {
            return AuthenticationResult.Failed("This password reset link has expired.");
        }

        var passwordHash = passwords.HashPassword(model.Password);
        var reset = await users.ResetPasswordAsync(storedToken.UserID, model.Token, passwordHash);
        if (reset)
        {
            var resetUser = await users.GetByIdAsync(storedToken.UserID);
            await InsertLoginAuditAsync(storedToken.UserID, resetUser?.Email ?? string.Empty, true, "Password reset completed");
        }

        return reset
            ? AuthenticationResult.Succeeded()
            : AuthenticationResult.Failed("Your password could not be reset with this link.");
    }

    public async Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
    {
        var userId = CurrentUserId();
        if (!userId.HasValue)
        {
            return AuthenticationResult.Failed("You need to sign in again before changing your password.");
        }

        var user = await users.GetByIdAsync(userId.Value);
        if (user is null)
        {
            return AuthenticationResult.Failed("Your account could not be found.");
        }

        if (!passwords.VerifyPassword(model.CurrentPassword, user.PasswordHash))
        {
            await InsertLoginAuditAsync(user.UserID, user.Email, false, "Password change failed, bad password");
            return AuthenticationResult.Failed("The current password was not recognised.");
        }

        var passwordHash = passwords.HashPassword(model.NewPassword);
        await users.ChangePasswordAsync(user.UserID, passwordHash);
        await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password changed");
        await LogoutAsync();
        return AuthenticationResult.Succeeded();
    }

    public async Task<TwoFactorSetupResult> EnableTwoFactorAsync(int userId, string secretKey, string code)
    {
        var user = await users.GetByIdAsync(userId);
        if (user is null)
        {
            return TwoFactorSetupResult.Failed("Your account could not be found.");
        }

        if (!twoFactor.ValidateCode(secretKey, code, lastUsedTimeStep: null, out var matchedTimeStep))
        {
            return TwoFactorSetupResult.Failed("The authenticator code was not recognised.");
        }

        await authentication.SaveTwoFactorAsync(userId, secretKey, null, DateTime.UtcNow);
        await authentication.UpdateLastUsedTimeStepAsync(userId, matchedTimeStep);
        await authentication.EnableTwoFactorAsync(userId);
        var recoveryCodes = await GenerateRecoveryCodesAsync(userId);
        await InsertLoginAuditAsync(userId, user.Email, true, "2FA enabled");

        return TwoFactorSetupResult.Succeeded(recoveryCodes);
    }

    public async Task<AuthenticationResult> DisableTwoFactorAsync(int userId, string currentPassword)
    {
        var user = await users.GetByIdAsync(userId);
        if (user is null)
        {
            return AuthenticationResult.Failed("Your account could not be found.");
        }

        if (!passwords.VerifyPassword(currentPassword, user.PasswordHash))
        {
            return AuthenticationResult.Failed("The current password was not recognised.");
        }

        await authentication.DisableTwoFactorAsync(userId);
        await InsertLoginAuditAsync(user.UserID, user.Email, true, "2FA disabled");
        return AuthenticationResult.Succeeded();
    }

    public async Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId)
    {
        var recoveryCodes = twoFactor.GenerateRecoveryCodes(10);
        var hashes = recoveryCodes
            .Select(code => passwords.HashPassword(NormalizeRecoveryCode(code)))
            .ToArray();

        await authentication.SaveRecoveryCodesAsync(userId, JsonSerializer.Serialize(hashes));
        var user = await users.GetByIdAsync(userId);
        await InsertLoginAuditAsync(userId, user?.Email ?? string.Empty, true, "Recovery codes regenerated");
        return recoveryCodes;
    }

    private async Task SignInAsync(AppUser user, bool rememberMe)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.UserID.ToString()),
            new(ClaimTypes.Name, user.DisplayName),
            new(ClaimTypes.Email, user.Email),
            new(SecurityStampClaimType, user.SecurityStamp.ToString("D"))
        };

        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);
        var properties = new AuthenticationProperties
        {
            IsPersistent = rememberMe,
            AllowRefresh = true
        };

        var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No active HTTP context is available.");
        await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties);
    }

    private async Task InsertLoginAuditAsync(int? userId, string emailAttempted, bool wasSuccessful, string reason)
    {
        var httpContext = httpContextAccessor.HttpContext;
        await authentication.InsertLoginAuditAsync(new UserLoginAudit
        {
            UserID = userId,
            EmailAttempted = emailAttempted,
            IpAddress = httpContext?.Connection.RemoteIpAddress?.ToString(),
            UserAgent = httpContext?.Request.Headers.UserAgent.ToString(),
            WasSuccessful = wasSuccessful,
            Reason = reason
        });
    }

    private static string Clean(string value) => value.Trim();

    private int? CurrentUserId()
    {
        var value = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
        return int.TryParse(value, out var userId) ? userId : null;
    }

    private static bool IsLockedOut(AppUser user)
    {
        return user.IsLocked && (!user.LockoutEndUtc.HasValue || user.LockoutEndUtc > DateTime.UtcNow);
    }

    private void SetPendingTwoFactor(int userId, bool rememberMe, string? returnUrl)
    {
        var session = GetSession();
        session.SetInt32(PendingTwoFactorUserIdKey, userId);
        session.SetString(PendingTwoFactorRememberMeKey, rememberMe ? "true" : "false");
        session.SetString(PendingTwoFactorReturnUrlKey, returnUrl ?? string.Empty);
        session.SetString(PendingTwoFactorExpiresUtcKey, DateTimeOffset.UtcNow.Add(PendingTwoFactorLifetime).ToString("O"));
    }

    private (int UserID, bool RememberMe, string? ReturnUrl)? GetPendingTwoFactor()
    {
        var session = GetSession();
        var userId = session.GetInt32(PendingTwoFactorUserIdKey);
        var expiresValue = session.GetString(PendingTwoFactorExpiresUtcKey);
        if (!userId.HasValue
            || string.IsNullOrWhiteSpace(expiresValue)
            || !DateTimeOffset.TryParse(expiresValue, out var expires)
            || expires <= DateTimeOffset.UtcNow)
        {
            ClearPendingTwoFactor();
            return null;
        }

        var rememberMe = string.Equals(session.GetString(PendingTwoFactorRememberMeKey), "true", StringComparison.OrdinalIgnoreCase);
        var returnUrl = session.GetString(PendingTwoFactorReturnUrlKey);
        return (userId.Value, rememberMe, string.IsNullOrWhiteSpace(returnUrl) ? null : returnUrl);
    }

    private void ClearPendingTwoFactor()
    {
        var session = GetSession();
        session.Remove(PendingTwoFactorUserIdKey);
        session.Remove(PendingTwoFactorRememberMeKey);
        session.Remove(PendingTwoFactorReturnUrlKey);
        session.Remove(PendingTwoFactorExpiresUtcKey);
    }

    private async Task<(AppUser User, UserTwoFactor Settings, (int UserID, bool RememberMe, string? ReturnUrl) Pending)?> GetPendingTwoFactorContextAsync()
    {
        var pending = GetPendingTwoFactor();
        if (pending is null)
        {
            return null;
        }

        var user = await users.GetByIdAsync(pending.Value.UserID);
        if (user is null || !user.TwoFactorEnabled)
        {
            ClearPendingTwoFactor();
            return null;
        }

        var settings = await authentication.GetTwoFactorAsync(user.UserID);
        if (settings is null)
        {
            ClearPendingTwoFactor();
            return null;
        }

        return (user, settings, pending.Value);
    }

    private async Task<AuthenticationResult> CompletePendingTwoFactorSignInAsync(AppUser user, bool rememberMe, string auditReason)
    {
        var updatedUser = await users.UpdateLoginSuccessAsync(user.UserID) ?? user;
        await InsertLoginAuditAsync(user.UserID, user.Email, true, auditReason);
        await SignInAsync(updatedUser, rememberMe);
        ClearPendingTwoFactor();
        return AuthenticationResult.Succeeded();
    }

    private ISession GetSession()
    {
        return httpContextAccessor.HttpContext?.Session
            ?? throw new InvalidOperationException("No active session is available.");
    }

    private async Task<bool> TryUseRecoveryCodeAsync(int userId, string recoveryCode)
    {
        var stored = await authentication.GetRecoveryCodesAsync(userId);
        if (string.IsNullOrWhiteSpace(stored))
        {
            return false;
        }

        var hashes = JsonSerializer.Deserialize<List<string>>(stored) ?? new List<string>();
        var normalized = NormalizeRecoveryCode(recoveryCode);
        for (var i = 0; i < hashes.Count; i++)
        {
            if (!passwords.VerifyPassword(normalized, hashes[i]))
            {
                continue;
            }

            hashes.RemoveAt(i);
            await authentication.SaveRecoveryCodesAsync(userId, JsonSerializer.Serialize(hashes));
            return true;
        }

        return false;
    }

    private static string NormalizeRecoveryCode(string code)
    {
        return code.Trim().Replace(" ", string.Empty).Replace("-", string.Empty).ToUpperInvariant();
    }
}
