using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.ScadaBridge.Security;

namespace ZB.MOM.WW.ScadaBridge.Security.Tests;

#region M2.19 (#15): cookie session idle-timeout + LDAP-free role-mapping refresh

/// <summary>
/// Tests for the cookie <c>OnValidatePrincipal</c> core (<see cref="CookieSessionValidator"/>)
/// and the shared <see cref="SessionClaimBuilder"/>. These pin the always-achievable M2.19
/// layers: idle-timeout enforcement, mid-session role-mapping refresh from STORED groups
/// WITHOUT any LDAP call, the fail-soft refresh policy (errors keep the session), and claim
/// parity between the login path and the refresh path.
/// </summary>
public class CookieSessionValidatorTests
{
    private static readonly DateTimeOffset Start = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);

    // A controllable clock (the central package list has no FakeTimeProvider, mirroring
    // the Transport tests' hand-rolled TestTimeProvider).
    private sealed class TestTimeProvider : TimeProvider
    {
        private DateTimeOffset _now;
        public TestTimeProvider(DateTimeOffset start) => _now = start;
        public override DateTimeOffset GetUtcNow() => _now;
        public void Advance(TimeSpan by) => _now = _now.Add(by);
    }

    // A fake IGroupRoleMapper<string> that returns a programmable RoleMappingResult and
    // counts how many times it was invoked — so a test can assert NO LDAP call happens
    // (this mapper is DB-backed only; if it is hit, the result came from the DB seam, not
    // LDAP) and that the refresh actually re-ran the mapping.
    private sealed class FakeGroupRoleMapper : IGroupRoleMapper<string>
    {
        private RoleMappingResult _result;
        private readonly Exception? _throw;
        public int CallCount { get; private set; }
        public IReadOnlyList<string>? LastGroups { get; private set; }

        public FakeGroupRoleMapper(RoleMappingResult result) => _result = result;
        public FakeGroupRoleMapper(Exception toThrow) => (_result, _throw) =
            (new RoleMappingResult([], [], false), toThrow);

        public void SetResult(RoleMappingResult result) => _result = result;

        public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
        {
            CallCount++;
            LastGroups = groups;
            if (_throw is not null) throw _throw;
            return Task.FromResult(new GroupRoleMapping<string>(_result.Roles, Scope: _result));
        }
    }

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

    private static CookieSessionValidator CreateValidator(
        IGroupRoleMapper<string> mapper, TimeProvider clock, SecurityOptions? options = null) =>
        new(mapper, Microsoft.Extensions.Options.Options.Create(options ?? Options()), clock,
            NullLogger<CookieSessionValidator>.Instance);

    // Build a session the way login does (via SessionClaimBuilder), anchored at `at`.
    private static ClaimsPrincipal Session(
        DateTimeOffset at,
        IReadOnlyList<string> groups,
        RoleMappingResult mapping) =>
        SessionClaimBuilder.Build("alice", "Alice", groups, mapping, at);

    // --- Idle timeout ---

    [Fact]
    public async Task ValidateAsync_PastIdleTimeout_Rejects()
    {
        var clock = new TestTimeProvider(Start);
        var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true));
        var sut = CreateValidator(mapper, clock);
        var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true));

        // 31 minutes idle (> 30-minute IdleTimeoutMinutes).
        clock.Advance(TimeSpan.FromMinutes(31));
        var result = await sut.ValidateAsync(principal);

        Assert.Equal(SessionValidationAction.Reject, result.Action);
        // An idle-timed-out session must never be refreshed instead of rejected.
        Assert.Equal(0, mapper.CallCount);
    }

    [Fact]
    public async Task ValidateAsync_WithinIdleWindow_KeptAndNotRejected()
    {
        var clock = new TestTimeProvider(Start);
        var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true));
        var sut = CreateValidator(mapper, clock);
        var principal = Session(Start, ["SCADA-Admins"], new RoleMappingResult([Roles.Administrator], [], true));

        // 10 minutes idle — well inside the 30-minute window, and BEFORE the 15-minute
        // role-refresh threshold, so the session is simply kept.
        clock.Advance(TimeSpan.FromMinutes(10));
        var result = await sut.ValidateAsync(principal);

        Assert.Equal(SessionValidationAction.Keep, result.Action);
    }

    [Fact]
    public async Task ValidateAsync_MissingLastActivityClaim_TreatedAsIdleTimedOut()
    {
        var clock = new TestTimeProvider(Start);
        var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Administrator], [], true));
        var sut = CreateValidator(mapper, clock);

        // A principal with no LastActivity anchor: fail-closed to rejected.
        var identity = new ClaimsIdentity(new[]
        {
            new Claim(ClaimTypes.Name, "alice"),
            new Claim(JwtTokenService.UsernameClaimType, "alice"),
            new Claim(JwtTokenService.RoleClaimType, Roles.Administrator),
        }, CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);

        var result = await sut.ValidateAsync(principal);

        Assert.Equal(SessionValidationAction.Reject, result.Action);
    }

    [Fact]
    public async Task ValidateAsync_UnauthenticatedPrincipal_Kept()
    {
        var clock = new TestTimeProvider(Start);
        var mapper = new FakeGroupRoleMapper(new RoleMappingResult([], [], false));
        var sut = CreateValidator(mapper, clock);

        Assert.Equal(SessionValidationAction.Keep, (await sut.ValidateAsync(null)).Action);
        Assert.Equal(SessionValidationAction.Keep,
            (await sut.ValidateAsync(new ClaimsPrincipal(new ClaimsIdentity()))).Action);
    }

    // --- Role-mapping refresh WITHOUT LDAP ---

    [Fact]
    public async Task ValidateAsync_AfterRoleRefreshThreshold_RemapsFromStoredGroups_NoLdap()
    {
        var clock = new TestTimeProvider(Start);
        // The user logged in as a system-wide Administrator.
        var principal = Session(Start, ["SCADA-Admins"],
            new RoleMappingResult([Roles.Administrator], [], true));

        // The DB mapping CHANGED mid-session: the same groups now map to Viewer + site-scoped.
        var mapper = new FakeGroupRoleMapper(
            new RoleMappingResult([Roles.Viewer], ["7"], IsSystemWideDeployment: false));
        var sut = CreateValidator(mapper, clock);

        // Past the 15-minute role-refresh threshold (still inside the 30-minute idle window).
        clock.Advance(TimeSpan.FromMinutes(16));
        var result = await sut.ValidateAsync(principal);

        Assert.Equal(SessionValidationAction.Replace, result.Action);
        Assert.NotNull(result.Principal);

        // The mapper was invoked with EXACTLY the STORED groups — no LDAP re-query.
        Assert.Equal(1, mapper.CallCount);
        Assert.Equal(new[] { "SCADA-Admins" }, mapper.LastGroups);

        // The rebuilt principal reflects the NEW DB mapping.
        var newRoles = result.Principal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
        Assert.Contains(Roles.Viewer, newRoles);
        Assert.DoesNotContain(Roles.Administrator, newRoles);
        Assert.Equal("7", result.Principal.FindFirst(JwtTokenService.SiteIdClaimType)?.Value);

        // The role-refresh anchor was advanced to now.
        var refreshClaim = result.Principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType);
        Assert.NotNull(refreshClaim);
        Assert.True(DateTimeOffset.TryParse(refreshClaim!.Value, out var refreshedAt));
        Assert.Equal(clock.GetUtcNow(), refreshedAt);
    }

    [Fact]
    public async Task ValidateAsync_RevokedGroupUser_LosesRolesOnRefresh()
    {
        var clock = new TestTimeProvider(Start);
        var principal = Session(Start, ["SCADA-Admins"],
            new RoleMappingResult([Roles.Administrator], [], true));

        // The group's mapping was deleted in the DB: the same groups now map to NOTHING.
        var mapper = new FakeGroupRoleMapper(new RoleMappingResult([], [], IsSystemWideDeployment: false));
        var sut = CreateValidator(mapper, clock);

        clock.Advance(TimeSpan.FromMinutes(16));
        var result = await sut.ValidateAsync(principal);

        Assert.Equal(SessionValidationAction.Replace, result.Action);
        // The user has lost ALL roles mid-session — every authorization policy will now deny.
        Assert.Empty(result.Principal!.FindAll(JwtTokenService.RoleClaimType));
        Assert.Empty(result.Principal.FindAll(JwtTokenService.SiteIdClaimType));
    }

    [Fact]
    public async Task ValidateAsync_BeforeRoleRefreshThreshold_DoesNotRefresh()
    {
        var clock = new TestTimeProvider(Start);
        var principal = Session(Start, ["SCADA-Admins"],
            new RoleMappingResult([Roles.Administrator], [], true));

        // Even though the DB mapping would now return Viewer, a refresh BEFORE the
        // 15-minute threshold must not happen — claims stay as they were at login.
        var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Viewer], [], false));
        var sut = CreateValidator(mapper, clock);

        clock.Advance(TimeSpan.FromMinutes(14)); // < 15-minute threshold
        var result = await sut.ValidateAsync(principal);

        Assert.Equal(SessionValidationAction.Keep, result.Action);
        Assert.Equal(0, mapper.CallCount); // no re-map at all
    }

    // --- Failure policy: a refresh exception keeps the existing principal ---

    [Fact]
    public async Task ValidateAsync_RefreshThrows_KeepsExistingPrincipal_NoSignOut_NoThrow()
    {
        var clock = new TestTimeProvider(Start);
        var principal = Session(Start, ["SCADA-Admins"],
            new RoleMappingResult([Roles.Administrator], [], true));

        // The DB-backed mapper faults (e.g. SQL unreachable) DURING a due refresh.
        var mapper = new FakeGroupRoleMapper(new InvalidOperationException("db down"));
        var sut = CreateValidator(mapper, clock);

        clock.Advance(TimeSpan.FromMinutes(16)); // refresh IS due
        var result = await sut.ValidateAsync(principal); // must NOT throw

        // The session is KEPT (current roles preserved) — never rejected, never broadened.
        Assert.Equal(SessionValidationAction.Keep, result.Action);
        Assert.Equal(1, mapper.CallCount);
    }

    // --- IsIdleTimedOut / IsRoleRefreshDue boundaries ---

    [Fact]
    public void IsIdleTimedOut_AtBoundary_NotTimedOut()
    {
        var clock = new TestTimeProvider(Start);
        var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock);
        var principal = Session(Start, [], new RoleMappingResult([], [], false));

        // Exactly 30 minutes — NOT strictly greater, so not timed out.
        Assert.False(sut.IsIdleTimedOut(principal, Start.AddMinutes(30)));
        Assert.True(sut.IsIdleTimedOut(principal, Start.AddMinutes(30).AddSeconds(1)));
    }

    [Fact]
    public void IsRoleRefreshDue_AtBoundary_NotDue()
    {
        var clock = new TestTimeProvider(Start);
        var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock);
        var principal = Session(Start, [], new RoleMappingResult([], [], false));

        Assert.False(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15)));
        Assert.True(sut.IsRoleRefreshDue(principal, Start.AddMinutes(15).AddSeconds(1)));
    }

    // --- Missing/unparsable zb:lastrolerefresh anchor treated as refresh-due ---

    [Fact]
    public async Task ValidateAsync_MissingLastRoleRefreshClaim_TreatedAsRefreshDue_RefreshesAndRestamps()
    {
        // Mirrors the existing MissingLastActivityClaim_TreatedAsIdleTimedOut test but for
        // the role-refresh anchor. A principal whose zb:lastrolerefresh claim is absent or
        // unparsable must be treated as "refresh due" (refresh now + re-stamp the anchor),
        // NOT as "never refresh". This prevents a stale principal from coasting forever
        // without a role re-check just because the claim was missing.
        var clock = new TestTimeProvider(Start);
        // The mapper returns a different mapping than the one encoded in the session, so we
        // can confirm the refresh actually ran.
        var mapper = new FakeGroupRoleMapper(new RoleMappingResult([Roles.Viewer], [], false));
        var sut = CreateValidator(mapper, clock);

        // Build a principal that has a valid LastActivity (not timed out) but NO lastrolerefresh.
        var identity = new ClaimsIdentity([
            new Claim(ClaimTypes.Name, "alice"),
            new Claim(JwtTokenService.UsernameClaimType, "alice"),
            new Claim(JwtTokenService.DisplayNameClaimType, "Alice"),
            new Claim(JwtTokenService.RoleClaimType, Roles.Administrator),
            new Claim(JwtTokenService.LastActivityClaimType, Start.ToString("o")),
            // Deliberately NO zb:lastrolerefresh claim.
        ], CookieAuthenticationDefaults.AuthenticationScheme,
            nameType: ClaimTypes.Name,
            roleType: JwtTokenService.RoleClaimType);
        var principal = new ClaimsPrincipal(identity);

        var result = await sut.ValidateAsync(principal);

        // Must have triggered a refresh (Replace), NOT Keep.
        Assert.Equal(SessionValidationAction.Replace, result.Action);
        Assert.Equal(1, mapper.CallCount);

        // The rebuilt principal carries the new mapping (Viewer, not Administrator).
        var newRoles = result.Principal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
        Assert.Contains(Roles.Viewer, newRoles);
        Assert.DoesNotContain(Roles.Administrator, newRoles);

        // A new lastrolerefresh anchor was stamped.
        var refreshClaim = result.Principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType);
        Assert.NotNull(refreshClaim);
        Assert.True(DateTimeOffset.TryParse(refreshClaim!.Value, out _));
    }

    [Fact]
    public void IsRoleRefreshDue_MissingClaim_ReturnsDue()
    {
        // Direct helper test: a principal with no zb:lastrolerefresh claim returns true.
        var clock = new TestTimeProvider(Start);
        var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock);
        var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "alice")],
            CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);

        Assert.True(sut.IsRoleRefreshDue(principal, Start));
    }

    [Fact]
    public void IsRoleRefreshDue_UnparsableClaim_ReturnsDue()
    {
        // A present but unparsable zb:lastrolerefresh claim is treated as due, not as a
        // parse error that keeps the session stale forever.
        var clock = new TestTimeProvider(Start);
        var sut = CreateValidator(new FakeGroupRoleMapper(new RoleMappingResult([], [], false)), clock);
        var identity = new ClaimsIdentity([
            new Claim(ClaimTypes.Name, "alice"),
            new Claim(JwtTokenService.LastRoleRefreshClaimType, "not-a-date"),
        ], CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);

        Assert.True(sut.IsRoleRefreshDue(principal, Start));
    }
}

/// <summary>
/// Claim-parity tests: the shared <see cref="SessionClaimBuilder"/> must produce EXACTLY
/// the claim shape the login endpoint historically minted (plus the M2.19 group +
/// refresh-anchor additions), and the refresh path — which also goes through the builder —
/// must therefore be identical. This is the load-bearing anti-drift guarantee.
/// </summary>
public class SessionClaimBuilderParityTests
{
    private static readonly DateTimeOffset At = new(2026, 6, 15, 9, 0, 0, TimeSpan.Zero);

    [Fact]
    public void Build_ProducesCanonicalIdentityRoleAndScopeClaims_LikeLogin()
    {
        var mapping = new RoleMappingResult(
            [Roles.Administrator, Roles.Designer], ["3", "4"], IsSystemWideDeployment: false);

        var principal = SessionClaimBuilder.Build("janer", "Jane Roe", ["G1", "G2"], mapping, At);

        // Identity claims — identical to the historical /auth/login set.
        Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name
        Assert.Equal("Jane Roe", principal.FindFirst(ZbClaimTypes.DisplayName)?.Value);
        Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value);

        // Roles — one per mapped role, canonical type, and IsInRole resolves.
        var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
        Assert.Equal(new[] { Roles.Administrator, Roles.Designer }.OrderBy(r => r), roles.OrderBy(r => r));
        Assert.True(principal.IsInRole(Roles.Administrator));

        // Scope — one SiteId per permitted site (non-system-wide).
        var sites = principal.FindAll(ZbClaimTypes.ScopeId).Select(c => c.Value).ToList();
        Assert.Equal(new[] { "3", "4" }.OrderBy(s => s), sites.OrderBy(s => s));
    }

    [Fact]
    public void Build_SystemWideMapping_OmitsSiteIdClaims()
    {
        var mapping = new RoleMappingResult([Roles.Deployer], [], IsSystemWideDeployment: true);

        var principal = SessionClaimBuilder.Build("op", "Operator", ["GlobalDeployers"], mapping, At);

        // Deny-by-omission preserved: a system-wide mapping stamps NO SiteId claims.
        Assert.Empty(principal.FindAll(ZbClaimTypes.ScopeId));
        Assert.Contains(Roles.Deployer, principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
    }

    [Fact]
    public void Build_StoresGroupsAndRefreshAnchor_M219Additions()
    {
        var mapping = new RoleMappingResult([Roles.Administrator], [], true);

        var principal = SessionClaimBuilder.Build("janer", "Jane Roe", ["G1", "G2"], mapping, At);

        // M2.19: one zb:group claim per LDAP group, round-trippable via ReadGroups.
        Assert.Equal(new[] { "G1", "G2" }, SessionClaimBuilder.ReadGroups(principal));

        // M2.19: refresh anchor AND idle anchor seeded from the same instant.
        Assert.Equal(At.ToString("o"), principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value);
        Assert.Equal(At.ToString("o"), principal.FindFirst(JwtTokenService.LastActivityClaimType)?.Value);
    }

    [Fact]
    public void Build_LoginThenRefresh_ProduceIdenticalClaimShape()
    {
        // The anti-drift guarantee: login and the refresh path both call Build with the
        // same inputs, so the resulting role/scope/group claim SETS are identical (only
        // the timestamp anchors differ by design).
        var mapping = new RoleMappingResult([Roles.Designer], ["9"], IsSystemWideDeployment: false);

        var login = SessionClaimBuilder.Build("u", "User", ["G"], mapping, At);
        var refresh = SessionClaimBuilder.Build("u", "User", ["G"], mapping, At.AddMinutes(16));

        static IEnumerable<string> Pairs(ClaimsPrincipal p, string type) =>
            p.FindAll(type).Select(c => c.Value).OrderBy(v => v);

        Assert.Equal(Pairs(login, ClaimTypes.Name), Pairs(refresh, ClaimTypes.Name));
        Assert.Equal(Pairs(login, JwtTokenService.DisplayNameClaimType), Pairs(refresh, JwtTokenService.DisplayNameClaimType));
        Assert.Equal(Pairs(login, JwtTokenService.UsernameClaimType), Pairs(refresh, JwtTokenService.UsernameClaimType));
        Assert.Equal(Pairs(login, JwtTokenService.RoleClaimType), Pairs(refresh, JwtTokenService.RoleClaimType));
        Assert.Equal(Pairs(login, JwtTokenService.SiteIdClaimType), Pairs(refresh, JwtTokenService.SiteIdClaimType));
        Assert.Equal(Pairs(login, JwtTokenService.GroupClaimType), Pairs(refresh, JwtTokenService.GroupClaimType));

        // The refresh anchors moved forward (that is the ONLY intended difference).
        Assert.NotEqual(
            login.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value,
            refresh.FindFirst(JwtTokenService.LastRoleRefreshClaimType)?.Value);
    }
}

#endregion

#region M2.19 (#15): OnValidatePrincipalAsync adapter — translation from SessionValidationResult to cookie context calls

/// <summary>
/// Tests for the <c>ApplyValidationResultAsync</c> helper extracted from
/// <see cref="ServiceCollectionExtensions.OnValidatePrincipalAsync"/>: verifies that each
/// <see cref="SessionValidationResult"/> case is correctly translated into the
/// corresponding <see cref="CookieValidatePrincipalContext"/> mutations.
/// The pure decision-application step is tested in isolation — the DI-resolution of
/// <see cref="CookieSessionValidator"/> is a separate concern covered by the integration
/// wiring tests in <see cref="SecurityReviewRegressionTests"/>.
/// </summary>
public class OnValidatePrincipalAdapterTests
{
    private static readonly DateTimeOffset AdapterStart = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);

    // A minimal IAuthenticationService stub that records SignOut calls so we can assert
    // the Reject path actually invokes SignOutAsync.
    private sealed class StubAuthenticationService : IAuthenticationService
    {
        public int SignOutCallCount { get; private set; }

        public Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme) =>
            Task.FromResult(AuthenticateResult.NoResult());

        public Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) =>
            Task.CompletedTask;

        public Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) =>
            Task.CompletedTask;

        public Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties) =>
            Task.CompletedTask;

        public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
        {
            SignOutCallCount++;
            return Task.CompletedTask;
        }
    }

    private static (CookieValidatePrincipalContext Context, StubAuthenticationService AuthService)
        BuildContext(ClaimsPrincipal principal)
    {
        var authService = new StubAuthenticationService();

        var services = new ServiceCollection();
        services.AddSingleton<IAuthenticationService>(authService);
        services.AddLogging();
        var serviceProvider = services.BuildServiceProvider();

        var httpContext = new DefaultHttpContext { RequestServices = serviceProvider };
        var ticket = new AuthenticationTicket(
            principal,
            new AuthenticationProperties(),
            CookieAuthenticationDefaults.AuthenticationScheme);
        var scheme = new AuthenticationScheme(
            CookieAuthenticationDefaults.AuthenticationScheme,
            displayName: null,
            handlerType: typeof(CookieAuthenticationHandler));

        var context = new CookieValidatePrincipalContext(
            httpContext,
            scheme,
            new CookieAuthenticationOptions(),
            ticket);

        return (context, authService);
    }

    private static ClaimsPrincipal AuthenticatedPrincipal() =>
        SessionClaimBuilder.Build(
            "alice", "Alice",
            ["SCADA-Admins"],
            new RoleMappingResult([Roles.Administrator], [], true),
            AdapterStart);

    [Fact]
    public async Task ApplyResult_Reject_CallsRejectPrincipalAndSignsOut()
    {
        // The Reject result is the idle-timeout path: the adapter must call RejectPrincipal
        // (sets Principal = null) AND SignOutAsync so the cookie is cleared for the next request.
        var principal = AuthenticatedPrincipal();
        var (ctx, authService) = BuildContext(principal);

        await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Reject);

        // RejectPrincipal() sets Principal to null.
        Assert.Null(ctx.Principal);
        // SignOutAsync must have been invoked exactly once.
        Assert.Equal(1, authService.SignOutCallCount);
        // ShouldRenew must NOT be set — we are expiring, not renewing.
        Assert.False(ctx.ShouldRenew);
    }

    [Fact]
    public async Task ApplyResult_Replace_ReplacesAndSetsRenew()
    {
        // The Replace result is the role-refresh path: the adapter must swap in the new
        // principal and set ShouldRenew = true so the cookie is re-issued with the new claims.
        var newPrincipal = SessionClaimBuilder.Build(
            "alice", "Alice",
            ["SCADA-Admins"],
            new RoleMappingResult([Roles.Viewer], [], false),
            AdapterStart.AddMinutes(16));
        var originalPrincipal = AuthenticatedPrincipal();
        var (ctx, authService) = BuildContext(originalPrincipal);

        await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Replace(newPrincipal));

        Assert.Same(newPrincipal, ctx.Principal);
        Assert.True(ctx.ShouldRenew);
        Assert.Equal(0, authService.SignOutCallCount);
    }

    [Fact]
    public async Task ApplyResult_Keep_LeavesContextUnchanged()
    {
        // The Keep result is the no-op path (no refresh due, or a swallowed refresh fault):
        // the adapter must leave the principal and ShouldRenew completely untouched.
        var principal = AuthenticatedPrincipal();
        var (ctx, authService) = BuildContext(principal);

        await ServiceCollectionExtensions.ApplyValidationResultAsync(ctx, SessionValidationResult.Keep);

        Assert.Same(principal, ctx.Principal);
        Assert.False(ctx.ShouldRenew);
        Assert.Equal(0, authService.SignOutCallCount);
    }
}

#endregion

#region M2.19 (#15): SecurityOptionsValidator — config-guard fails startup on misconfiguration

/// <summary>
/// Tests for <see cref="SecurityOptionsValidator"/>: the startup validator that prevents
/// idle-timeout enforcement from being silently defeated by a misconfigured
/// <c>RoleRefreshThresholdMinutes &gt;= IdleTimeoutMinutes</c>.
/// </summary>
public class SecurityOptionsValidatorConfigGuardTests
{
    private static SecurityOptions ValidOptions() => new()
    {
        JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
        IdleTimeoutMinutes = 30,
        RoleRefreshThresholdMinutes = 15,
    };

    [Fact]
    public void Validate_DefaultOptions_Succeeds()
    {
        var result = new SecurityOptionsValidator().Validate(name: null, ValidOptions());
        Assert.True(result.Succeeded);
    }

    [Fact]
    public void Validate_RefreshEqualsIdle_Fails()
    {
        // Equal is invalid: a refresh cycle at t=30 would advance LastActivity to t=30 which
        // equals the idle timeout — enforcement is defeated.
        var options = ValidOptions();
        options.RoleRefreshThresholdMinutes = options.IdleTimeoutMinutes; // 30 == 30

        var result = new SecurityOptionsValidator().Validate(name: null, options);

        Assert.True(result.Failed);
        Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage);
        Assert.Contains(nameof(SecurityOptions.IdleTimeoutMinutes), result.FailureMessage);
    }

    [Fact]
    public void Validate_RefreshGreaterThanIdle_Fails()
    {
        // Inverted: refresh threshold LARGER than the idle window is clearly wrong and
        // must fail loudly at startup.
        var options = ValidOptions();
        options.RoleRefreshThresholdMinutes = 60; // 60 > 30

        var result = new SecurityOptionsValidator().Validate(name: null, options);

        Assert.True(result.Failed);
        Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage);
    }

    [Fact]
    public void Validate_RefreshOneLessThanIdle_Succeeds()
    {
        // Boundary: threshold = idle - 1 is the tightest VALID configuration.
        var options = ValidOptions();
        options.IdleTimeoutMinutes = 30;
        options.RoleRefreshThresholdMinutes = 29;

        var result = new SecurityOptionsValidator().Validate(name: null, options);

        Assert.True(result.Succeeded);
    }

    [Fact]
    public void AddSecurity_RegistersSecurityOptionsValidator_WithValidateOnStart()
    {
        // End-to-end wiring: AddSecurity registers SecurityOptionsValidator as
        // IValidateOptions<SecurityOptions>. ValidateOnStart is what makes the DI container
        // call Validate on startup. We confirm the validator is present in the container.
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddDataProtection();
        services.AddSecurity();

        using var provider = services.BuildServiceProvider();
        var validators = provider.GetServices<IValidateOptions<SecurityOptions>>().ToList();
        Assert.Contains(validators, v => v is SecurityOptionsValidator);
    }

    [Fact]
    public void AddSecurity_RegisteredValidator_DetectsMisconfiguration_WhenCalledDirectly()
    {
        // Confirm that the SecurityOptionsValidator registered by AddSecurity actually
        // catches a bad configuration. We resolve the validator from the container and
        // call it directly — the ValidateOnStart pipeline would fire the same validator
        // during IHost.StartAsync in a real deployment.
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddDataProtection();
        services.AddSecurity();

        using var provider = services.BuildServiceProvider();
        var validator = provider.GetServices<IValidateOptions<SecurityOptions>>()
            .OfType<SecurityOptionsValidator>()
            .Single();

        // A configuration where RoleRefreshThreshold == IdleTimeout defeats idle enforcement.
        var badOptions = new SecurityOptions
        {
            JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
            IdleTimeoutMinutes = 30,
            RoleRefreshThresholdMinutes = 30, // == IdleTimeoutMinutes — invalid
        };
        var result = validator.Validate(name: null, badOptions);

        Assert.True(result.Failed);
        Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage);
    }
}

#endregion
