namespace DodoSSH.Client.Terminal.Tests;

/// <summary>
/// The flow-control accounting.
/// </summary>
/// <remarks>
/// Worth testing on its own, ahead of the pump, because everything else about the terminal depends on
/// this arithmetic being right. An off-by-one that leaks credit shows up as a session that stalls
/// after several minutes of heavy output — a symptom nobody would trace back to here.
/// </remarks>
public sealed class CreditWindowTests
{
    [Fact]
    public void AFreshWindow_OffersItsWholeSize()
    {
        var window = new CreditWindow(1024);

        window.Available.ShouldBe(1024);
        window.Outstanding.ShouldBe(0);
    }

    [Fact]
    public void Reserving_ReducesWhatIsAvailable()
    {
        var window = new CreditWindow(1024);

        window.TryReserve(400).ShouldBe(400);

        window.Outstanding.ShouldBe(400);
        window.Available.ShouldBe(624);
    }

    [Fact]
    public void ReservingMoreThanRemains_GrantsAPartialAmount()
    {
        // All-or-nothing would stall a session that could have made progress with what was left, and
        // the caller has to cope with a short read regardless.
        var window = new CreditWindow(1024);
        window.TryReserve(900);

        window.TryReserve(400).ShouldBe(124);
        window.Available.ShouldBe(0);
    }

    [Fact]
    public void AFullWindow_GrantsNothing()
    {
        var window = new CreditWindow(1024);
        window.TryReserve(1024);

        window.TryReserve(1).ShouldBe(0);
    }

    [Fact]
    public void Returning_RestoresCredit()
    {
        var window = new CreditWindow(1024);
        window.TryReserve(1024);

        window.Return(512);

        window.Available.ShouldBe(512);
        window.Outstanding.ShouldBe(512);
    }

    [Fact]
    public void ReturningMoreThanWasReserved_IsClamped()
    {
        // The acknowledgement crosses into JavaScript, so a buggy or tampered page can claim to have
        // rendered more than it was sent. Letting that drive outstanding negative would hand it an
        // unbounded window, which is precisely what this class exists to prevent.
        var window = new CreditWindow(1024);
        window.TryReserve(100);

        window.Return(100_000);

        window.Outstanding.ShouldBe(0);
        window.Available.ShouldBe(1024);
    }

    [Fact]
    public void ReturningWithNothingOutstanding_ChangesNothing()
    {
        var window = new CreditWindow(1024);

        window.Return(500);

        window.Available.ShouldBe(1024);
    }

    [Fact]
    public async Task WaitingWithCreditAvailable_ReturnsImmediately()
    {
        var window = new CreditWindow(1024);

        await window.WaitForCreditAsync(TestContext.Current.CancellationToken);
    }

    [Fact]
    public async Task WaitingOnAFullWindow_BlocksUntilCreditIsReturned()
    {
        var window = new CreditWindow(1024);
        window.TryReserve(1024);

        var wait = window.WaitForCreditAsync(TestContext.Current.CancellationToken).AsTask();

        wait.IsCompleted.ShouldBeFalse("A full window must not let a reader proceed.");

        window.Return(1);

        await wait;
    }

    [Fact]
    public async Task Reset_UnblocksAWaiter()
    {
        // A session being torn down must not leave its reader parked forever on credit that will
        // never be acknowledged.
        var window = new CreditWindow(1024);
        window.TryReserve(1024);

        var wait = window.WaitForCreditAsync(TestContext.Current.CancellationToken).AsTask();
        wait.IsCompleted.ShouldBeFalse();

        window.Reset();

        await wait;
        window.Outstanding.ShouldBe(0);
    }

    [Fact]
    public async Task Waiting_ObservesCancellation()
    {
        var window = new CreditWindow(1024);
        window.TryReserve(1024);

        using var cts = new CancellationTokenSource();
        var wait = window.WaitForCreditAsync(cts.Token).AsTask();

        await cts.CancelAsync();

        await Should.ThrowAsync<OperationCanceledException>(async () => await wait);
    }

    [Fact]
    public async Task ConcurrentReservesAndReturns_KeepTheAccountingConsistent()
    {
        // The pump reserves on one task and returns on another, so the arithmetic has to hold under
        // real contention rather than only in a single-threaded walkthrough.
        const int Window = 64 * 1024;
        var window = new CreditWindow(Window);

        var reserved = 0;
        var returned = 0;

        var workers = Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
        {
            for (var i = 0; i < 2_000; i++)
            {
                var granted = window.TryReserve(97);
                Interlocked.Add(ref reserved, granted);

                window.Return(granted);
                Interlocked.Add(ref returned, granted);
            }
        }, TestContext.Current.CancellationToken));

        await Task.WhenAll(workers);

        reserved.ShouldBe(returned);
        window.Outstanding.ShouldBe(0);
        window.Available.ShouldBe(Window);
    }

    [Fact]
    public void AWindowOfZeroOrLess_IsRejected()
    {
        Should.Throw<ArgumentOutOfRangeException>(() => new CreditWindow(0));
        Should.Throw<ArgumentOutOfRangeException>(() => new CreditWindow(-1));
    }
}
