using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;

namespace ROLAC.API.Tests.Services;

public class AuthServiceTests
{
    // -----------------------------------------------------------------------
    // Factory helpers
    // -----------------------------------------------------------------------

    private static AppDbContext BuildDb() =>
        new(new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options);

    private static IConfiguration BuildConfig() =>
        new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "Jwt:RefreshTokenExpiryDays", "30" },
            })
            .Build();

    /// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
    private static Mock<UserManager<AppUser>> BuildUserManager(
        AppUser?         findResult           = null,
        bool             passwordOk           = true,
        IList<string>?   roles                = null,
        IdentityResult?  changePasswordResult = null)
    {
        var store = new Mock<IUserStore<AppUser>>();
        // Remaining ctor params are all optional; Moq passes them via reflection.
#pragma warning disable CS8625
        var mgr = new Mock<UserManager<AppUser>>(
            store.Object, null, null, null, null, null, null, null, null);
#pragma warning restore CS8625

        mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
           .ReturnsAsync(findResult);
        mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
           .ReturnsAsync(findResult);
        mgr.Setup(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
           .ReturnsAsync(passwordOk);
        mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
           .ReturnsAsync(roles ?? new List<string> { "member" });
        mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
           .ReturnsAsync(IdentityResult.Success);
        mgr.Setup(m => m.ChangePasswordAsync(
                It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
           .ReturnsAsync(changePasswordResult ?? IdentityResult.Success);

        return mgr;
    }

    /// <summary>
    /// ITokenService mock: GenerateAccessToken → "access-token",
    /// GenerateRefreshToken → "raw-refresh", HashToken(x) → "hash:{x}".
    /// </summary>
    private static Mock<ITokenService> BuildTokenService()
    {
        var svc = new Mock<ITokenService>();
        svc.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>()))
           .Returns("access-token");
        svc.Setup(t => t.GenerateRefreshToken())
           .Returns("raw-refresh");
        svc.Setup(t => t.HashToken(It.IsAny<string>()))
           .Returns<string>(s => $"hash:{s}");
        return svc;
    }

    /// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
    private static Mock<IPermissionService> BuildPermissionService()
    {
        var svc = new Mock<IPermissionService>();
        svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
           .ReturnsAsync(new Dictionary<string, ModuleActions>());
        return svc;
    }

    private static AuthService BuildSut(
        Mock<UserManager<AppUser>> umMock,
        Mock<ITokenService>        tsMock,
        AppDbContext                db)
        => new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object,
               ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig());

    // -----------------------------------------------------------------------
    // Login tests
    // -----------------------------------------------------------------------

    [Fact]
    public async Task Login_ValidCredentials_ReturnsAccessTokenAndRefreshToken()
    {
        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
        var um   = BuildUserManager(findResult: user, roles: new[] { "member" });
        var ts   = BuildTokenService();
        var db   = BuildDb();
        var sut  = BuildSut(um, ts, db);

        var (response, raw) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });

        Assert.Equal("access-token", response.AccessToken);
        Assert.Equal(900, response.ExpiresIn);
        Assert.Equal("u1",    response.User.Id);
        Assert.Equal("a@b.com", response.User.Email);
        Assert.Contains("member", response.User.Roles);
        Assert.Equal("raw-refresh", raw);

        // Persisted in DB
        var stored = db.RefreshTokens.Single();
        Assert.Equal("hash:raw-refresh", stored.TokenHash);
        Assert.Equal("u1", stored.UserId);
    }

    [Fact]
    public async Task Login_UnknownEmail_ThrowsUnauthorized()
    {
        var um  = BuildUserManager(findResult: null);
        var ts  = BuildTokenService();
        var sut = BuildSut(um, ts, BuildDb());

        await Assert.ThrowsAsync<UnauthorizedAccessException>(
            () => sut.LoginAsync(new LoginRequest { Email = "nope@b.com", Password = "P@ssw0rd!" }));
    }

    [Fact]
    public async Task Login_WrongPassword_ThrowsUnauthorized()
    {
        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
        var um   = BuildUserManager(findResult: user, passwordOk: false);
        var ts   = BuildTokenService();
        var sut  = BuildSut(um, ts, BuildDb());

        await Assert.ThrowsAsync<UnauthorizedAccessException>(
            () => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "wrong" }));
    }

    [Fact]
    public async Task Login_InactiveAccount_ThrowsUnauthorized()
    {
        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = false };
        var um   = BuildUserManager(findResult: user, passwordOk: true);
        var ts   = BuildTokenService();
        var sut  = BuildSut(um, ts, BuildDb());

        await Assert.ThrowsAsync<UnauthorizedAccessException>(
            () => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }));
    }

    [Fact]
    public async Task Login_Success_UpdatesLastLoginAt()
    {
        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
        var um   = BuildUserManager(findResult: user);
        var ts   = BuildTokenService();
        var sut  = BuildSut(um, ts, BuildDb());

        await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });

        um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
    }

    [Fact]
    public async Task Login_LinkedMember_ReturnsMemberInfo()
    {
        var db = BuildDb();
        db.Members.Add(new Member
        {
            Id           = 7,
            NickName     = "Johnny",
            FirstName_en = "John",
            LastName_en  = "Chen",
            LastName_zh  = "陳",
            CreatedBy    = "seed",
            UpdatedBy    = "seed",
        });
        await db.SaveChangesAsync();

        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 };
        var um   = BuildUserManager(findResult: user);
        var ts   = BuildTokenService();
        var sut  = BuildSut(um, ts, db);

        var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });

        Assert.NotNull(response.User.MemberInfo);
        Assert.Equal(7,        response.User.MemberInfo!.Id);
        Assert.Equal("Johnny", response.User.MemberInfo.NickName);
        Assert.Equal("Chen",   response.User.MemberInfo.LastName_en);
    }

    [Fact]
    public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo()
    {
        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null };
        var um   = BuildUserManager(findResult: user);
        var ts   = BuildTokenService();
        var sut  = BuildSut(um, ts, BuildDb());

        var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });

        Assert.Null(response.User.MemberInfo);
    }

    // -----------------------------------------------------------------------
    // Refresh tests
    // -----------------------------------------------------------------------

    [Fact]
    public async Task Refresh_ValidToken_RotatesRefreshToken()
    {
        const string rawOld = "old-raw";
        const string rawNew = "raw-refresh"; // what BuildTokenService returns

        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
        var um   = BuildUserManager(findResult: user);
        var ts   = BuildTokenService();
        var db   = BuildDb();

        // Seed an active token
        db.RefreshTokens.Add(new RefreshToken
        {
            UserId    = "u1",
            TokenHash = "hash:old-raw",
            ExpiresAt = DateTime.UtcNow.AddDays(30),
            CreatedAt = DateTime.UtcNow.AddHours(-1),
        });
        await db.SaveChangesAsync();

        var sut = BuildSut(um, ts, db);
        var (response, newRaw) = await sut.RefreshAsync(rawOld);

        Assert.Equal("access-token", response.AccessToken);
        Assert.Equal(rawNew, newRaw);

        // Old token revoked
        var old = db.RefreshTokens.First(rt => rt.TokenHash == "hash:old-raw");
        Assert.NotNull(old.RevokedAt);
        Assert.Equal("hash:raw-refresh", old.ReplacedByHash);

        // New token stored
        var newTok = db.RefreshTokens.First(rt => rt.TokenHash == "hash:raw-refresh");
        Assert.Null(newTok.RevokedAt);
    }

    [Fact]
    public async Task Refresh_UnknownToken_ThrowsUnauthorized()
    {
        var um  = BuildUserManager();
        var ts  = BuildTokenService();
        var sut = BuildSut(um, ts, BuildDb());

        await Assert.ThrowsAsync<UnauthorizedAccessException>(
            () => sut.RefreshAsync("no-such-token"));
    }

    [Fact]
    public async Task Refresh_ExpiredToken_ThrowsUnauthorized()
    {
        var db = BuildDb();
        db.RefreshTokens.Add(new RefreshToken
        {
            UserId    = "u1",
            TokenHash = "hash:expired-raw",
            ExpiresAt = DateTime.UtcNow.AddDays(-1),  // already expired
            CreatedAt = DateTime.UtcNow.AddDays(-31),
        });
        await db.SaveChangesAsync();

        var um  = BuildUserManager();
        var ts  = BuildTokenService();
        var sut = BuildSut(um, ts, db);

        await Assert.ThrowsAsync<UnauthorizedAccessException>(
            () => sut.RefreshAsync("expired-raw"));
    }

    // -----------------------------------------------------------------------
    // Logout tests
    // -----------------------------------------------------------------------

    [Fact]
    public async Task Logout_ValidToken_RevokesRefreshToken()
    {
        const string raw = "my-raw-token";

        var db = BuildDb();
        db.RefreshTokens.Add(new RefreshToken
        {
            UserId    = "u1",
            TokenHash = "hash:my-raw-token",
            ExpiresAt = DateTime.UtcNow.AddDays(30),
            CreatedAt = DateTime.UtcNow.AddHours(-1),
        });
        await db.SaveChangesAsync();

        var um  = BuildUserManager();
        var ts  = BuildTokenService();
        var sut = BuildSut(um, ts, db);

        await sut.LogoutAsync(raw);

        var token = db.RefreshTokens.Single();
        Assert.NotNull(token.RevokedAt);
    }

    // -----------------------------------------------------------------------
    // Change password tests
    // -----------------------------------------------------------------------

    [Fact]
    public async Task ChangePassword_ValidRequest_Succeeds()
    {
        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
        var um   = BuildUserManager(findResult: user);
        var ts   = BuildTokenService();
        var sut  = BuildSut(um, ts, BuildDb());

        var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);

        Assert.True(result.Succeeded);
        um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
    }

    [Fact]
    public async Task ChangePassword_UnknownUser_Fails()
    {
        var um  = BuildUserManager(findResult: null);
        var ts  = BuildTokenService();
        var sut = BuildSut(um, ts, BuildDb());

        var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);

        Assert.False(result.Succeeded);
        um.Verify(m => m.ChangePasswordAsync(
            It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
    }

    [Fact]
    public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
    {
        var user   = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
        var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
        var um     = BuildUserManager(findResult: user, changePasswordResult: failed);
        var ts     = BuildTokenService();
        var sut    = BuildSut(um, ts, BuildDb());

        var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);

        Assert.False(result.Succeeded);
    }

    [Fact]
    public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
    {
        var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
        var um   = BuildUserManager(findResult: user);
        var ts   = BuildTokenService();   // HashToken(x) => "hash:{x}"
        var db   = BuildDb();

        // Current session token (raw "current-raw" => "hash:current-raw")
        db.RefreshTokens.Add(new RefreshToken
        {
            UserId    = "u1",
            TokenHash = "hash:current-raw",
            ExpiresAt = DateTime.UtcNow.AddDays(30),
            CreatedAt = DateTime.UtcNow.AddHours(-1),
        });
        // Another active session on a different device
        db.RefreshTokens.Add(new RefreshToken
        {
            UserId    = "u1",
            TokenHash = "hash:other-device",
            ExpiresAt = DateTime.UtcNow.AddDays(30),
            CreatedAt = DateTime.UtcNow.AddHours(-2),
        });
        await db.SaveChangesAsync();

        var sut = BuildSut(um, ts, db);
        await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");

        var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
        var other   = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
        Assert.Null(current.RevokedAt);        // current session preserved
        Assert.NotNull(other.RevokedAt);       // other session revoked
    }
}
