using System.Text.Json;
using Microsoft.Extensions.Configuration;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;

namespace ROLAC.API.Tests.Services;

public class TokenServiceTests
{
    private readonly TokenService _sut;

    public TokenServiceTests()
    {
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "Jwt:SecretKey",                "test-secret-key-that-is-at-least-32-characters-long!!" },
                { "Jwt:Issuer",                   "rolac-api-test" },
                { "Jwt:Audience",                 "rolac-client-test" },
                { "Jwt:AccessTokenExpiryMinutes", "15" },
                { "Jwt:RefreshTokenExpiryDays",   "30" },
            })
            .Build();

        _sut = new TokenService(config);
    }

    // ---------------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------------

    /// <summary>
    /// Base64Url-decodes the payload segment of a JWT and returns it as a
    /// <see cref="JsonDocument"/>.  Caller is responsible for disposing.
    /// </summary>
    private static JsonDocument ReadJwtPayload(string token)
    {
        var b64 = token.Split('.')[1];
        var mod4 = b64.Length % 4;
        if (mod4 > 0) b64 += new string('=', 4 - mod4);
        var bytes = Convert.FromBase64String(b64.Replace('-', '+').Replace('_', '/'));
        return JsonDocument.Parse(bytes);
    }

    // ---------------------------------------------------------------------------
    // GenerateAccessToken
    // ---------------------------------------------------------------------------

    [Fact]
    public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles()
    {
        var user  = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
        var roles = new List<string> { "member", "finance" };

        var token = _sut.GenerateAccessToken(user, roles);

        Assert.NotEmpty(token);

        // Decode payload without touching any Microsoft.IdentityModel API so that
        // library version mismatches (7.1.2 vs 7.5.2) cannot affect this test.
        using var doc = ReadJwtPayload(token);
        var root = doc.RootElement;

        Assert.Equal("user-123",     root.GetProperty("sub").GetString());
        Assert.Equal("test@rolac.org", root.GetProperty("email").GetString());

        // JwtSecurityTokenHandler's DefaultOutboundClaimTypeMap maps
        // ClaimTypes.Role → "role", so roles land under the "role" key.
        // When multiple claims share a type they are serialised as a JSON array.
        var roleEl = root.GetProperty("role");
        var roleClaims = roleEl.ValueKind == JsonValueKind.Array
            ? roleEl.EnumerateArray().Select(e => e.GetString()!).ToList()
            : new List<string> { roleEl.GetString()! };

        Assert.Contains("member",  roleClaims);
        Assert.Contains("finance", roleClaims);
    }

    [Fact]
    public void GenerateAccessToken_ExpiresIn15Minutes()
    {
        var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };

        var token = _sut.GenerateAccessToken(user, new List<string>());

        using var doc       = ReadJwtPayload(token);
        var expUnix         = doc.RootElement.GetProperty("exp").GetInt64();
        var expectedUnix    = DateTimeOffset.UtcNow.AddMinutes(15).ToUnixTimeSeconds();

        // Allow ±2 s for test execution time.
        Assert.InRange(expUnix, expectedUnix - 2, expectedUnix + 2);
    }

    // ---------------------------------------------------------------------------
    // GenerateRefreshToken
    // ---------------------------------------------------------------------------

    [Fact]
    public void GenerateRefreshToken_Returns86CharUrlSafeBase64()
    {
        var token = _sut.GenerateRefreshToken();

        Assert.NotEmpty(token);
        // 64 bytes → 88 standard Base64 chars → 86 URL-safe chars (no '==' padding,
        // '+' → '-', '/' → '_').  All chars must be unreserved in RFC 6265 cookie-values.
        Assert.Equal(86, token.Length);
        Assert.True(
            token.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'),
            "Token must use URL-safe Base64 alphabet (A-Z a-z 0-9 - _)");
    }

    [Fact]
    public void GenerateRefreshToken_ProducesUniqueTokensEachCall()
    {
        var token1 = _sut.GenerateRefreshToken();
        var token2 = _sut.GenerateRefreshToken();

        Assert.NotEqual(token1, token2);
    }

    // ---------------------------------------------------------------------------
    // HashToken
    // ---------------------------------------------------------------------------

    [Fact]
    public void HashToken_SameInputProducesSameHash()
    {
        const string raw = "some-raw-token-value";

        var hash1 = _sut.HashToken(raw);
        var hash2 = _sut.HashToken(raw);

        Assert.Equal(hash1, hash2);
    }

    [Fact]
    public void HashToken_DifferentInputsProduceDifferentHashes()
    {
        var hash1 = _sut.HashToken("token-a");
        var hash2 = _sut.HashToken("token-b");

        Assert.NotEqual(hash1, hash2);
    }

    [Fact]
    public void HashToken_Returns64CharLowercaseHexString()
    {
        var hash = _sut.HashToken("any-token");

        Assert.Equal(64, hash.Length);
        Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
    }
}
