using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Worker.Sta;

namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta;

/// <summary>
///     Tests for <see cref="StaWaitHelper"/>, the message-aware wait behind the
///     write-completion and ReadBulk value waits.
/// </summary>
/// <remarks>
///     <para>
///         These exist because the cache-level tests cannot guard the signal
///         path. Every cache wait clamps its slice to
///         <see cref="StaWaitHelper.MaxFallbackTickMilliseconds"/>, so a wait
///         woken by the handle and a wait that merely timed out and re-checked
///         are milliseconds apart — no wall-clock assertion up there can tell
///         them apart, and deleting the caches' <c>Set()</c> calls would leave
///         them green. Calling the helper directly with a five-second timeout
///         removes the clamp from the picture: only the handle can end that wait
///         early, and the test asserts the handle was the thing consumed.
///     </para>
///     <para>
///         Boundary: the <c>WAIT_FAILED</c> branch is not exercised, for the same
///         reason <see cref="StaMessagePumpTests"/> gives — forcing
///         <c>MsgWaitForMultipleObjectsEx</c> to fail means handing it a
///         deliberately invalid native handle, which is unsafe to construct in a
///         managed test.
///     </para>
/// </remarks>
public sealed class StaWaitHelperTests
{
    /// <summary>Verifies that a null wait handle is rejected.</summary>
    [Fact]
    public void WaitForSignalOrMessages_NullSignal_ThrowsArgumentNullException()
    {
        ArgumentNullException exception = Assert.Throws<ArgumentNullException>(
            () => StaWaitHelper.WaitForSignalOrMessages(null!, 10));

        Assert.Equal("signal", exception.ParamName);
    }

    /// <summary>
    ///     Verifies the wait ends on a cross-thread signal rather than on its
    ///     timeout: the handle is set ~50 ms into a five-second wait, and the
    ///     wait must return two orders of magnitude before that timeout. The
    ///     post-condition that the handle was consumed is what makes this a
    ///     discriminator — an auto-reset event that our wait did not take would
    ///     still be signalled afterwards, which would mean something other than
    ///     the signal ended the wait.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task WaitForSignalOrMessages_SignalSetDuringWait_ReturnsLongBeforeTheTimeout()
    {
        using AutoResetEvent signal = new(initialState: false);

        // Drain anything already queued for this thread so the wait cannot be
        // woken by stale input instead of by the signal.
        new StaMessagePump().PumpPendingMessages();

        Task setter = Task.Run(async () =>
        {
            await Task.Delay(50, CancellationToken.None);
            signal.Set();
        });

        Stopwatch elapsed = Stopwatch.StartNew();
        StaWaitHelper.WaitForSignalOrMessages(signal, 5000);
        elapsed.Stop();
        await setter;

        Assert.True(
            elapsed.Elapsed < TimeSpan.FromMilliseconds(500),
            $"Wait took {elapsed.ElapsedMilliseconds} ms of its 5000 ms timeout; the signal should have ended it.");
        Assert.False(
            signal.WaitOne(0),
            "The wait did not consume the signal, so something other than the signal ended it.");
    }

    /// <summary>
    ///     Verifies an already-signalled handle ends the wait immediately, which
    ///     is the case that keeps a completion recorded between the caller's
    ///     check and its wait from being missed.
    /// </summary>
    [Fact]
    public void WaitForSignalOrMessages_PreSignalledHandle_ReturnsImmediately()
    {
        using AutoResetEvent signal = new(initialState: true);

        // Drained for the same reason as the other two waits: a stale message could end this wait
        // instead of the handle, which would leave the signal un-consumed and fail the
        // post-condition below for a reason that has nothing to do with pre-signalling.
        new StaMessagePump().PumpPendingMessages();

        Stopwatch elapsed = Stopwatch.StartNew();
        StaWaitHelper.WaitForSignalOrMessages(signal, 30_000);
        elapsed.Stop();

        Assert.True(
            elapsed.Elapsed < TimeSpan.FromSeconds(5),
            $"Wait took {elapsed.ElapsedMilliseconds} ms; a pre-signalled handle must return at once.");
        Assert.False(signal.WaitOne(0), "The wait should have consumed the signal.");
    }

    /// <summary>
    ///     Verifies the wait actually blocks for its timeout when nothing signals
    ///     it. The lower bound is the point: a wait that returned instantly would
    ///     turn every caller's poll loop into a spin, which is exactly the
    ///     failure mode the queue must be drained to avoid.
    /// </summary>
    [Fact]
    public void WaitForSignalOrMessages_NeverSignalled_BlocksUntilTheTimeout()
    {
        using AutoResetEvent signal = new(initialState: false);

        // The wait wakes on input that is merely present, so drain first —
        // otherwise a stale message would end the wait early and the lower
        // bound below would be measuring the wrong thing.
        new StaMessagePump().PumpPendingMessages();

        Stopwatch elapsed = Stopwatch.StartNew();
        StaWaitHelper.WaitForSignalOrMessages(signal, 200);
        elapsed.Stop();

        // Loose lower bound: the OS may return slightly early on a coarse timer
        // tick, so this proves "it blocked", not "it blocked for exactly 200 ms".
        Assert.True(
            elapsed.Elapsed >= TimeSpan.FromMilliseconds(100),
            $"Wait returned after {elapsed.ElapsedMilliseconds} ms; a 200 ms wait must not return instantly.");
        Assert.True(
            elapsed.Elapsed < TimeSpan.FromSeconds(5),
            $"Wait took {elapsed.ElapsedMilliseconds} ms; a 200 ms timeout must end it.");
    }

    /// <summary>
    ///     Verifies a non-positive timeout returns without entering the wait at
    ///     all — proven by the signal still being set afterwards, since a wait
    ///     that ran would have consumed it.
    /// </summary>
    /// <param name="timeoutMilliseconds">The non-positive timeout under test.</param>
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void WaitForSignalOrMessages_NonPositiveTimeout_DoesNotEnterTheWait(int timeoutMilliseconds)
    {
        using AutoResetEvent signal = new(initialState: true);

        Stopwatch elapsed = Stopwatch.StartNew();
        StaWaitHelper.WaitForSignalOrMessages(signal, timeoutMilliseconds);
        elapsed.Stop();

        Assert.True(
            elapsed.Elapsed < TimeSpan.FromSeconds(2),
            $"Wait took {elapsed.ElapsedMilliseconds} ms; a non-positive timeout must not block.");
        Assert.True(signal.WaitOne(0), "The signal must be untouched when the wait is skipped.");
    }

    /// <summary>
    ///     Boundary table for the slice math. Every caller's wait length comes
    ///     from here, so each clause is pinned: a poll interval below one clamps
    ///     up to 1 ms, an expired deadline yields 0 (the caller returns without
    ///     waiting), a deadline nearer than the tick rounds up so the wait never
    ///     ends before the deadline, and any interval above the fallback ceiling
    ///     clamps down to it.
    /// </summary>
    /// <param name="remainingMilliseconds">Time left before the caller's deadline.</param>
    /// <param name="pollIntervalMilliseconds">The caller's requested pump cadence.</param>
    /// <param name="expected">The expected wait slice in milliseconds.</param>
    [Theory]
    // pollInterval < 1 clamps up to 1 ms — never a zero-length spin.
    [InlineData(1000d, 0, 1)]
    [InlineData(1000d, -5, 1)]
    // remaining <= 0 yields 0; the caller has already returned on its deadline.
    [InlineData(0d, 5, 0)]
    [InlineData(-10d, 5, 0)]
    // remaining < tick shortens the wait to the deadline.
    [InlineData(3d, 5, 3)]
    [InlineData(1d, 50, 1)]
    // remaining == tick is not "less than", so the tick stands.
    [InlineData(5d, 5, 5)]
    // The caller's interval wins while it is under the ceiling.
    [InlineData(1000d, 5, 5)]
    [InlineData(1000d, 49, 49)]
    // Anything at or above the ceiling clamps to it — this is what bounds the
    // unconditional pumpStep cadence regardless of what a caller asks for.
    [InlineData(1000d, 50, 50)]
    [InlineData(1000d, 10_000, 50)]
    [InlineData(50d, 10_000, 50)]
    public void ClampWaitMilliseconds_BoundaryTable(
        double remainingMilliseconds,
        int pollIntervalMilliseconds,
        int expected)
    {
        int actual = StaWaitHelper.ClampWaitMilliseconds(
            TimeSpan.FromMilliseconds(remainingMilliseconds),
            pollIntervalMilliseconds);

        Assert.Equal(expected, actual);
    }

    /// <summary>
    ///     Verifies a sub-millisecond remainder rounds up rather than down: a
    ///     zero-length wait would spin, and truncating would end the wait before
    ///     the caller's deadline. Built from ticks because
    ///     <see cref="TimeSpan.FromMilliseconds(double)"/> rounds its argument to
    ///     whole milliseconds and so cannot express these values at all.
    /// </summary>
    [Fact]
    public void ClampWaitMilliseconds_SubMillisecondRemaining_RoundsUp()
    {
        // 10_000 ticks == 1 ms.
        Assert.Equal(1, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(4_000), 5));
        Assert.Equal(3, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(25_000), 5));
        Assert.Equal(1, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(1), 5));
    }

    /// <summary>
    ///     Verifies the clamp never exceeds the time actually left, which is what
    ///     keeps a wait from overrunning its caller's deadline.
    /// </summary>
    [Fact]
    public void ClampWaitMilliseconds_NeverExceedsRemainingOrTheCeiling()
    {
        for (int remaining = 1; remaining <= 200; remaining++)
        {
            int slice = StaWaitHelper.ClampWaitMilliseconds(
                TimeSpan.FromMilliseconds(remaining),
                pollIntervalMilliseconds: 10_000);

            Assert.True(slice >= 1, $"Slice for {remaining} ms remaining was {slice}; a positive deadline must wait.");
            Assert.True(slice <= remaining, $"Slice {slice} overran the {remaining} ms left before the deadline.");
            Assert.True(
                slice <= StaWaitHelper.MaxFallbackTickMilliseconds,
                $"Slice {slice} exceeded the {StaWaitHelper.MaxFallbackTickMilliseconds} ms fallback ceiling.");
        }
    }
}
