using ScadaLink.Commons.Types;

namespace ScadaLink.Commons.Tests.Types;

/// <summary>
/// Regression tests for Commons-001: the check-then-act race between the timer
/// callback (<c>OnTimerElapsed</c>) and <c>OnValueReceived</c> / <c>Stop</c> / <c>Start</c>.
///
/// The original implementation guarded firing with a single <c>volatile bool</c> that
/// was both read by the callback and reset by the caller threads. Because the
/// check-then-set was not atomic with the timer reschedule, a callback that had
/// already entered could raise <c>Stale</c> after the period it was scheduled for
/// had been cancelled or restarted — a spurious staleness signal that, for a
/// connection-health monitor, triggers an unnecessary reconnect.
///
/// These tests use the internal <c>CallbackEnteredHook</c> seam to deterministically
/// interleave a caller-thread operation with an in-flight callback.
/// </summary>
public class StaleTagMonitorRaceTests
{
    /// <summary>
    /// A value arrives (<c>OnValueReceived</c>) while a previous-period timer callback
    /// is in flight, before that callback decides whether to fire. The old period has
    /// been superseded, so the in-flight callback must not raise <c>Stale</c>
    /// immediately; <c>Stale</c> may only fire later, for the fresh period, after a
    /// full <c>MaxSilence</c> with no further values.
    ///
    /// With the original single-volatile-bool guard the in-flight callback fired
    /// <c>Stale</c> right after the value arrived (a spurious, wrong-moment signal);
    /// this test detects that by checking how soon the fire lands after the value.
    /// </summary>
    [Fact]
    public void Stale_DoesNotFirePromptly_WhenValueArrivesWhileCallbackInFlight()
    {
        var maxSilence = TimeSpan.FromMilliseconds(60);
        using var monitor = new StaleTagMonitor(maxSilence);

        DateTime? valueArrivedAt = null;
        DateTime? staleFiredAt = null;
        monitor.Stale += () => staleFiredAt ??= DateTime.UtcNow;

        // When the (old-period) callback is entered, simulate a fresh value arriving
        // on another thread before the callback's fire decision.
        monitor.CallbackEnteredHook = () =>
        {
            monitor.CallbackEnteredHook = null; // only intercept the first callback
            valueArrivedAt = DateTime.UtcNow;
            monitor.OnValueReceived();
        };

        monitor.Start();

        // Wait well past the intercepted callback and the fresh period's deadline.
        Thread.Sleep(300);
        monitor.Stop();

        // The fresh period legitimately goes stale, so a fire is expected — but it
        // must land roughly MaxSilence after the value, not immediately. A spurious
        // wrong-moment fire from the superseded callback would land within a few ms.
        Assert.NotNull(valueArrivedAt);
        Assert.NotNull(staleFiredAt);
        var delay = staleFiredAt.Value - valueArrivedAt.Value;
        Assert.True(delay >= maxSilence * 0.5,
            $"Stale fired only {delay.TotalMilliseconds:F0}ms after the value arrived; " +
            $"expected at least {maxSilence.TotalMilliseconds * 0.5:F0}ms — the in-flight " +
            "callback fired spuriously for the superseded period.");
    }

    /// <summary>
    /// <c>Stop</c> races an in-flight timer callback. Once monitoring is stopped no
    /// <c>Stale</c> signal may be delivered, even for a callback that had already
    /// been entered.
    /// </summary>
    [Fact]
    public void Stale_DoesNotFire_WhenStopRacesInFlightCallback()
    {
        using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
        var staleCount = 0;
        monitor.Stale += () => Interlocked.Increment(ref staleCount);

        monitor.CallbackEnteredHook = () =>
        {
            monitor.CallbackEnteredHook = null;
            monitor.Stop();
        };

        monitor.Start();
        Thread.Sleep(200);

        Assert.Equal(0, staleCount);
    }

    /// <summary>
    /// <c>Start</c> (a restart) races an in-flight callback from the prior run. The
    /// old callback belongs to a superseded period and must not fire; the new period
    /// fires exactly once on its own deadline.
    /// </summary>
    [Fact]
    public void Stale_FiresOnceForNewPeriod_WhenRestartRacesInFlightCallback()
    {
        using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
        var staleCount = 0;
        monitor.Stale += () => Interlocked.Increment(ref staleCount);

        monitor.CallbackEnteredHook = () =>
        {
            monitor.CallbackEnteredHook = null;
            monitor.Start(); // restart — supersedes the in-flight callback's period
        };

        monitor.Start();

        // Old callback must be suppressed; the restarted period fires exactly once.
        Thread.Sleep(250);
        monitor.Stop();

        Assert.Equal(1, staleCount);
    }
}
