using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using ZB.MOM.WW.MxGateway.Worker.Sta;
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;

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

/// <summary>
/// Tests for <see cref="MxAccessStaSession"/>.
/// </summary>
public sealed class MxAccessStaSessionTests
{
    /// <summary>
    ///     Verifies the launcher-env-var parse branches of
    ///     <see cref="MxAccessStaSession.ResolveWriteCompletionTimeout"/>:
    ///     a valid non-negative value is honored (0 = disabled), while a
    ///     missing, malformed, or negative value falls back to the executor
    ///     default. Env mutation is restored in a finally so parallel tests
    ///     never observe the temporary value.
    /// </summary>
    /// <param name="rawValue">Raw env-var value, or null for unset.</param>
    /// <param name="expectedMilliseconds">Expected resolved wait, or null for the executor default.</param>
    [Theory]
    [InlineData(null, null)]
    [InlineData("", null)]
    [InlineData("junk", null)]
    [InlineData("-5", null)]
    [InlineData("0", 0)]
    [InlineData("250", 250)]
    public void ResolveWriteCompletionTimeout_ParsesEnvironmentValue(string? rawValue, int? expectedMilliseconds)
    {
        string? original = Environment.GetEnvironmentVariable(
            MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName);
        try
        {
            Environment.SetEnvironmentVariable(
                MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName,
                rawValue);

            TimeSpan resolved = MxAccessStaSession.ResolveWriteCompletionTimeout();

            TimeSpan expected = expectedMilliseconds is null
                ? MxAccessCommandExecutor.DefaultWriteCompletionTimeout
                : TimeSpan.FromMilliseconds(expectedMilliseconds.Value);
            Assert.Equal(expected, resolved);
        }
        finally
        {
            Environment.SetEnvironmentVariable(
                MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName,
                original);
        }
    }

    /// <summary>
    ///     The alarm poll cadence comes from the launcher-set environment
    ///     variable; a missing, unparseable, or out-of-range value must fall
    ///     back to the 500 ms default rather than throw. The ceiling matters as
    ///     much as the floor: <see cref="int.MaxValue"/> milliseconds is ~24
    ///     days, which silently disables alarm polling altogether.
    /// </summary>
    /// <param name="environmentValue">Raw environment value under test.</param>
    /// <param name="expectedMilliseconds">Expected resolved cadence, in milliseconds.</param>
    [Theory]
    [InlineData(null, 500)]
    [InlineData("", 500)]
    [InlineData("not-a-number", 500)]
    [InlineData("0", 500)]
    [InlineData("-1", 500)]
    [InlineData("99", 500)]
    [InlineData("100", 100)]
    [InlineData("250", 250)]
    [InlineData("3600000", 3600000)]
    [InlineData("3600001", 500)]
    [InlineData("2147483647", 500)]
    public void ResolveAlarmPollInterval_WithEnvironmentValue_FallsBackToDefaultWhenOutOfRange(
        string? environmentValue,
        int expectedMilliseconds)
    {
        string? original = Environment.GetEnvironmentVariable(
            MxAccessStaSession.AlarmPollIntervalEnvironmentVariableName);
        try
        {
            Environment.SetEnvironmentVariable(
                MxAccessStaSession.AlarmPollIntervalEnvironmentVariableName,
                environmentValue);

            Assert.Equal(
                TimeSpan.FromMilliseconds(expectedMilliseconds),
                MxAccessStaSession.ResolveAlarmPollInterval());
        }
        finally
        {
            Environment.SetEnvironmentVariable(
                MxAccessStaSession.AlarmPollIntervalEnvironmentVariableName,
                original);
        }
    }

    /// <summary>
    /// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task StartAsync_CreatesComObjectAndAttachesEventSinkOnStaThread()
    {
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, eventSink);

        WorkerReady ready = await session.StartAsync("session-1", workerProcessId: 1234);

        Assert.Equal(1234, ready.WorkerProcessId);
        Assert.Equal(MxAccessInteropInfo.ProgId, ready.MxaccessProgid);
        Assert.Equal(MxAccessInteropInfo.Clsid, ready.MxaccessClsid);
        Assert.NotNull(ready.ReadyTimestamp);
        Assert.Equal(runtime.StaThreadId, factory.CreateThreadId);
        Assert.Equal(runtime.StaThreadId, eventSink.AttachThreadId);
        Assert.Equal(ApartmentState.STA, factory.CreateApartmentState);
        Assert.Same(factory.CreatedObject, eventSink.AttachedObject);
        Assert.Equal("session-1", eventSink.SessionId);
    }

    /// <summary>
    /// Verifies that StartAsync maps creation exceptions with HResult when the factory fails.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task StartAsync_WhenFactoryFails_MapsCreationExceptionWithHResult()
    {
        const int hresult = unchecked((int)0x80040154);
        FakeMxAccessComObjectFactory factory = new(new COMException("Class not registered.", hresult));
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, eventSink);

        MxAccessCreationException exception = await Assert.ThrowsAsync<MxAccessCreationException>(
            () => session.StartAsync(workerProcessId: 1234));

        Assert.Equal(hresult, exception.CapturedHResult);
        Assert.Equal(MxAccessInteropInfo.ProgId, exception.AttemptedProgId);
        Assert.Equal(MxAccessInteropInfo.Clsid, exception.AttemptedClsid);
        Assert.Null(eventSink.AttachedObject);
    }

    /// <summary>
    /// Verifies that Dispose detaches the event sink on the STA thread.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Dispose_DetachesEventSinkOnStaThread()
    {
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        MxAccessStaSession session = new(runtime, factory, eventSink);
        await session.StartAsync(workerProcessId: 1234);

        session.Dispose();

        Assert.Equal(runtime.StaThreadId, eventSink.DetachThreadId);
    }

    private static StaRuntime CreateRuntime()
    {
        return new StaRuntime(
            new NoopComApartmentInitializer(),
            new StaMessagePump(),
            TimeSpan.FromMilliseconds(25));
    }

    /// <summary>
    /// Fake MXAccess COM object factory for testing.
    /// </summary>
    private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
    {
        private readonly Exception? exception;

        /// <summary>
        /// Initializes a fake factory that optionally throws an exception.
        /// </summary>
        /// <param name="exception">Exception to throw when Create is called; null to succeed.</param>
        public FakeMxAccessComObjectFactory(Exception? exception = null)
        {
            this.exception = exception;
        }

        /// <summary>
        /// Gets the COM object created by this factory.
        /// </summary>
        public object CreatedObject { get; } = new();

        /// <summary>
        /// Gets the managed thread ID when Create was called.
        /// </summary>
        public int? CreateThreadId { get; private set; }

        /// <summary>
        /// Gets the apartment state when Create was called.
        /// </summary>
        public ApartmentState? CreateApartmentState { get; private set; }

        /// <inheritdoc />
        public object Create()
        {
            CreateThreadId = Thread.CurrentThread.ManagedThreadId;
            CreateApartmentState = Thread.CurrentThread.GetApartmentState();

            if (exception is not null)
            {
                throw exception;
            }

            return CreatedObject;
        }
    }

    /// <summary>
    /// Fake MXAccess event sink for testing.
    /// </summary>
    private sealed class FakeMxAccessEventSink : IMxAccessEventSink
    {
        /// <summary>
        /// Gets the attached MXAccess COM object.
        /// </summary>
        public object? AttachedObject { get; private set; }

        /// <summary>
        /// Gets the managed thread ID when Attach was called.
        /// </summary>
        public int? AttachThreadId { get; private set; }

        /// <summary>
        /// Gets the managed thread ID when Detach was called.
        /// </summary>
        public int? DetachThreadId { get; private set; }

        /// <summary>
        /// Gets the session identifier.
        /// </summary>
        public string? SessionId { get; private set; }

        /// <inheritdoc />
        public void Attach(
            object mxAccessComObject,
            string sessionId)
        {
            AttachedObject = mxAccessComObject;
            AttachThreadId = Thread.CurrentThread.ManagedThreadId;
            SessionId = sessionId;
        }

        /// <inheritdoc />
        public void Detach()
        {
            DetachThreadId = Thread.CurrentThread.ManagedThreadId;
            AttachedObject = null;
        }
    }

    /// <summary>
    /// Gap 1: Verifies that when MxAccessStaSession is created with an alarm handler factory,
    /// a SubscribeAlarms command dispatched through the session reaches the handler.
    /// This proves the fix in WorkerPipeSession (and the new internal constructor) correctly
    /// wires the factory rather than leaving alarmCommandHandler null.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task StartAsync_WithAlarmCommandHandlerFactory_SubscribeAlarmsCommandReachesHandler()
    {
        FakeAlarmCommandHandler handler = new();
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(
            runtime,
            factory,
            eventSink,
            new MxAccessEventQueue(),
            (_eq, _affinity, _comFactory) => handler);

        await session.StartAsync("session-1", workerProcessId: 1);

        StaCommand subscribeCommand = new StaCommand(
            "session-1",
            "corr-1",
            new MxCommand
            {
                Kind = MxCommandKind.SubscribeAlarms,
                SubscribeAlarms = new SubscribeAlarmsCommand
                {
                    SubscriptionExpression = @"\\HOST\Galaxy!Area",
                },
            });

        MxCommandReply reply = await session.DispatchAsync(subscribeCommand);

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(handler.IsSubscribed);
        Assert.Equal(@"\\HOST\Galaxy!Area", handler.LastSubscription);
    }

    /// <summary>
    /// Gap 1: Verifies that when MxAccessStaSession is created without an alarm
    /// command handler factory, SubscribeAlarms returns InvalidRequest with the
    /// exact "SubscribeAlarms requires an alarm command handler; the worker was
    /// constructed without one." diagnostic. The full phrase is asserted so the
    /// test fails if the diagnostic regresses to a misleading message that still
    /// happens to contain the word "alarm".
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest()
    {
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        // Use the 4-arg (no factory) constructor — equivalent to the old MxAccessStaSession()
        using MxAccessStaSession session = new(runtime, factory, eventSink);

        await session.StartAsync("session-1", workerProcessId: 1);

        StaCommand subscribeCommand = new StaCommand(
            "session-1",
            "corr-1",
            new MxCommand
            {
                Kind = MxCommandKind.SubscribeAlarms,
                SubscribeAlarms = new SubscribeAlarmsCommand
                {
                    SubscriptionExpression = @"\\HOST\Galaxy!Area",
                },
            });

        MxCommandReply reply = await session.DispatchAsync(subscribeCommand);

        Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
        Assert.Equal(
            "SubscribeAlarms requires an alarm command handler; the worker was constructed without one.",
            reply.DiagnosticMessage);
    }

    /// <summary>
    /// Gap 2: Verifies that after StartAsync with an alarm handler factory, the STA poll
    /// loop calls PollOnce on the handler via the STA within a reasonable timeout.
    /// This proves polling is driven by the STA rather than the consumer's internal timer.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task StartAsync_WithAlarmCommandHandlerFactory_PollOnceCalledViaSta()
    {
        FakeAlarmCommandHandler handler = new();
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(
            runtime,
            factory,
            eventSink,
            new MxAccessEventQueue(),
            (_eq, _affinity, _comFactory) => handler);

        await session.StartAsync("session-1", workerProcessId: 1);

        // Wait up to 3s for at least one PollOnce call from the STA poll loop.
        using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        while (handler.PollCount == 0 && !timeout.IsCancellationRequested)
        {
            await Task.Delay(50, CancellationToken.None);
        }

        Assert.True(handler.PollCount > 0,
            "Expected PollOnce to be called at least once by the STA poll loop within 3 seconds.");
        Assert.NotNull(handler.LastPollThreadId);
        Assert.Equal(runtime.StaThreadId, handler.LastPollThreadId);
    }

    /// <summary>
    /// Gap 2: Verifies that the STA poll loop stops when the session is disposed —
    /// no further PollOnce calls after disposal. <see cref="MxAccessStaSession.Dispose"/>
    /// joins the poll task before returning, so once Dispose returns no PollOnce
    /// call can still be in flight. The test asserts the poll count is frozen
    /// immediately after Dispose and stays frozen — deterministic, with no
    /// elapsed-time "no further polls" window that a slow agent could race.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task Dispose_StopsAlarmPollLoop()
    {
        FakeAlarmCommandHandler handler = new();
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        // using declaration: if an assertion below throws before the explicit
        // Dispose, the session (its STA poll loop and alarm handler) is still
        // torn down. Dispose is idempotent, so the explicit call mid-test and
        // the using-scope call do not conflict.
        using MxAccessStaSession session = new(
            runtime,
            factory,
            eventSink,
            new MxAccessEventQueue(),
            (_eq, _affinity, _comFactory) => handler);

        await session.StartAsync("session-1", workerProcessId: 1);

        // Wait for at least one poll to occur, then dispose.
        using CancellationTokenSource initTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        while (handler.PollCount == 0 && !initTimeout.IsCancellationRequested)
        {
            await Task.Delay(50, CancellationToken.None);
        }

        Assert.True(handler.PollCount > 0, "Prerequisite: poll loop must have fired before dispose.");

        // Dispose joins the poll task; when it returns the loop has stopped
        // and no PollOnce call is still running.
        session.Dispose();
        int pollCountAtDispose = handler.PollCount;

        // The count is already frozen — re-reading after a yield must not
        // observe any further poll. This is a deterministic check, not a
        // timing window: a poll cannot start once the joined loop has exited.
        await Task.Yield();
        Assert.Equal(pollCountAtDispose, handler.PollCount);
    }

    /// <summary>
    /// When the alarm poll loop's PollOnce throws a
    /// real failure (e.g. a COMException from GetXmlCurrentAlarms2), the
    /// failure must be recorded as a fault on the event queue so a broken
    /// alarm subscription becomes observable on the IPC fault path instead
    /// of silently faulting the never-awaited poll task.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAlarmPollLoop_WhenPollOnceThrows_RecordsFaultOnEventQueue()
    {
        FakeAlarmCommandHandler handler = new()
        {
            PollException = new System.Runtime.InteropServices.COMException(
                "GetXmlCurrentAlarms2 failed.", unchecked((int)0x80004005)),
        };
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        MxAccessEventQueue eventQueue = new();
        using MxAccessStaSession session = new(
            runtime,
            factory,
            eventSink,
            eventQueue,
            (_eq, _affinity, _comFactory) => handler);

        await session.StartAsync("session-1", workerProcessId: 1);

        // Wait up to 5s for the poll loop to fault the queue.
        using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested)
        {
            await Task.Delay(50, CancellationToken.None);
        }

        Assert.True(eventQueue.IsFaulted, "Expected the alarm poll failure to fault the event queue.");
        WorkerFault? fault = session.DrainFault();
        Assert.NotNull(fault);
        Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category);
        Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
        Assert.Equal(typeof(System.Runtime.InteropServices.COMException).FullName, fault.ExceptionType);
    }

    /// <summary>
    /// The alarm poll loop's catch for the graceful
    /// STA-runtime-shutdown signal must NOT also swallow a vanilla
    /// <see cref="InvalidOperationException"/> raised from inside the marshalled
    /// poll lambda — for example the STA-affinity assertion thrown by
    /// <c>EnsureOnAlarmConsumerThread</c> if a regression ever caused the poll
    /// to run off the alarm-consumer thread. The runtime-shutdown signal is now
    /// the dedicated <see cref="StaRuntimeShutdownException"/>; a plain
    /// <see cref="InvalidOperationException"/> from <c>PollOnce</c> must reach
    /// the fault-recording arm and become observable on the event queue.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAlarmPollLoop_WhenPollOnceThrowsInvalidOperation_RecordsFaultOnEventQueue()
    {
        FakeAlarmCommandHandler handler = new()
        {
            PollException = new InvalidOperationException(
                "Alarm consumer accessed off its owning STA thread."),
        };
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        MxAccessEventQueue eventQueue = new();
        using MxAccessStaSession session = new(
            runtime,
            factory,
            eventSink,
            eventQueue,
            (_eq, _affinity, _comFactory) => handler);

        await session.StartAsync("session-1", workerProcessId: 1);

        using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested)
        {
            await Task.Delay(50, CancellationToken.None);
        }

        Assert.True(
            eventQueue.IsFaulted,
            "Expected the alarm poll InvalidOperationException to fault the event queue, "
                + "not be silently swallowed as a shutdown signal.");
        WorkerFault? fault = session.DrainFault();
        Assert.NotNull(fault);
        Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category);
        Assert.Equal(typeof(InvalidOperationException).FullName, fault.ExceptionType);
        Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>
    /// WRK-27. While the alarm poll's PollOnce is executing on the STA, a heartbeat captured mid-poll
    /// must report <see cref="WorkerRuntimeHeartbeatSnapshot.StaCallInProgress"/> so the watchdog
    /// grants the poll the same grace-to-ceiling suppression as a dispatched command; once the poll
    /// returns the flag clears. PollOnce is blocked on a gate so the heartbeat can be captured while
    /// the STA call is genuinely in flight.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task CaptureHeartbeat_DuringAlarmPoll_ReportsStaCallInProgress()
    {
        FakeAlarmCommandHandler handler = new() { BlockPoll = true };
        FakeMxAccessComObjectFactory factory = new();
        FakeMxAccessEventSink eventSink = new();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(
            runtime,
            factory,
            eventSink,
            new MxAccessEventQueue(),
            (_eq, _affinity, _comFactory) => handler);

        await session.StartAsync("session-1", workerProcessId: 1);

        // Wait until PollOnce is blocked mid-call on the STA thread.
        Assert.True(
            handler.WaitForPollEntered(TimeSpan.FromSeconds(5)),
            "Expected the alarm poll to start within 5 seconds.");

        // Captured mid-poll, the heartbeat advertises the in-progress STA call.
        Assert.True(session.CaptureHeartbeat().StaCallInProgress);

        // Release the poll and stop blocking; the flag clears once the poll returns.
        handler.BlockPoll = false;
        handler.ReleasePoll();

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        while (session.CaptureHeartbeat().StaCallInProgress && !timeout.IsCancellationRequested)
        {
            await Task.Delay(25, CancellationToken.None);
        }

        Assert.False(
            session.CaptureHeartbeat().StaCallInProgress,
            "Expected StaCallInProgress to clear once the alarm poll returned.");
    }

    /// <summary>
    /// The STA-affinity guard throws when an
    /// IMxAccessAlarmConsumer call is attempted off the thread that created
    /// the consumer, mirroring the MxAccessSession.CreationThreadId invariant.
    /// </summary>
    [Fact]
    public void AssertOnAlarmConsumerThread_WhenOffOwningThread_Throws()
    {
        const int owningThread = 7;
        const int otherThread = 99;

        InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
            () => MxAccessStaSession.AssertOnAlarmConsumerThread(owningThread, otherThread));

        Assert.Contains("off its owning STA thread", exception.Message, StringComparison.Ordinal);
    }

    /// <summary>
    /// The STA-affinity guard is a no-op on the owning thread and
    /// when no alarm consumer is configured (expected thread id null).
    /// </summary>
    [Fact]
    public void AssertOnAlarmConsumerThread_OnOwningThreadOrUnset_DoesNotThrow()
    {
        MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: 42, actualThreadId: 42);
        MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: null, actualThreadId: 123);
    }

    /// <summary>
    /// Fake alarm command handler that records calls and tracks poll thread.
    /// </summary>
    private sealed class FakeAlarmCommandHandler : IAlarmCommandHandler
    {
        private readonly object gate = new object();
        private readonly ManualResetEventSlim pollEntered = new(false);
        private readonly ManualResetEventSlim releasePoll = new(false);
        private int pollCount;
        private int? lastPollThreadId;

        /// <summary>Gets a value indicating whether the alarm client is currently subscribed.</summary>
        public bool IsSubscribed { get; private set; }

        /// <summary>Gets the last alarm subscription name.</summary>
        public string? LastSubscription { get; private set; }

        /// <summary>Exception thrown by PollOnce; null to succeed.</summary>
        public Exception? PollException { get; set; }

        /// <summary>When set, <see cref="PollOnce"/> blocks until <see cref="ReleasePoll"/> is called.</summary>
        public bool BlockPoll { get; set; }

        /// <summary>Waits until a blocking <see cref="PollOnce"/> has entered and is blocked.</summary>
        /// <param name="timeout">Maximum time to wait.</param>
        /// <returns>True if a poll entered within the timeout.</returns>
        public bool WaitForPollEntered(TimeSpan timeout) => pollEntered.Wait(timeout);

        /// <summary>Releases a <see cref="PollOnce"/> blocked on the gate.</summary>
        public void ReleasePoll() => releasePoll.Set();

        /// <summary>Gets the count of PollOnce calls.</summary>
        public int PollCount
        {
            get { lock (gate) return pollCount; }
        }

        /// <summary>Gets the managed thread ID of the last PollOnce call.</summary>
        public int? LastPollThreadId
        {
            get { lock (gate) return lastPollThreadId; }
        }

        /// <inheritdoc />
        public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
        {
            IsSubscribed = true;
            LastSubscription = command.SubscriptionExpression;
        }

        /// <inheritdoc />
        public void Unsubscribe()
        {
            IsSubscribed = false;
        }

        /// <inheritdoc />
        public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
            string operatorNode, string operatorDomain, string operatorFullName)
            => 0;

        /// <inheritdoc />
        public int AcknowledgeByName(string alarmName, string providerName, string groupName,
            string comment, string operatorUser, string operatorNode,
            string operatorDomain, string operatorFullName)
            => 0;

        /// <inheritdoc />
        public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
            string? alarmFilterPrefix,
            out bool snapshotTruncated)
        {
            snapshotTruncated = false;
            return Array.Empty<ActiveAlarmSnapshot>();
        }

        /// <inheritdoc />
        public void PollOnce()
        {
            lock (gate)
            {
                pollCount++;
                lastPollThreadId = Thread.CurrentThread.ManagedThreadId;
            }

            if (BlockPoll)
            {
                pollEntered.Set();
                releasePoll.Wait(TimeSpan.FromSeconds(10));
            }

            if (PollException is not null)
            {
                throw PollException;
            }
        }

        /// <inheritdoc />
        public void Dispose()
        {
            pollEntered.Dispose();
            releasePoll.Dispose();
        }
    }
}
