using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Security;

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

/// <summary>
/// Exercises <see cref="LoginThrottle"/>: the fixed-window per-(username+IP)
/// LDAP-bind failure lockout. Time is driven by a hand-rolled controllable
/// <see cref="TimeProvider"/> (the central package list has no
/// Microsoft.Extensions.TimeProvider.Testing FakeTimeProvider — mirroring the
/// CookieSessionValidator + Transport tests, which hand-roll the same clock).
/// </summary>
public class LoginThrottleTests
{
    private static readonly DateTimeOffset Start = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);

    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);
    }

    private static LoginThrottle Create(TimeProvider clock, SecurityOptions? options = null) =>
        new(clock, Options.Create(options ?? new SecurityOptions()));

    private static (LoginThrottle throttle, TestTimeProvider clock) Create()
    {
        var clock = new TestTimeProvider(Start);
        return (Create(clock), clock);
    }

    [Fact]
    public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks()
    {
        var clock = new TestTimeProvider(Start);
        var throttle = Create(clock);

        for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");

        Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
        Assert.False(throttle.IsLockedOut("alice", "10.0.0.2"));  // per username+IP key — real isolation in production requires the trusted-proxy ForwardedHeaders config (R2 N2); without it all clients share the proxy IP
        Assert.False(throttle.IsLockedOut("bob", "10.0.0.1"));

        clock.Advance(TimeSpan.FromMinutes(6));                    // lockout window passed
        Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
    }

    [Fact]
    public void SuccessResets()
    {
        var clock = new TestTimeProvider(Start);
        var throttle = Create(clock);

        for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1");
        Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));

        throttle.RecordSuccess("alice", "10.0.0.1");

        // A single failure after the reset must NOT lock out — the counter restarted.
        throttle.RecordFailure("alice", "10.0.0.1");
        Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
    }

    [Fact]
    public void KeyIsCaseInsensitiveOnUsername()
    {
        var clock = new TestTimeProvider(Start);
        var throttle = Create(clock);

        for (var i = 0; i < 5; i++) throttle.RecordFailure("Alice", "10.0.0.1");

        Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
        Assert.True(throttle.IsLockedOut("ALICE", "10.0.0.1"));
    }

    [Fact]
    public void FailuresOutsideWindow_DoNotAccumulate()
    {
        var clock = new TestTimeProvider(Start);
        var throttle = Create(clock);

        // Four failures, then let the failure window lapse before the fifth.
        for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1");
        clock.Advance(TimeSpan.FromMinutes(6));  // > LoginFailureWindowMinutes (5)
        throttle.RecordFailure("alice", "10.0.0.1");

        // The window reset, so only one failure is counted — no lockout.
        Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
    }

    [Fact]
    public void LockoutPersistsUntilLockoutMinutesElapse()
    {
        var clock = new TestTimeProvider(Start);
        var throttle = Create(clock);

        for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
        Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));

        clock.Advance(TimeSpan.FromMinutes(4));   // still inside the 5-minute lockout
        Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));

        clock.Advance(TimeSpan.FromMinutes(2));   // now past the 5-minute lockout
        Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
    }

    [Fact]
    public void ZeroMaxFailures_DisablesThrottling()
    {
        var clock = new TestTimeProvider(Start);
        var throttle = Create(clock, new SecurityOptions { MaxLoginFailuresPerWindow = 0 });

        for (var i = 0; i < 100; i++) throttle.RecordFailure("alice", "10.0.0.1");

        Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
    }

    // ── arch-review R2 N8: amortized prune ─────────────────────────────────────

    [Fact]
    public void KeySpray_MapStaysBounded_AtTheHardCap()
    {
        var (throttle, _) = Create(); // existing helper: throttle over a fake clock
        // Spray far past the cap: the over-cap eviction must run on EVERY write once
        // the cap is exceeded, regardless of the amortized cadence (arch-review R2 N8).
        for (var i = 0; i < LoginThrottle.MaxTrackedKeys + 500; i++)
            throttle.RecordFailure($"user{i}", "10.0.0.1");
        Assert.True(throttle.TrackedKeyCount <= LoginThrottle.MaxTrackedKeys);
    }

    [Fact]
    public void ExpiredEntries_AreEventuallySwept_OnTheAmortizedCadence()
    {
        var (throttle, clock) = Create();
        throttle.RecordFailure("stale", "10.0.0.1");
        clock.Advance(TimeSpan.FromMinutes(10)); // window fully expired
        for (var i = 0; i < LoginThrottle.PruneEveryNFailures; i++)
            throttle.RecordFailure($"fresh{i}", "10.0.0.1"); // cadence tick fires within these
        Assert.False(throttle.IsLockedOut("stale", "10.0.0.1"));
        Assert.True(throttle.TrackedKeyCount <= LoginThrottle.PruneEveryNFailures); // "stale" swept
    }
}
