using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Entities.Security;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.Security;

namespace ScadaLink.Security.Tests;

#region WP-6: LdapAuthService Tests

public class LdapAuthServiceTests
{
    private static SecurityOptions CreateOptions(bool useTls = true, bool allowInsecure = false) => new()
    {
        LdapServer = "ldap.example.com",
        LdapPort = 636,
        LdapUseTls = useTls,
        AllowInsecureLdap = allowInsecure,
        LdapSearchBase = "dc=example,dc=com",
        JwtSigningKey = "test-key-that-is-long-enough-for-hmac-sha256-minimum"
    };

    [Fact]
    public async Task AuthenticateAsync_EmptyUsername_ReturnsFailed()
    {
        var service = new LdapAuthService(
            Options.Create(CreateOptions()),
            NullLogger<LdapAuthService>.Instance);

        var result = await service.AuthenticateAsync("", "password");
        Assert.False(result.Success);
        Assert.Contains("Username is required", result.ErrorMessage);
    }

    [Fact]
    public async Task AuthenticateAsync_EmptyPassword_ReturnsFailed()
    {
        var service = new LdapAuthService(
            Options.Create(CreateOptions()),
            NullLogger<LdapAuthService>.Instance);

        var result = await service.AuthenticateAsync("user", "");
        Assert.False(result.Success);
        Assert.Contains("Password is required", result.ErrorMessage);
    }

    [Fact]
    public async Task AuthenticateAsync_InsecureLdapNotAllowed_ReturnsFailed()
    {
        var service = new LdapAuthService(
            Options.Create(CreateOptions(useTls: false, allowInsecure: false)),
            NullLogger<LdapAuthService>.Instance);

        var result = await service.AuthenticateAsync("user", "password");
        Assert.False(result.Success);
        Assert.Contains("Insecure LDAP", result.ErrorMessage);
    }

    [Fact]
    public async Task AuthenticateAsync_ConnectionFailure_ReturnsFailed()
    {
        // Point to a non-existent server — connection should fail
        var options = CreateOptions();
        options.LdapServer = "nonexistent.invalid";
        options.LdapPort = 9999;

        var service = new LdapAuthService(
            Options.Create(options),
            NullLogger<LdapAuthService>.Instance);

        var result = await service.AuthenticateAsync("user", "password");
        Assert.False(result.Success);
    }
}

#endregion

#region WP-7: JwtTokenService Tests

public class JwtTokenServiceTests
{
    private static SecurityOptions CreateOptions() => new()
    {
        JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
        JwtExpiryMinutes = 15,
        IdleTimeoutMinutes = 30,
        JwtRefreshThresholdMinutes = 5
    };

    private static JwtTokenService CreateService(SecurityOptions? options = null)
    {
        return new JwtTokenService(
            Options.Create(options ?? CreateOptions()),
            NullLogger<JwtTokenService>.Instance);
    }

    [Fact]
    public void GenerateToken_ContainsCorrectClaims()
    {
        var service = CreateService();
        var token = service.GenerateToken(
            "John Doe", "johnd",
            new[] { "Admin", "Design" },
            new[] { "1", "2" });

        var principal = service.ValidateToken(token);
        Assert.NotNull(principal);

        Assert.Equal("John Doe", principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value);
        Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);

        var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
        Assert.Contains("Admin", roles);
        Assert.Contains("Design", roles);

        var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
        Assert.Contains("1", siteIds);
        Assert.Contains("2", siteIds);

        Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType));
    }

    [Fact]
    public void GenerateToken_NullSiteIds_NoSiteIdClaims()
    {
        var service = CreateService();
        var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
        var principal = service.ValidateToken(token);

        Assert.NotNull(principal);
        Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType));
    }

    [Fact]
    public void ValidateToken_InvalidToken_ReturnsNull()
    {
        var service = CreateService();
        var result = service.ValidateToken("invalid.token.here");
        Assert.Null(result);
    }

    [Fact]
    public void ValidateToken_WrongKey_ReturnsNull()
    {
        var service1 = CreateService();
        var token = service1.GenerateToken("User", "user", new[] { "Admin" }, null);

        var service2 = CreateService(new SecurityOptions
        {
            JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation",
            JwtExpiryMinutes = 15,
            IdleTimeoutMinutes = 30,
            JwtRefreshThresholdMinutes = 5
        });
        var result = service2.ValidateToken(token);
        Assert.Null(result);
    }

    [Fact]
    public void ValidateToken_UsesHmacSha256()
    {
        var service = CreateService();
        var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);

        // Decode header to verify algorithm
        var parts = token.Split('.');
        var headerJson = System.Text.Encoding.UTF8.GetString(
            Convert.FromBase64String(parts[0].PadRight((parts[0].Length + 3) & ~3, '=')));
        Assert.Contains("HS256", headerJson);
    }

    [Fact]
    public void ShouldRefresh_TokenNearExpiry_ReturnsTrue()
    {
        var options = CreateOptions();
        options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min
        var service = CreateService(options);

        var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
        var principal = service.ValidateToken(token);

        Assert.True(service.ShouldRefresh(principal!));
    }

    [Fact]
    public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse()
    {
        var service = CreateService(); // 15 min expiry, 5 min threshold
        var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
        var principal = service.ValidateToken(token);

        Assert.False(service.ShouldRefresh(principal!));
    }

    [Fact]
    public void IsIdleTimedOut_RecentActivity_ReturnsFalse()
    {
        var service = CreateService();
        var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
        var principal = service.ValidateToken(token);

        Assert.False(service.IsIdleTimedOut(principal!));
    }

    [Fact]
    public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue()
    {
        var service = CreateService();
        var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
        {
            new Claim(JwtTokenService.DisplayNameClaimType, "User")
        }));

        Assert.True(service.IsIdleTimedOut(principal));
    }

    [Fact]
    public void RefreshToken_ReturnsNewTokenWithUpdatedClaims()
    {
        var service = CreateService();
        var originalToken = service.GenerateToken("User", "user", new[] { "Admin" }, null);
        var principal = service.ValidateToken(originalToken);

        var newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" });
        Assert.NotNull(newToken);

        var newPrincipal = service.ValidateToken(newToken!);
        var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
        Assert.Contains("Design", roles);
    }

    [Fact]
    public void RefreshToken_MissingClaims_ReturnsNull()
    {
        var service = CreateService();
        var principal = new ClaimsPrincipal(new ClaimsIdentity());

        var result = service.RefreshToken(principal, new[] { "Admin" }, null);
        Assert.Null(result);
    }
}

#endregion

#region WP-8: RoleMapper Tests

public class RoleMapperTests : IDisposable
{
    private readonly ScadaLinkDbContext _context;
    private readonly SecurityRepository _securityRepo;
    private readonly RoleMapper _roleMapper;

    public RoleMapperTests()
    {
        var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
            .UseSqlite("DataSource=:memory:")
            .Options;

        _context = new ScadaLinkDbContext(options);
        _context.Database.OpenConnection();
        _context.Database.EnsureCreated();
        _securityRepo = new SecurityRepository(_context);
        _roleMapper = new RoleMapper(_securityRepo);
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    [Fact]
    public async Task MapGroupsToRoles_MultiRoleExtraction()
    {
        // Add mappings (note: seed data adds SCADA-Admins -> Admin)
        _context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design"));
        _context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment"));
        await _context.SaveChangesAsync();

        var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" });

        Assert.Contains("Admin", result.Roles);
        Assert.Contains("Design", result.Roles);
        Assert.DoesNotContain("Deployment", result.Roles);
    }

    [Fact]
    public async Task MapGroupsToRoles_SiteScopedDeployment()
    {
        var site1 = new Site("Site1", "S-001");
        var site2 = new Site("Site2", "S-002");
        _context.Sites.AddRange(site1, site2);
        _context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", "Deployment"));
        await _context.SaveChangesAsync();

        var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers");
        _context.SiteScopeRules.AddRange(
            new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id },
            new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site2.Id });
        await _context.SaveChangesAsync();

        var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" });

        Assert.Contains("Deployment", result.Roles);
        Assert.False(result.IsSystemWideDeployment);
        Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds);
        Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds);
    }

    [Fact]
    public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
    {
        _context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment"));
        await _context.SaveChangesAsync();

        var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" });

        Assert.Contains("Deployment", result.Roles);
        Assert.True(result.IsSystemWideDeployment);
        Assert.Empty(result.PermittedSiteIds);
    }

    [Fact]
    public async Task MapGroupsToRoles_UnrecognizedGroups_Ignored()
    {
        var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "NonExistentGroup", "AnotherRandom" });

        Assert.Empty(result.Roles);
        Assert.Empty(result.PermittedSiteIds);
        Assert.False(result.IsSystemWideDeployment);
    }

    [Fact]
    public async Task MapGroupsToRoles_NoMatchingGroups_NoRoles()
    {
        var result = await _roleMapper.MapGroupsToRolesAsync(Array.Empty<string>());

        Assert.Empty(result.Roles);
    }

    [Fact]
    public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch()
    {
        // "SCADA-Admins" is seeded
        var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" });

        Assert.Contains("Admin", result.Roles);
    }
}

#endregion

#region Code Review Regression Tests

/// <summary>
/// Regression tests for Security-001 (StartTLS dead code), Security-002 (cookie not
/// marked Secure), and Security-003 (JWT signing key length never validated).
/// </summary>
public class SecurityReviewRegressionTests
{
    // --- Security-003: JWT signing key length validation ---

    private static SecurityOptions JwtOptions(string signingKey) => new()
    {
        JwtSigningKey = signingKey,
        JwtExpiryMinutes = 15,
        IdleTimeoutMinutes = 30,
        JwtRefreshThresholdMinutes = 5
    };

    [Fact]
    public void JwtTokenService_EmptySigningKey_ThrowsAtConstruction()
    {
        var ex = Assert.Throws<InvalidOperationException>(() =>
            new JwtTokenService(Options.Create(JwtOptions("")), NullLogger<JwtTokenService>.Instance));
        Assert.Contains("JwtSigningKey", ex.Message);
    }

    [Fact]
    public void JwtTokenService_ShortSigningKey_ThrowsAtConstruction()
    {
        // 31 bytes — one short of the 256-bit HMAC-SHA256 minimum.
        var shortKey = new string('k', 31);
        var ex = Assert.Throws<InvalidOperationException>(() =>
            new JwtTokenService(Options.Create(JwtOptions(shortKey)), NullLogger<JwtTokenService>.Instance));
        Assert.Contains("32", ex.Message);
    }

    [Fact]
    public void JwtTokenService_AdequateSigningKey_ConstructsSuccessfully()
    {
        var key = new string('k', 32);
        var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger<JwtTokenService>.Instance);
        var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
        Assert.False(string.IsNullOrEmpty(token));
    }

    // --- Security-002: authentication cookie must be marked Secure ---

    [Fact]
    public void AddSecurity_AuthCookie_IsMarkedSecureAlways()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddDataProtection();
        services.AddSecurity();

        using var provider = services.BuildServiceProvider();
        var cookieOptions = provider
            .GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
            .Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);

        Assert.Equal(
            Microsoft.AspNetCore.Http.CookieSecurePolicy.Always,
            cookieOptions.Cookie.SecurePolicy);
        Assert.True(cookieOptions.Cookie.HttpOnly);
    }

    // --- Security-001: StartTLS transport must be reachable ---

    [Fact]
    public void SecurityOptions_LdapTransport_DefaultsToLdaps()
    {
        var options = new SecurityOptions();
        Assert.Equal(LdapTransport.Ldaps, options.LdapTransport);
    }

    [Fact]
    public async Task AuthenticateAsync_StartTlsTransport_AttemptsConnection()
    {
        // With StartTLS selected the service must not be blocked by the insecure-LDAP
        // guard and must reach the connection stage (which fails against a dead host).
        // This proves the StartTLS path is reachable rather than dead code.
        var options = new SecurityOptions
        {
            LdapServer = "nonexistent.invalid",
            LdapPort = 389,
            LdapTransport = LdapTransport.StartTls,
            LdapSearchBase = "dc=example,dc=com"
        };
        var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);

        var result = await service.AuthenticateAsync("user", "password");

        // Connection fails (host invalid) — but crucially NOT with the insecure-LDAP message.
        Assert.False(result.Success);
        Assert.DoesNotContain("Insecure LDAP", result.ErrorMessage ?? string.Empty);
    }

    [Fact]
    public async Task AuthenticateAsync_NoTlsTransport_RejectedWithoutAllowInsecure()
    {
        var options = new SecurityOptions
        {
            LdapServer = "ldap.example.com",
            LdapPort = 389,
            LdapTransport = LdapTransport.None,
            AllowInsecureLdap = false
        };
        var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);

        var result = await service.AuthenticateAsync("user", "password");

        Assert.False(result.Success);
        Assert.Contains("Insecure LDAP", result.ErrorMessage);
    }
}

#endregion

#region Code Review Regression Tests — Security-004/005/006/007

/// <summary>
/// Regression tests for Security-004 (uid/cn attribute mismatch between search filter
/// and fallback DN), Security-005 (DN injection in the no-service-account fallback),
/// Security-006 (JWT issuer/audience checks disabled), and Security-007 (idle-timeout
/// claim reset on every token refresh).
/// </summary>
public class SecurityReviewRegressionTests2
{
    private static SecurityOptions JwtOptions() => new()
    {
        JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
        JwtExpiryMinutes = 15,
        IdleTimeoutMinutes = 30,
        JwtRefreshThresholdMinutes = 5
    };

    private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
        new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);

    // --- Security-004: search filter and fallback DN must use the same attribute ---

    [Fact]
    public void BuildFallbackUserDn_UsesConfiguredUserIdAttribute()
    {
        // The default user-id attribute is "uid"; the fallback DN must use it,
        // not a hard-coded "cn", so search-then-bind and direct-bind are interchangeable.
        var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "uid");
        Assert.Equal("uid=alice,dc=example,dc=com", dn);
    }

    [Fact]
    public void BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute()
    {
        var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "sAMAccountName");
        Assert.Equal("sAMAccountName=alice,dc=example,dc=com", dn);
    }

    [Fact]
    public void SecurityOptions_LdapUserIdAttribute_DefaultsToUid()
    {
        Assert.Equal("uid", new SecurityOptions().LdapUserIdAttribute);
    }

    // --- Security-005: DN-component escaping must be applied to the username ---

    [Fact]
    public void BuildFallbackUserDn_EscapesDnMetacharacters()
    {
        // A hostile username must not be able to alter the DN structure: the comma
        // that would otherwise start a new RDN ("ou=admins") must be escaped so the
        // whole string remains a single RDN value.
        var dn = LdapAuthService.BuildFallbackUserDn("victim,ou=admins", "dc=example,dc=com", "uid");
        Assert.Equal(@"uid=victim\,ou=admins,dc=example,dc=com", dn);
        // The comma from the username is backslash-escaped, so it does not act as an
        // RDN separator: the only unescaped comma is the one joining RDN and base DN.
        Assert.Contains(@"victim\,ou=admins", dn);
    }

    [Fact]
    public void EscapeLdapDn_EscapesAllRfc4514Specials()
    {
        var escaped = LdapAuthService.EscapeLdapDn("a,b+c\"d\\e<f>g;h");
        Assert.Equal(@"a\,b\+c\""d\\e\<f\>g\;h", escaped);
    }

    [Fact]
    public void EscapeLdapDn_EscapesLeadingAndTrailingSpaces()
    {
        Assert.Equal(@"\  x \ ", LdapAuthService.EscapeLdapDn("  x  "));
    }

    // --- Security-006: JWT issuer/audience must be bound and validated ---

    [Fact]
    public void GenerateToken_SetsIssuerAndAudience()
    {
        var service = CreateJwtService();
        var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);

        var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token);
        Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer);
        Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences);
    }

    [Fact]
    public void ValidateToken_RejectsTokenWithWrongIssuer()
    {
        // A token signed with the same key but a foreign issuer must be rejected.
        var options = JwtOptions();
        var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
            System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey));
        var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials(
            key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256);
        var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
            issuer: "some-other-system",
            audience: JwtTokenService.TokenAudience,
            claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") },
            expires: DateTime.UtcNow.AddMinutes(10),
            signingCredentials: creds);
        var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign);

        var service = CreateJwtService(options);
        Assert.Null(service.ValidateToken(foreignToken));
    }

    [Fact]
    public void ValidateToken_AcceptsOwnIssuerAndAudience()
    {
        var service = CreateJwtService();
        var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
        Assert.NotNull(service.ValidateToken(token));
    }

    // --- Security-007: refresh must preserve the original LastActivity timestamp ---

    [Fact]
    public void RefreshToken_PreservesOriginalLastActivityClaim()
    {
        var service = CreateJwtService();

        // Mint a token whose LastActivity is 20 minutes in the past (still inside the
        // 30-minute idle window). Refresh must NOT move it forward to "now".
        var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
        var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
        {
            new Claim(JwtTokenService.DisplayNameClaimType, "User"),
            new Claim(JwtTokenService.UsernameClaimType, "user"),
            new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
        }, "test"));

        var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
        Assert.NotNull(refreshed);

        var refreshedPrincipal = service.ValidateToken(refreshed!);
        Assert.NotNull(refreshedPrincipal);
        var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
        Assert.NotNull(claim);
        Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
        // The carried timestamp must equal the original, not "now".
        Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2,
            $"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}");
    }

    [Fact]
    public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle()
    {
        // A user idle for 25 of the 30-minute window: a refresh fired by some background
        // request must not make IsIdleTimedOut flip back to false forever.
        var service = CreateJwtService();
        var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25);
        var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
        {
            new Claim(JwtTokenService.DisplayNameClaimType, "User"),
            new Claim(JwtTokenService.UsernameClaimType, "user"),
            new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
        }, "test"));

        var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
        var refreshedPrincipal = service.ValidateToken(refreshed!);

        // Still 25 min idle after refresh — not reset to 0.
        Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid
        var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
        Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
        Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20,
            "Refresh wrongly reset the idle clock to ~now");
    }

    [Fact]
    public void RecordActivity_UpdatesLastActivityToNow()
    {
        // Genuine user activity (a real request) — distinct from a token refresh —
        // updates LastActivity to the current time.
        var service = CreateJwtService();
        var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
        var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
        {
            new Claim(JwtTokenService.DisplayNameClaimType, "User"),
            new Claim(JwtTokenService.UsernameClaimType, "user"),
            new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
        }, "test"));

        var touched = service.RecordActivity(principal, new[] { "Admin" }, null);
        Assert.NotNull(touched);

        var touchedPrincipal = service.ValidateToken(touched!);
        var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
        Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated));
        Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5,
            "RecordActivity should set LastActivity to ~now");
    }
}

#endregion

#region WP-9: Authorization Policy Tests

public class AuthorizationPolicyTests
{
    [Fact]
    public async Task AdminPolicy_AdminRole_Succeeds()
    {
        var principal = CreatePrincipal(new[] { "Admin" });
        var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
        Assert.True(result);
    }

    [Fact]
    public async Task AdminPolicy_DesignRole_Fails()
    {
        var principal = CreatePrincipal(new[] { "Design" });
        var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
        Assert.False(result);
    }

    [Fact]
    public async Task DesignPolicy_DesignRole_Succeeds()
    {
        var principal = CreatePrincipal(new[] { "Design" });
        var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal);
        Assert.True(result);
    }

    [Fact]
    public async Task DeploymentPolicy_DeploymentRole_Succeeds()
    {
        var principal = CreatePrincipal(new[] { "Deployment" });
        var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal);
        Assert.True(result);
    }

    [Fact]
    public async Task NoRoles_DeniedAll()
    {
        var principal = CreatePrincipal(Array.Empty<string>());
        Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
        Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal));
        Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal));
    }

    [Fact]
    public async Task SiteScope_SystemWideDeployer_Succeeds()
    {
        var handler = new SiteScopeAuthorizationHandler();
        var claims = new List<Claim>
        {
            new(JwtTokenService.RoleClaimType, "Deployment")
            // No SiteId claims = system-wide
        };
        var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));

        var requirement = new SiteScopeRequirement("42");
        var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);

        await handler.HandleAsync(context);

        Assert.True(context.HasSucceeded);
    }

    [Fact]
    public async Task SiteScope_PermittedSite_Succeeds()
    {
        var handler = new SiteScopeAuthorizationHandler();
        var claims = new List<Claim>
        {
            new(JwtTokenService.RoleClaimType, "Deployment"),
            new(JwtTokenService.SiteIdClaimType, "1"),
            new(JwtTokenService.SiteIdClaimType, "2")
        };
        var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));

        var requirement = new SiteScopeRequirement("1");
        var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);

        await handler.HandleAsync(context);

        Assert.True(context.HasSucceeded);
    }

    [Fact]
    public async Task SiteScope_UnpermittedSite_Fails()
    {
        var handler = new SiteScopeAuthorizationHandler();
        var claims = new List<Claim>
        {
            new(JwtTokenService.RoleClaimType, "Deployment"),
            new(JwtTokenService.SiteIdClaimType, "1")
        };
        var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));

        var requirement = new SiteScopeRequirement("99");
        var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);

        await handler.HandleAsync(context);

        Assert.False(context.HasSucceeded);
    }

    [Fact]
    public async Task SiteScope_NoDeploymentRole_Fails()
    {
        var handler = new SiteScopeAuthorizationHandler();
        var claims = new List<Claim>
        {
            new(JwtTokenService.RoleClaimType, "Admin")
        };
        var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));

        var requirement = new SiteScopeRequirement("1");
        var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);

        await handler.HandleAsync(context);

        Assert.False(context.HasSucceeded);
    }

    private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
    {
        var claims = new List<Claim>();
        foreach (var role in roles)
            claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
        if (siteIds != null)
            foreach (var siteId in siteIds)
                claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));

        return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
    }

    private static async Task<bool> EvaluatePolicy(string policyName, ClaimsPrincipal principal)
    {
        var services = new ServiceCollection();
        services.AddScadaLinkAuthorization();
        services.AddLogging();

        using var provider = services.BuildServiceProvider();
        var authService = provider.GetRequiredService<IAuthorizationService>();

        var result = await authService.AuthorizeAsync(principal, null, policyName);
        return result.Succeeded;
    }
}

#endregion
