using ZB.MOM.WW.ScadaBridge.Transport.Import;

namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;

/// <summary>
/// Transport-004: <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap
/// over a trailing window — the design doc's "per-IP-per-hour" cap (§11). The
/// limiter accepts any opaque caller key (typically a remote IP); these tests use
/// IP-style strings to mirror the documented intent.
/// </summary>
public sealed class BundleUnlockRateLimiterTests
{
    private sealed class TestClock : TimeProvider
    {
        private DateTimeOffset _now;
        public TestClock(DateTimeOffset start) { _now = start; }
        public override DateTimeOffset GetUtcNow() => _now;
        public void Advance(TimeSpan delta) { _now += delta; }
    }

    [Fact]
    public void TryRegisterAttempt_UnderLimit_ReturnsTrue()
    {
        // The first N attempts at the same key are permitted; the trailing-hour
        // count tracks them.
        var clock = new TestClock(DateTimeOffset.UtcNow);
        var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));

        for (var i = 1; i <= 10; i++)
        {
            Assert.True(
                limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10),
                $"Attempt {i} should be allowed (under the cap).");
        }

        Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
    }

    [Fact]
    public void TryRegisterAttempt_AtLimit_RejectsNextAttempt()
    {
        // N attempts allowed, attempt N+1 rejected — the headline contract.
        var clock = new TestClock(DateTimeOffset.UtcNow);
        var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));

        for (var i = 0; i < 10; i++)
        {
            Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
        }

        // 11th attempt within the hour exceeds the cap.
        Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));

        // Subsequent attempts also rejected — the limiter does NOT silently let a
        // 12th, 13th, ... attempt through (no leak past the cap).
        Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));

        // And the recorded count never exceeds the cap (rejected attempts are not
        // appended to the trailing-hour queue).
        Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
    }

    [Fact]
    public void TryRegisterAttempt_EntriesExpireAfterWindow()
    {
        // Once the trailing-hour window rolls past every recorded attempt the key
        // is fully reset — a legitimate operator returning later is not penalised.
        var clock = new TestClock(DateTimeOffset.UtcNow);
        var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));

        for (var i = 0; i < 10; i++)
        {
            Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
        }
        Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));

        // Roll just past the window's end. Every recorded timestamp is now
        // strictly older than (now - window) and must be pruned.
        clock.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1));

        Assert.Equal(0, limiter.GetAttemptCount("10.0.0.1"));
        // A fresh full budget is available.
        Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
    }

    [Fact]
    public void TryRegisterAttempt_PartialExpiry_ReleasesOldestSlotOnly()
    {
        // Sliding window — when only some of the recorded entries have aged out,
        // exactly that many slots are released.
        var clock = new TestClock(DateTimeOffset.UtcNow);
        var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));

        // Five attempts at t=0.
        for (var i = 0; i < 5; i++)
        {
            Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
        }

        // 30 minutes later, five more — saturates the budget.
        clock.Advance(TimeSpan.FromMinutes(30));
        for (var i = 0; i < 5; i++)
        {
            Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
        }
        Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));

        // Roll just past the first batch's window. Only those five entries expire;
        // the second batch (recorded at t=30) is still within window from t=61.
        clock.Advance(TimeSpan.FromMinutes(31));

        Assert.Equal(5, limiter.GetAttemptCount("10.0.0.1"));
        // Five fresh slots are available.
        for (var i = 0; i < 5; i++)
        {
            Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
        }
        Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
    }

    [Fact]
    public void TryRegisterAttempt_PerKeyIsolation()
    {
        // The cap is per key — saturating one IP does not affect another.
        var clock = new TestClock(DateTimeOffset.UtcNow);
        var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));

        for (var i = 0; i < 10; i++)
        {
            Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
        }
        Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));

        // A different IP has its own untouched budget.
        Assert.True(limiter.TryRegisterAttempt("10.0.0.2", maxAttemptsPerWindow: 10));
        Assert.Equal(1, limiter.GetAttemptCount("10.0.0.2"));
        Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public void TryRegisterAttempt_BlankKey_Throws(string? key)
    {
        var limiter = new BundleUnlockRateLimiter();
        Assert.ThrowsAny<ArgumentException>(
            () => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void TryRegisterAttempt_NonPositiveLimit_Throws(int limit)
    {
        var limiter = new BundleUnlockRateLimiter();
        Assert.Throws<ArgumentOutOfRangeException>(
            () => limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: limit));
    }
}
