using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Mirea.Api.Security.Common;
using Mirea.Api.Security.Common.Domain;
using Mirea.Api.Security.Common.Domain.Caching;
using Mirea.Api.Security.Common.Interfaces;
using Mirea.Api.Security.Common.Model;
using System;
using System.Security;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using CookieOptions = Mirea.Api.Security.Common.Model.CookieOptions;

namespace Mirea.Api.Security.Services;

public class AuthService(ICacheService cache, IAccessToken accessTokenService, IRevokedToken revokedToken, ILogger<AuthService> logger,
    PasswordHashService passwordService)
{
    public TimeSpan Lifetime { private get; init; }
    public TimeSpan LifetimeFirstAuth { private get; init; }

    private static string GenerateRefreshToken() => Guid.NewGuid().ToString().Replace("-", "") +
                                                    GeneratorKey.GenerateString(32);
    private (string Token, DateTime ExpireIn) GenerateAccessToken(string userId) =>
        accessTokenService.GenerateToken(userId);

    private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token";
    private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
    private static string GetAttemptFailedCountKey(string fingerprint) => $"{fingerprint}_login_failed";

    private Task StoreAuthTokenInCache(AuthToken data, CancellationToken cancellation) =>
        cache.SetAsync(
            GetAuthCacheKey(data.Fingerprint),
            JsonSerializer.SerializeToUtf8Bytes(data),
            slidingExpiration: Lifetime,
            cancellationToken: cancellation);

    private Task StoreFirstAuthTokenInCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) =>
        cache.SetAsync(
            GetFirstAuthCacheKey(requestContext.Fingerprint),
            JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
            {
                UserId = data.Id,
                Secret = data.SecondFactorToken,
                TwoFactorAuthenticator = data.TwoFactorAuthenticator
            }),
            slidingExpiration: LifetimeFirstAuth,
            cancellationToken: cancellation);

    private Task RevokeAccessToken(string token) =>
        revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));

    private async Task RecordFailedLoginAttempt(string fingerprint, string userId, CancellationToken cancellation)
    {
        var failedLoginAttemptsCount = await cache.GetAsync<int?>(GetAttemptFailedCountKey(fingerprint), cancellation) ?? 1;
        var failedLoginCacheExpiration = TimeSpan.FromHours(1);

        if (failedLoginAttemptsCount > 5)
        {
            logger.LogWarning(
                "Multiple unsuccessful login attempts for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
                userId,
                fingerprint,
                failedLoginAttemptsCount);

            throw new SecurityException("Too many unsuccessful login attempts. Please try again later.");
        }

        logger.LogInformation(
            "Login attempt failed for user ID {UserId}. Fingerprint: {Fingerprint}. Attempt count: {AttemptNumber}.",
            userId,
            fingerprint,
            failedLoginAttemptsCount);

        await cache.SetAsync(GetAttemptFailedCountKey(fingerprint), failedLoginAttemptsCount + 1,
            slidingExpiration: failedLoginCacheExpiration, cancellationToken: cancellation);
    }

    private Task ResetFailedLoginAttempts(string fingerprint, CancellationToken cancellation) =>
        cache.RemoveAsync(GetAttemptFailedCountKey(fingerprint), cancellation);

    private async Task VerifyUserOrThrowError(RequestContextInfo requestContext, User user, string password, string username,
        CancellationToken cancellation = default)
    {
        if ((user.Email.Equals(username, StringComparison.OrdinalIgnoreCase) ||
             user.Username.Equals(username, StringComparison.OrdinalIgnoreCase)) &&
            passwordService.VerifyPassword(password, user.Salt, user.PasswordHash))
        {
            await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
            return;
        }

        await RecordFailedLoginAttempt(requestContext.Fingerprint, user.Id, cancellation);

        throw new SecurityException("Authentication failed. Please check your credentials.");
    }

    private async Task GenerateAuthTokensAsync(CookieOptions cookieOptions, HttpContext context,
        RequestContextInfo requestContext, string userId, CancellationToken cancellation = default)
    {
        var refreshToken = GenerateRefreshToken();
        var (token, expireIn) = GenerateAccessToken(userId);

        var authToken = new AuthToken(requestContext)
        {
            CreatedAt = DateTime.UtcNow,
            RefreshToken = refreshToken,
            UserId = userId,
            AccessToken = token
        };

        await StoreAuthTokenInCache(authToken, cancellation);
        cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
        cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));

        logger.LogInformation(
            "Login successful for user ID {UserId}. Fingerprint: {Fingerprint}.",
            authToken.UserId,
            authToken.Fingerprint);
    }

    public async Task<bool> LoginAsync(CookieOptions cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code,
        CancellationToken cancellation = default)
    {
        var requestContext = new RequestContextInfo(context, cookieOptions);

        var firstTokenAuth = await cache.GetAsync<FirstAuthToken?>(GetFirstAuthCacheKey(requestContext.Fingerprint), cancellationToken: cancellation);

        if (firstTokenAuth == null || authenticator != firstTokenAuth.TwoFactorAuthenticator)
            throw new SecurityException("Session expired. Please log in again.");

        switch (firstTokenAuth.TwoFactorAuthenticator)
        {
            case TwoFactorAuthenticator.Totp:
                {
                    if (string.IsNullOrEmpty(firstTokenAuth.Secret))
                    {
                        logger.LogWarning("The user {Fingerprint} for {UserId} tried to pass the 2FA even though the secret is empty",
                            requestContext.Fingerprint,
                            firstTokenAuth.UserId);

                        throw new InvalidOperationException("Required authentication data is missing.");
                    }

                    var totp = new TotpService(firstTokenAuth.Secret);

                    if (!totp.VerifyToken(code))
                    {
                        await RecordFailedLoginAttempt(requestContext.Fingerprint, firstTokenAuth.UserId, cancellation);
                        throw new SecurityException("Invalid verification code. Please try again.");
                    }

                    await ResetFailedLoginAttempts(requestContext.Fingerprint, cancellation);
                }
                break;
            default:
                throw new InvalidOperationException("Unsupported authorization method.");
        }

        await GenerateAuthTokensAsync(cookieOptions, context, requestContext, firstTokenAuth.UserId, cancellation);
        return true;
    }

    private async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions,
        HttpContext context,
        User user,
        CancellationToken cancellation = default)
    {
        var requestContext = new RequestContextInfo(context, cookieOptions);

        if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
        {
            await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
            return TwoFactorAuthenticator.None;
        }

        await StoreFirstAuthTokenInCache(user, requestContext, cancellation);

        return user.TwoFactorAuthenticator;
    }

    public Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptions cookieOptions,
        HttpContext context,
        User user,
        OAuthUser oAuthUser,
        OAuthProvider provider,
        CancellationToken cancellation = default)
    {
        if (user.OAuthProviders == null || !user.OAuthProviders.TryGetValue(provider, out var value))
            throw new SecurityException($"This provider '{Enum.GetName(provider)}' is not linked to the account.");

        if (value.Id != oAuthUser.Id)
            throw new SecurityException("This account was not linked");

        return LoginAsync(cookieOptions, context, user, cancellation);
    }

    public async Task<TwoFactorAuthenticator> LoginAsync(CookieOptions cookieOptions,
        HttpContext context,
        User user,
        string password,
        string username,
        CancellationToken cancellation = default)
    {
        var requestContext = new RequestContextInfo(context, cookieOptions);
        username = username.Trim();
        await VerifyUserOrThrowError(requestContext, user, password, username, cancellation);

        return await LoginAsync(cookieOptions, context, user, cancellation);
    }

    public async Task RefreshTokenAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default)
    {
        const string defaultMessageError = "The session time has expired";
        var requestContext = new RequestContextInfo(context, cookieOptions);
        var authToken = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation) ??
                        throw new SecurityException(defaultMessageError);

        if (authToken.RefreshToken != requestContext.RefreshToken ||
            authToken.UserAgent != requestContext.UserAgent &&
            authToken.Ip != requestContext.Ip)
        {
            await RevokeAccessToken(authToken.AccessToken);
            await cache.RemoveAsync(GetAuthCacheKey(requestContext.Fingerprint), cancellation);
            cookieOptions.DropCookie(context, CookieNames.AccessToken);
            cookieOptions.DropCookie(context, CookieNames.RefreshToken);

            logger.LogWarning("Token validation failed for user ID {UserId}. Fingerprint: {Fingerprint}. " +
                              "RefreshToken: {ExpectedRefreshToken} -> {RefreshToken}, " +
                              "UserAgent: {ExpectedUserAgent} -> {ProvidedUserAgent}, " +
                              "Ip: {ExpectedUserIp} -> {ProvidedIp}",
                authToken.UserId,
                authToken.Fingerprint,
                authToken.RefreshToken,
                requestContext.RefreshToken,
                authToken.UserAgent,
                requestContext.UserAgent,
                authToken.Ip,
                requestContext.Ip);

            throw new SecurityException(defaultMessageError);
        }

        if (authToken.UserAgent != requestContext.UserAgent)
        {
            logger.LogInformation("The resulting User-Agent {ProvidedUserAgent} does not match the cached " +
                                  "{ExpectedUserAgent} of the user {UserId} with the fingerprint {Fingerprint}.",
                requestContext.UserAgent,
                authToken.UserAgent,
                authToken.UserId,
                requestContext.Fingerprint);

            authToken.UserAgent = requestContext.UserAgent;
        }

        if (authToken.Ip != requestContext.Ip)
        {
            logger.LogInformation("The resulting Ip {ProvidedIp} does not match the cached " +
                                  "{ExpectedIp} of the user {UserId} with the fingerprint {Fingerprint}.",
                requestContext.Ip,
                authToken.Ip,
                authToken.UserId,
                requestContext.Fingerprint);

            authToken.Ip = requestContext.Ip;
        }

        var (token, expireIn) = GenerateAccessToken(authToken.UserId);
        await RevokeAccessToken(authToken.AccessToken);

        var newRefreshToken = GenerateRefreshToken();

        authToken.AccessToken = token;
        authToken.RefreshToken = newRefreshToken;

        await StoreAuthTokenInCache(authToken, cancellation);
        cookieOptions.SetCookie(context, CookieNames.AccessToken, authToken.AccessToken, expireIn);
        cookieOptions.SetCookie(context, CookieNames.RefreshToken, authToken.RefreshToken, DateTime.UtcNow.Add(Lifetime));
    }

    public async Task LogoutAsync(CookieOptions cookieOptions, HttpContext context, CancellationToken cancellation = default)
    {
        var requestContext = new RequestContextInfo(context, cookieOptions);

        cookieOptions.DropCookie(context, CookieNames.AccessToken);
        cookieOptions.DropCookie(context, CookieNames.RefreshToken);

        var authTokenStruct = await cache.GetAsync<AuthToken>(GetAuthCacheKey(requestContext.Fingerprint), cancellation);

        if (authTokenStruct == null)
            return;

        await RevokeAccessToken(authTokenStruct.AccessToken);
        await cache.RemoveAsync(requestContext.Fingerprint, cancellation);
    }
}