using System.Security.Claims;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;

namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;

public sealed class HubTokenServiceTests
{
    /// <summary>
    /// A token whose data-protected payload has both
    /// <c>Name</c> and <c>NameIdentifier</c> null (the principal that
    /// minted the token had no identity claims) must be rejected by
    /// <see cref="HubTokenService.Validate"/>. The role claims alone are
    /// not enough — without a caller identity, the resulting
    /// <see cref="ClaimsPrincipal"/> would satisfy
    /// <c>IsAuthenticated</c> / <c>IsInRole</c> checks without an
    /// associated user.
    /// </summary>
    [Fact]
    public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
    {
        HubTokenService service = CreateService();

        // Issue from a principal with NO Name claim and NO NameIdentifier
        // claim. The Issue method's payload will then carry
        // (Name = null, NameIdentifier = null, Roles = ["Viewer"]).
        ClaimsIdentity identity = new(
            [new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
            authenticationType: "test");
        ClaimsPrincipal principal = new(identity);
        string token = service.Issue(principal);

        ClaimsPrincipal? result = service.Validate(token);

        Assert.Null(result);
    }

    /// <summary>
    /// Sanity check: a token minted from a principal with a Name claim
    /// validates and returns a principal carrying that identity. Pins
    /// that the null-identity rejection above does not over-reject valid tokens.
    /// </summary>
    [Fact]
    public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
    {
        HubTokenService service = CreateService();

        ClaimsIdentity identity = new(
            [
                new Claim(ClaimTypes.Name, "alice"),
                new Claim(ClaimTypes.NameIdentifier, "alice-id"),
                new Claim(ClaimTypes.Role, DashboardRoles.Admin),
            ],
            authenticationType: "test",
            nameType: ClaimTypes.Name,
            roleType: ClaimTypes.Role);
        ClaimsPrincipal principal = new(identity);
        string token = service.Issue(principal);

        ClaimsPrincipal? result = service.Validate(token);

        Assert.NotNull(result);
        Assert.Equal("alice", result.Identity?.Name);
        Assert.True(result.IsInRole(DashboardRoles.Admin));
    }

    /// <summary>
    /// Sanity check: a token minted with only a NameIdentifier (no Name)
    /// still validates — a non-null caller identity is the contract,
    /// either field is sufficient.
    /// </summary>
    [Fact]
    public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
    {
        HubTokenService service = CreateService();

        ClaimsIdentity identity = new(
            [
                new Claim(ClaimTypes.NameIdentifier, "alice-id"),
                new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
            ],
            authenticationType: "test");
        ClaimsPrincipal principal = new(identity);
        string token = service.Issue(principal);

        ClaimsPrincipal? result = service.Validate(token);

        Assert.NotNull(result);
        Assert.True(result.IsInRole(DashboardRoles.Viewer));
    }

    /// <summary>Verifies that a null token returns null.</summary>
    [Fact]
    public void Validate_NullToken_ReturnsNull()
    {
        HubTokenService service = CreateService();

        Assert.Null(service.Validate(null));
    }

    /// <summary>Verifies that an empty token returns null.</summary>
    [Fact]
    public void Validate_EmptyToken_ReturnsNull()
    {
        HubTokenService service = CreateService();

        Assert.Null(service.Validate(string.Empty));
    }

    /// <summary>Verifies that an invalid token returns null.</summary>
    [Fact]
    public void Validate_GarbageToken_ReturnsNull()
    {
        HubTokenService service = CreateService();

        Assert.Null(service.Validate("this-is-not-a-protected-payload"));
    }

    /// <summary>
    /// Issue/validate round-trip: a freshly minted token (default <see cref="HubTokenService.TokenLifetime"/>)
    /// validates and reconstructs the caller's identity and roles.
    /// </summary>
    [Fact]
    public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
    {
        HubTokenService service = CreateService();
        ClaimsIdentity identity = new(
            [
                new Claim(ClaimTypes.Name, "bob"),
                new Claim(ClaimTypes.NameIdentifier, "bob-id"),
                new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
                new Claim(ClaimTypes.Role, DashboardRoles.Admin),
            ],
            authenticationType: "test",
            nameType: ClaimTypes.Name,
            roleType: ClaimTypes.Role);

        string token = service.Issue(new ClaimsPrincipal(identity));
        ClaimsPrincipal? result = service.Validate(token);

        Assert.NotNull(result);
        Assert.Equal("bob", result.Identity?.Name);
        Assert.True(result.IsInRole(DashboardRoles.Viewer));
        Assert.True(result.IsInRole(DashboardRoles.Admin));
    }

    /// <summary>
    /// The default token lifetime is the short (5-minute) window, not the
    /// former 30-minute window. Pins the value so a regression that widens the exposure window
    /// of an irrevocable, query-string-carried token is caught in CI.
    /// </summary>
    [Fact]
    public void TokenLifetime_IsFiveMinutes()
    {
        Assert.Equal(TimeSpan.FromMinutes(5), HubTokenService.TokenLifetime);
    }

    /// <summary>
    /// A token whose lifetime has elapsed is rejected by <see cref="HubTokenService.Validate"/>.
    /// Uses the internal lifetime-issuing seam with a negative lifetime so the token is already
    /// expired at mint time — deterministic, no wall-clock delay.
    /// </summary>
    [Fact]
    public void Validate_ExpiredToken_ReturnsNull()
    {
        HubTokenService service = CreateService();
        ClaimsIdentity identity = new(
            [new Claim(ClaimTypes.Name, "carol")],
            authenticationType: "test");

        string expiredToken = service.Issue(
            new ClaimsPrincipal(identity),
            TimeSpan.FromMinutes(-1));

        Assert.Null(service.Validate(expiredToken));
    }

    /// <summary>
    /// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are
    /// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
    /// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
    /// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
    /// <see cref="HubTokenService.Validate"/> reconstructs — which is the principal
    /// <c>IDashboardSessionAcl</c> reads on the hub path.
    /// </summary>
    [Fact]
    public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags()
    {
        HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
        {
            ["GwViewer"] = ["team-a"],
            ["TeamBViewers"] = ["team-b"],
        });

        ClaimsIdentity identity = new(
            [
                new Claim(ClaimTypes.Name, "dana"),
                new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
                new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
                new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"),
            ],
            authenticationType: "test",
            nameType: ClaimTypes.Name,
            roleType: ClaimTypes.Role);

        ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));

        Assert.NotNull(result);
        Assert.Equal(
            ["team-a", "team-b"],
            result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)
                .Select(c => c.Value)
                .Order(StringComparer.Ordinal));
    }

    /// <summary>
    /// A caller whose groups map to nothing mints a token with no tags, and validating it yields a
    /// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This
    /// is also the shape of every token minted before the tag field existed (the payload field
    /// deserializes to null), so the fail-closed direction is covered for both.
    /// </summary>
    [Fact]
    public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant()
    {
        HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
        {
            ["SomeOtherGroup"] = ["team-a"],
        });

        ClaimsIdentity identity = new(
            [
                new Claim(ClaimTypes.Name, "erin"),
                new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
                new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
            ],
            authenticationType: "test",
            nameType: ClaimTypes.Name,
            roleType: ClaimTypes.Role);

        ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));

        Assert.NotNull(result);
        Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
    }

    /// <summary>
    /// A token minted before the payload carried tags at all still validates, and yields an empty
    /// grant rather than throwing or rejecting. Distinct from the empty-grant test above, which
    /// exercises a <c>Tags</c> key that is present and empty: this one protects a hand-built
    /// payload with the key genuinely ABSENT, which is the shape every in-flight token has across
    /// the deploy that introduces the field. Deserialization leaves the field null, and the
    /// null-coalesce in <c>Validate</c> is the only thing standing between that and a crash on
    /// the hub's authentication path.
    /// </summary>
    [Fact]
    public void Validate_TokenMintedBeforeTagsFieldExisted_YieldsEmptyGrant()
    {
        EphemeralDataProtectionProvider dataProtection = new();
        HubTokenService service = CreateService(dataProtection: dataProtection);

        // The pre-field payload shape, verbatim: no "Tags" key anywhere.
        const string LegacyPayload = """{"Name":"frank","NameIdentifier":"frank-id","Roles":["Viewer"]}""";
        string legacyToken = dataProtection
            .CreateProtector(HubTokenService.ProtectorPurpose)
            .ToTimeLimitedDataProtector()
            .Protect(LegacyPayload, HubTokenService.TokenLifetime);

        ClaimsPrincipal? result = service.Validate(legacyToken);

        Assert.NotNull(result);
        Assert.Equal("frank", result.Identity?.Name);
        Assert.True(result.IsInRole(DashboardRoles.Viewer));
        Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
    }

    private static HubTokenService CreateService(
        Dictionary<string, string[]>? groupToTag = null,
        IDataProtectionProvider? dataProtection = null)
    {
        GatewayOptions options = new()
        {
            Dashboard = new DashboardOptions
            {
                GroupToTag = groupToTag ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase),
            },
        };

        return new HubTokenService(
            dataProtection ?? new EphemeralDataProtectionProvider(),
            Options.Create(options));
    }
}
