using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;

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

/// <summary>
///     Unit tests for <see cref="MxAccessValueCache"/>. The cache is consumed by
///     <see cref="MxAccessSession.ReadBulk"/> to satisfy "current value"
///     requests for already-advised tags without touching the existing
///     subscription, so its contract is exercised in isolation here before any
///     STA / COM plumbing gets layered on top.
/// </summary>
public sealed class MxAccessValueCacheTests
{
    /// <summary>Verifies that cache returns the last value with incrementing versions.</summary>
    [Fact]
    public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion()
    {
        MxAccessValueCache cache = new();
        Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc));

        cache.Set(serverHandle: 7, itemHandle: 21, BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp));

        Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue first));
        Assert.Equal(1UL, first.Version);
        Assert.Equal(100, first.Value.Int32Value);
        Assert.Equal(192, first.Quality);
        Assert.Equal(sourceTimestamp, first.SourceTimestamp);

        // A second Set on the same key bumps the version and overwrites the
        // payload. Different keys remain isolated.
        cache.Set(7, 21, BuildEvent(7, 21, intValue: 200, quality: 192, sourceTimestamp));
        cache.Set(7, 22, BuildEvent(7, 22, intValue: 999, quality: 192, sourceTimestamp));

        Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue second));
        Assert.Equal(2UL, second.Version);
        Assert.Equal(200, second.Value.Int32Value);

        Assert.True(cache.TryGet(7, 22, out MxAccessValueCache.CachedValue other));
        Assert.Equal(1UL, other.Version);
        Assert.Equal(999, other.Value.Int32Value);
    }

    /// <summary>
    ///     Pins the ownership contract: <c>Set</c> borrows the event's own
    ///     protobuf sub-messages instead of deep-copying them, so <c>TryGet</c>
    ///     hands back the very <c>Value</c>, <c>SourceTimestamp</c>, and
    ///     <c>MxStatusProxy</c> instances the caller passed in.
    ///
    ///     This is safe only because the event is write-once by the time
    ///     <c>Set</c> runs: the sink enqueues it (which stamps the worker
    ///     sequence and timestamp) and only then post-publishes it here, and
    ///     <see cref="MxAccessEventQueue"/>'s ownership invariant forbids
    ///     mutating an enqueued event. This test therefore asserts reference
    ///     identity and deliberately does NOT mutate the event afterwards —
    ///     doing so is exactly what the contract forbids, and it would also
    ///     invalidate the serialized size the queue memoized at enqueue.
    ///
    ///     It replaced a test asserting the opposite (an independent deep-copied
    ///     snapshot). The three clones that test pinned — the MxValue, the
    ///     Timestamp, and the RepeatedField plus every status row in it — ran on
    ///     every OnDataChange and bought nothing: the read path already aliased
    ///     the cache's instances into every BulkReadResult.
    /// </summary>
    [Fact]
    public void Set_BorrowsTheEventsOwnInstances_ByOwnershipContract()
    {
        MxAccessValueCache cache = new();
        Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc));
        MxEvent mxEvent = BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp);

        cache.Set(7, 21, mxEvent);

        Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue cached));
        Assert.Same(mxEvent.Value, cached.Value);
        Assert.Same(mxEvent.SourceTimestamp, cached.SourceTimestamp);
        Assert.Same(mxEvent.Statuses, cached.Statuses);
        Assert.Same(mxEvent.Statuses[0], Assert.Single(cached.Statuses));
        Assert.Equal(192, cached.Quality);
    }

    /// <summary>Verifies that TryGet returns false for unknown handles.</summary>
    [Fact]
    public void TryGet_WithUnknownHandle_ReturnsFalse()
    {
        MxAccessValueCache cache = new();

        Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _));
    }

    /// <summary>Verifies that Remove drops entries and resets versions.</summary>
    [Fact]
    public void Remove_DropsEntryAndResetsVersion()
    {
        MxAccessValueCache cache = new();
        cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
        cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));

        cache.Remove(7, 21);
        Assert.False(cache.TryGet(7, 21, out _));

        // After Remove, a subsequent Set restarts the per-handle version from 1
        // — the cache must not serve a stale "version 3" entry that would race
        // against a reused MXAccess item handle.
        cache.Set(7, 21, BuildEvent(7, 21, intValue: 3, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
        Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue reset));
        Assert.Equal(1UL, reset.Version);
    }

    /// <summary>Verifies that CurrentVersion returns zero for unknown handles and the latest for known ones.</summary>
    [Fact]
    public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown()
    {
        MxAccessValueCache cache = new();
        Assert.Equal(0UL, cache.CurrentVersion(7, 21));

        cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
        cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));

        Assert.Equal(2UL, cache.CurrentVersion(7, 21));
    }

    /// <summary>Verifies that TryWaitForUpdate returns false after the deadline expires.</summary>
    [Fact]
    public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs()
    {
        MxAccessValueCache cache = new();
        int pumpCalls = 0;

        // Deadline already in the past — eliminates the wall-clock-floor
        // race. The loop must pump once (so MXAccess messages can dispatch
        // on the calling thread even when the deadline has just expired)
        // and then immediately observe the passed deadline.
        DateTime expiredDeadlineUtc = DateTime.UtcNow.AddMilliseconds(-1);

        bool result = cache.TryWaitForUpdate(
            serverHandle: 7,
            itemHandle: 21,
            sinceVersion: 0,
            deadlineUtc: expiredDeadlineUtc,
            pumpStep: () => Interlocked.Increment(ref pumpCalls),
            out MxAccessValueCache.CachedValue value,
            pollIntervalMs: 5);

        Assert.False(result);
        Assert.Equal(default, value.Value);
        Assert.Equal(1, pumpCalls);
    }

    /// <summary>Verifies that TryWaitForUpdate returns true when the cache is updated after the baseline.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion()
    {
        MxAccessValueCache cache = new();
        Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
        // Baseline is "no entry yet" → wait for the first Set to land.
        Task<(bool ok, MxAccessValueCache.CachedValue value)> waitTask = Task.Run(() =>
        {
            bool ok = cache.TryWaitForUpdate(
                serverHandle: 7,
                itemHandle: 21,
                sinceVersion: 0,
                deadlineUtc: DateTime.UtcNow.AddSeconds(2),
                pumpStep: () => { },
                out MxAccessValueCache.CachedValue v,
                pollIntervalMs: 5);
            return (ok, v);
        });

        // Race a Set against the wait loop. The cache's lock guarantees the
        // wait observes the new version before TryGet returns it.
        await Task.Delay(20);
        cache.Set(7, 21, BuildEvent(7, 21, intValue: 4242, quality: 192, sourceTimestamp));

        (bool ok, MxAccessValueCache.CachedValue value) = await waitTask;
        Assert.True(ok);
        Assert.Equal(4242, value.Value.Int32Value);
        Assert.Equal(1UL, value.Version);
    }

    /// <summary>
    ///     Verifies the wait slice stays bounded no matter what poll interval the
    ///     caller asks for: with a 10 s interval and a 3 s deadline, a value set
    ///     50 ms in is still returned in time, because every slice is capped at
    ///     the 50 ms fallback tick. The blind <c>Thread.Sleep</c> this replaced
    ///     would have slept the full interval — on the STA, 10 s with no Windows
    ///     messages dispatched, so the OnDataChange being waited for could not
    ///     have arrived at all — and then reported a timeout.
    /// </summary>
    /// <remarks>
    ///     Scope: this proves the cadence bound, not the signal path. The clamp
    ///     alone would satisfy it, so it stays green if the <c>Set</c>-side
    ///     signal is deleted. <c>StaWaitHelperTests</c> owns the signal path,
    ///     where a five-second wait with no clamp in play makes the handle the
    ///     only thing that can end it early.
    /// </remarks>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task TryWaitForUpdate_CompletesWithinDeadline_WhenPollIntervalExceedsTheFallbackTick()
    {
        MxAccessValueCache cache = new();
        Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
        using ManualResetEventSlim waitEntered = new(false);

        Task setter = Task.Run(async () =>
        {
            // Handshake so the value cannot land before the wait starts —
            // otherwise the first check would satisfy it and prove nothing.
            waitEntered.Wait(TimeSpan.FromSeconds(5));
            await Task.Delay(50, CancellationToken.None);
            cache.Set(7, 21, BuildEvent(7, 21, intValue: 8080, quality: 192, sourceTimestamp));
        });

        Stopwatch elapsed = Stopwatch.StartNew();
        bool found = cache.TryWaitForUpdate(
            serverHandle: 7,
            itemHandle: 21,
            sinceVersion: 0,
            deadlineUtc: DateTime.UtcNow.AddSeconds(3),
            pumpStep: () => waitEntered.Set(),
            out MxAccessValueCache.CachedValue value,
            pollIntervalMs: 10_000);
        elapsed.Stop();
        await setter;

        Assert.True(found);
        Assert.Equal(8080, value.Value.Int32Value);
        Assert.True(
            elapsed.Elapsed < TimeSpan.FromSeconds(2),
            $"The wait should have re-checked within the fallback tick, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
    }

    /// <summary>
    ///     Verifies the pump step keeps running throughout a wait that nothing
    ///     ever signals: the caller's poll interval is capped at the 50 ms
    ///     fallback tick, so a timing-out wait still pumps repeatedly instead of
    ///     once. This is what keeps the STA dispatching COM events in a process
    ///     whose message queue never wakes the wait, and it is the safety net
    ///     behind the ReadBulk per-tag timeout.
    /// </summary>
    [Fact]
    public void TryWaitForUpdate_KeepsPumping_WhenPollIntervalExceedsTheFallbackTick()
    {
        MxAccessValueCache cache = new();
        int pumpCalls = 0;

        bool found = cache.TryWaitForUpdate(
            serverHandle: 7,
            itemHandle: 21,
            sinceVersion: 0,
            deadlineUtc: DateTime.UtcNow.AddMilliseconds(400),
            pumpStep: () => Interlocked.Increment(ref pumpCalls),
            out _,
            pollIntervalMs: 10_000);

        Assert.False(found);
        Assert.True(pumpCalls >= 3, $"Expected repeated pumping across the 400 ms wait, saw {pumpCalls} calls.");
    }

    private static MxEvent BuildEvent(
        int serverHandle,
        int itemHandle,
        int intValue,
        int quality,
        Timestamp sourceTimestamp)
    {
        MxEvent mxEvent = new()
        {
            Family = MxEventFamily.OnDataChange,
            ServerHandle = serverHandle,
            ItemHandle = itemHandle,
            Quality = quality,
            SourceTimestamp = sourceTimestamp,
            Value = new MxValue
            {
                DataType = MxDataType.Integer,
                VariantType = "VT_I4",
                Int32Value = intValue,
            },
            OnDataChange = new OnDataChangeEvent(),
        };
        mxEvent.Statuses.Add(new MxStatusProxy
        {
            Category = MxStatusCategory.Ok,
        });
        return mxEvent;
    }
}
