using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;

namespace JobTrackerApi.Tests;

public sealed class AuthSessionRevocationTests
{
    [Fact]
    public async Task Logout_revokes_the_exact_session_and_a_copied_principal_stops_working()
    {
        using var db = TestHostFactory.CreateInMemoryDb(null);
        db.Users.AddRange(
            new ApplicationUser { Id = "user-1", UserName = "one@example.test", Email = "one@example.test" },
            new ApplicationUser { Id = "user-2", UserName = "two@example.test", Email = "two@example.test" });
        db.UserSessions.Add(Session("sid-current", "user-1"));
        db.UserSessions.Add(Session("sid-other", "user-2"));
        await db.SaveChangesAsync();
        var controller = CreateAuthController(db, TestHostFactory.CreateUserManager()) ;
        controller.HttpContext.User = Principal("user-1", "sid-current");

        Assert.IsType<NoContentResult>(await controller.Logout(CancellationToken.None));

        Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-current"), DateTimeOffset.UtcNow));
        Assert.True(await LocalSessionValidator.IsValidAsync(db, Principal("user-2", "sid-other"), DateTimeOffset.UtcNow));
    }

    [Fact]
    public async Task Logout_best_effort_revokes_an_expired_cookie_without_an_authenticated_principal()
    {
        using var db = TestHostFactory.CreateInMemoryDb(null);
        db.UserSessions.Add(Session("sid-expired-cookie", "user-1"));
        await db.SaveChangesAsync();
        var controller = CreateAuthController(db, TestHostFactory.CreateUserManager());
        var expired = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
            claims: new[]
            {
                new Claim(ClaimTypes.NameIdentifier, "user-1"),
                new Claim("sid", "sid-expired-cookie"),
            },
            expires: DateTime.UtcNow.AddMinutes(-10)));
        controller.Request.Headers.Cookie = $"{AuthSessionOptions.SessionCookieName}={expired}";

        Assert.IsType<NoContentResult>(await controller.Logout(CancellationToken.None));

        Assert.NotNull((await db.UserSessions.IgnoreQueryFilters().SingleAsync()).RevokedAtUtc);
    }

    [Fact]
    public async Task Password_reset_revokes_all_target_sessions_and_trusted_devices_but_preserves_two_factor()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "still-present" };
        var users = TestHostFactory.CreateUserManager();
        users.Setup(x => x.FindByEmailAsync(user.Email)).ReturnsAsync(user);
        users.Setup(x => x.ResetPasswordAsync(user, "valid-token", "new-password")).ReturnsAsync(Microsoft.AspNetCore.Identity.IdentityResult.Success);
        using var db = TestHostFactory.CreateInMemoryDb(null);
        db.UserSessions.AddRange(Session("sid-a", user.Id), Session("sid-b", user.Id), Session("sid-other", "user-2"));
        db.TrustedDevices.AddRange(Device(user.Id, "hash-a"), Device(user.Id, "hash-b"), Device("user-2", "hash-other"));
        await db.SaveChangesAsync();
        var controller = CreateAuthController(db, users);

        Assert.IsType<NoContentResult>(await controller.ResetPassword(
            new AuthController.ResetPasswordRequest(user.Email, "valid-token", "new-password"), CancellationToken.None));

        Assert.All(await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync(), x => Assert.NotNull(x.RevokedAtUtc));
        Assert.Null((await db.UserSessions.IgnoreQueryFilters().SingleAsync(x => x.UserId == "user-2")).RevokedAtUtc);
        Assert.Empty(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync());
        Assert.Single(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-2").ToListAsync());
        Assert.True(user.TwoFactorEnabled);
        Assert.Equal("still-present", user.TotpSecretEncrypted);
    }

    [Fact]
    public async Task Password_change_rotates_the_session_and_keeps_only_the_current_trusted_device()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
        var users = TestHostFactory.CreateUserManager();
        users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
        users.Setup(x => x.ChangePasswordAsync(user, "old-password", "new-password"))
            .ReturnsAsync(Microsoft.AspNetCore.Identity.IdentityResult.Success);
        var tokens = new Mock<ITokenService>();
        tokens.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("new-jwt");
        using var db = TestHostFactory.CreateInMemoryDb(null);
        db.UserSessions.AddRange(Session("sid-current", user.Id), Session("sid-other", user.Id));
        const string trustedToken = "current-device-token";
        var currentHash = TrustedDeviceService.HashToken(trustedToken);
        db.TrustedDevices.AddRange(Device(user.Id, currentHash), Device(user.Id, "other-hash"), Device("user-2", "other-user-hash"));
        await db.SaveChangesAsync();
        var controller = CreateAuthController(db, users, tokens.Object);
        controller.HttpContext.User = Principal(user.Id, "sid-current");
        controller.Request.Headers.Cookie = $"{AuthSessionOptions.TrustedDeviceCookieName}={trustedToken}";

        Assert.IsType<NoContentResult>(await controller.ChangePassword(
            new AuthController.ChangePasswordRequest("old-password", "new-password"), CancellationToken.None));

        var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync();
        Assert.Equal(2, sessions.Count(x => x.RevokedAtUtc is not null));
        Assert.Single(sessions, x => x.RevokedAtUtc is null);
        Assert.Equal(currentHash, (await db.TrustedDevices.IgnoreQueryFilters().SingleAsync(x => x.UserId == user.Id)).TokenHash);
        Assert.Single(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-2").ToListAsync());
    }

    [Fact]
    public async Task Session_validator_requires_sid_and_user_to_match_the_same_row()
    {
        using var db = TestHostFactory.CreateInMemoryDb(null);
        db.UserSessions.Add(Session("sid-user-1", "user-1"));
        await db.SaveChangesAsync();

        Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-2", "sid-user-1"), DateTimeOffset.UtcNow));
    }

    [Fact]
    public async Task Session_validator_rejects_an_unconfirmed_account_when_verification_is_required()
    {
        using var db = TestHostFactory.CreateInMemoryDb(null);
        db.Users.Add(new ApplicationUser { Id = "user-1", Email = "pending@example.com", UserName = "pending@example.com", EmailConfirmed = false });
        db.UserSessions.Add(Session("sid-pending", "user-1"));
        await db.SaveChangesAsync();

        Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-pending"), DateTimeOffset.UtcNow, requireConfirmedEmail: true));
        Assert.True(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-pending"), DateTimeOffset.UtcNow));
    }

    [Fact]
    public async Task Session_validator_rejects_an_account_pending_deletion()
    {
        using var db = TestHostFactory.CreateInMemoryDb(null);
        db.Users.Add(new ApplicationUser { Id = "user-1", Email = "pending-delete@example.test", UserName = "pending-delete@example.test", DeletionStatus = AccountDeletionStatuses.Pending });
        db.UserSessions.Add(Session("sid-delete", "user-1"));
        await db.SaveChangesAsync();

        Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-delete"), DateTimeOffset.UtcNow));
    }

    [Fact]
    public async Task Password_security_stamp_change_invalidates_a_pending_two_factor_challenge()
    {
        var user = new ApplicationUser
        {
            Id = "user-1",
            TwoFactorEnabled = true,
            TotpSecretEncrypted = "not-read-on-stamp-mismatch",
            SecurityStamp = "new-stamp",
        };
        var users = TestHostFactory.CreateUserManager(user);
        var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
        var pendingToken = pending.IssuePendingToken(user.Id, rememberMe: false, securityStamp: "old-stamp");
        using var db = TestHostFactory.CreateInMemoryDb(null);
        var controller = new TwoFactorController(
            users.Object,
            Mock.Of<ITokenService>(),
            db,
            pending,
            new EphemeralDataProtectionProvider(),
            BuildConfig())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
        };

        Assert.IsType<UnauthorizedResult>(await controller.Challenge(
            new TwoFactorController.ChallengeRequest(pendingToken, "123456"), CancellationToken.None));
        Assert.Null(pending.Resolve(pendingToken, consume: false));
    }

    private static AuthController CreateAuthController(
        JobTrackerApi.Data.JobTrackerContext db,
        Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>> users,
        ITokenService? tokens = null) =>
        new(
            BuildConfig(),
            users.Object,
            tokens ?? Mock.Of<ITokenService>(),
            Mock.Of<IAppEmailSender>(),
            Mock.Of<IGoogleTokenValidator>(),
            Mock.Of<IMicrosoftTokenValidator>(),
            NullLogger<AuthController>.Instance,
            Mock.Of<ITwoFactorPendingTokenService>(),
            db)
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
        };

    private static IConfiguration BuildConfig() => new ConfigurationBuilder().AddInMemoryCollection().Build();

    private static UserSession Session(string id, string userId) => new()
    {
        Id = id,
        UserId = userId,
        CreatedAtUtc = DateTimeOffset.UtcNow,
        LastSeenAtUtc = DateTimeOffset.UtcNow,
        ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1),
    };

    private static TrustedDevice Device(string userId, string hash) => new()
    {
        UserId = userId,
        TokenHash = hash,
        CreatedAtUtc = DateTimeOffset.UtcNow,
        LastSeenAtUtc = DateTimeOffset.UtcNow,
        ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30),
    };

    private static ClaimsPrincipal Principal(string userId, string sid) => new(new ClaimsIdentity(new[]
    {
        new Claim(ClaimTypes.NameIdentifier, userId),
        new Claim("sid", sid),
    }, "local"));
}
