using System;
using System.Collections.Generic;
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 the per-session alarm command router. Uses a fake
///     consumer factory so the lazy-construction lifecycle on
///     <c>SubscribeAlarms</c> is exercised without touching wnwrap COM.
/// </summary>
public sealed class AlarmCommandHandlerTests
{
    /// <summary>Verifies that subscribe creates a consumer and forwards the subscription when not yet subscribed.</summary>
    [Fact]
    public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe()
    {
        FakeConsumer consumer = new FakeConsumer();
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);

        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area" }, "session-1");

        Assert.True(handler.IsSubscribed);
        Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription);
    }

    /// <summary>Verifies that subscribe throws when already subscribed.</summary>
    [Fact]
    public void Subscribe_WhenAlreadySubscribed_Throws()
    {
        FakeConsumer consumer = new FakeConsumer();
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);

        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
        Assert.Throws<InvalidOperationException>(
            () => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!B" }, "s1"));
    }

    /// <summary>
    ///     Pins both the disposal contract and the
    ///     origin of the propagated exception. The fake throws
    ///     <c>InvalidOperationException("simulated wnwrap subscribe failure")</c>
    ///     from <c>Subscribe</c>; the handler must propagate that exact
    ///     exception (not swallow it and rethrow its own) and dispose the
    ///     just-constructed consumer so a retry can build a fresh one.
    ///     Pinning the message guards against a regression where the
    ///     handler throws a different <see cref="InvalidOperationException"/>
    ///     (for example its own "already subscribed" guard) and the
    ///     disposal assertion alone would still pass while hiding the
    ///     real swallow.
    /// </summary>
    [Fact]
    public void Subscribe_WhenUnderlyingSubscribeThrows_DisposesConsumer()
    {
        FakeConsumer consumer = new FakeConsumer { ThrowOnSubscribe = true };
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);

        InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
            () => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"));
        Assert.Contains("simulated wnwrap subscribe failure", exception.Message);
        Assert.False(handler.IsSubscribed);
        Assert.True(consumer.Disposed);
    }

    /// <summary>Verifies that unsubscribe disposes consumer and clears state when subscribed.</summary>
    [Fact]
    public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState()
    {
        FakeConsumer consumer = new FakeConsumer();
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);
        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");

        handler.Unsubscribe();

        Assert.False(handler.IsSubscribed);
        Assert.True(consumer.Disposed);
    }

    /// <summary>Verifies that unsubscribe is a no-op when not yet subscribed.</summary>
    [Fact]
    public void Unsubscribe_WithoutPriorSubscribe_IsNoop()
    {
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => new FakeConsumer());
        handler.Unsubscribe(); // Should not throw.
        Assert.False(handler.IsSubscribed);
    }

    /// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary>
    [Fact]
    public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
    {
        FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);
        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");

        Guid g = Guid.NewGuid();
        int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F");

        Assert.Equal(0, rc);
        Assert.Equal(g, consumer.LastAckGuid);
        Assert.Equal("u", consumer.LastAckOperatorName);
    }

    /// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
    [Fact]
    public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation()
    {
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => new FakeConsumer());

        Assert.Throws<InvalidOperationException>(
            () => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
    }

    /// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
    [Fact]
    public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots()
    {
        FakeConsumer consumer = new FakeConsumer
        {
            SnapshotResult = new[]
            {
                new MxAlarmSnapshotRecord
                {
                    AlarmGuid = Guid.NewGuid(),
                    ProviderName = "Galaxy",
                    Group = "TestArea",
                    TagName = "Tag1",
                    Type = "DSC",
                    Priority = 500,
                    State = MxAlarmStateKind.UnackAlm,
                },
            },
        };
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);
        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");

        IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null, out _);

        Assert.Single(snapshots);
        Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
        Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
    }

    /// <summary>Verifies that query active filters by prefix when prefix is provided.</summary>
    [Fact]
    public void QueryActive_WithPrefix_FiltersByPrefix()
    {
        FakeConsumer consumer = new FakeConsumer
        {
            SnapshotResult = new[]
            {
                NewRecord("Galaxy", "AreaA", "Tag1"),
                NewRecord("Galaxy", "AreaB", "Tag2"),
            },
        };
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);
        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");

        IReadOnlyList<ActiveAlarmSnapshot> filtered = handler.QueryActive("Galaxy!AreaA", out _);

        Assert.Single(filtered);
        Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference);
    }

    /// <summary>
    ///     The consumer's truncation verdict reaches the caller through the
    ///     handler and its dispatcher. This is the middle hop of the flag's
    ///     journey to the QueryActiveAlarms reply payload; without it the reply
    ///     builder would have nothing to stamp.
    /// </summary>
    /// <param name="consumerReportsTruncated">The verdict the fake consumer reports.</param>
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void QueryActive_ReportsConsumerTruncationVerdict(bool consumerReportsTruncated)
    {
        FakeConsumer consumer = new FakeConsumer
        {
            SnapshotTruncated = consumerReportsTruncated,
            SnapshotResult = new[] { NewRecord("Galaxy", "AreaA", "Tag1") },
        };
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);
        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");

        IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null, out bool snapshotTruncated);

        Assert.Equal(consumerReportsTruncated, snapshotTruncated);
        Assert.Equal(consumerReportsTruncated, Assert.Single(snapshots).FromTruncatedSnapshot);
    }

    /// <summary>
    ///     A prefix filter that removes every record must not remove the verdict
    ///     with them. This is exactly why the flag rides out separately as well as
    ///     on each record: a scoped query over a truncated fetch can legitimately
    ///     return nothing and still owe the caller the completeness caveat.
    /// </summary>
    [Fact]
    public void QueryActive_WhenPrefixFiltersOutEveryRecord_StillReportsTruncation()
    {
        FakeConsumer consumer = new FakeConsumer
        {
            SnapshotTruncated = true,
            SnapshotResult = new[] { NewRecord("Galaxy", "AreaB", "Tag2") },
        };
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);
        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");

        IReadOnlyList<ActiveAlarmSnapshot> filtered =
            handler.QueryActive("Galaxy!AreaA", out bool snapshotTruncated);

        Assert.Empty(filtered);
        Assert.True(snapshotTruncated);
    }

    /// <summary>Verifies that dispose unsubscribes and disposes consumer when subscribed.</summary>
    [Fact]
    public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer()
    {
        FakeConsumer consumer = new FakeConsumer();
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer);
        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");

        handler.Dispose();

        Assert.True(consumer.Disposed);
        Assert.Throws<ObjectDisposedException>(
            () => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = "x" }, "y"));
    }

    /// <summary>
    ///     Every method that touches the underlying
    ///     <see cref="IMxAccessAlarmConsumer"/> must invoke the configured
    ///     STA-affinity guard. A guard that throws (simulating an off-STA
    ///     call) must propagate from every command-path entry point.
    /// </summary>
    [Fact]
    public void EveryCommandPathEntry_InvokesThreadAffinityGuard()
    {
        FakeConsumer consumer = new FakeConsumer();
        int guardInvocations = 0;
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer,
            () => guardInvocations++);

        // Subscribe is the first call — guard must run before the consumer
        // factory is invoked. We tally invocation counts after each call so
        // that a missed guard surfaces as the diagnostic count, not a generic
        // "Subscribe should have failed".
        handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
        Assert.Equal(1, guardInvocations);

        handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F");
        Assert.Equal(2, guardInvocations);

        handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F");
        Assert.Equal(3, guardInvocations);

        _ = handler.QueryActive(null, out _);
        Assert.Equal(4, guardInvocations);

        handler.PollOnce();
        Assert.Equal(5, guardInvocations);

        handler.Unsubscribe();
        Assert.Equal(6, guardInvocations);
    }

    /// <summary>
    ///     A guard that throws must propagate from
    ///     every command-path entry point — proving the guard is not
    ///     swallowed by an inner try/catch.
    /// </summary>
    [Fact]
    public void EveryCommandPathEntry_PropagatesAffinityGuardException()
    {
        FakeConsumer consumer = new FakeConsumer();
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => consumer,
            threadAffinityCheck: () =>
                throw new InvalidOperationException("off-STA"));

        // Subscribe: guard runs before the dispatcher is constructed.
        Assert.Throws<InvalidOperationException>(
            () => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"));

        // To exercise the other entry points we need a subscribed handler.
        // Construct a parallel handler with a passing guard, then swap in a
        // throwing one — but the existing handler is the simpler vehicle:
        // re-build the handler with the guard initially silent, subscribe,
        // then verify each remaining entry by passing a guard that throws
        // through a second handler instance — actually the cleaner way is to
        // assert each independently with a fresh handler. Below we reuse
        // the same throwing handler for the not-subscribed-yet entries:
        Assert.Throws<InvalidOperationException>(
            () => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
        Assert.Throws<InvalidOperationException>(
            () => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
        Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
        Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
        Assert.Throws<InvalidOperationException>(() => handler.Unsubscribe());
    }

    /// <summary>
    ///     ForcedMode=Subtag builds a subtag consumer (via the
    ///     injected standby factory) and advises it — the primary
    ///     (alarmmgr) consumer is NOT created.
    /// </summary>
    [Fact]
    public void Subscribe_WithForcedSubtagMode_BuildsStandbyConsumerOnly()
    {
        FakeConsumer primary = new FakeConsumer();
        FakeConsumer standby = new FakeConsumer();
        IReadOnlyList<AlarmSubtagTarget>? capturedWatchList = null;
        AlarmCommandHandler handler = new AlarmCommandHandler(
            new MxAccessEventQueue(),
            () => primary,
            threadAffinityCheck: null,
            comFactory: null,
            standbyFactory: watch =>
            {
                capturedWatchList = watch;
                return standby;
            });

        SubscribeAlarmsCommand command = new SubscribeAlarmsCommand
        {
            SubscriptionExpression = @"\\HOST\Galaxy!Area",
            ForcedMode = AlarmProviderMode.Subtag,
        };
        command.WatchList.Add(new AlarmSubtagTarget { AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi" });

        handler.Subscribe(command, "s1");

        Assert.True(handler.IsSubscribed);
        Assert.Equal(@"\\HOST\Galaxy!Area", standby.LastSubscription); // standby advised
        Assert.Null(primary.LastSubscription);                        // primary never built
        Assert.NotNull(capturedWatchList);
        Assert.Single(capturedWatchList!);
    }

    /// <summary>
    ///     ForcedMode=Unspecified + a non-empty watch list builds a
    ///     failover composite (primary + subtag standby). Forcing the primary
    ///     to fail on subscribe with a threshold of 1 drives the composite to
    ///     switch to the subtag provider, which must enqueue an
    ///     OnAlarmProviderModeChanged event carrying mode=Subtag.
    /// </summary>
    [Fact]
    public void Subscribe_AutoModeWithWatchList_FailoverModeChange_EnqueuesProviderModeChangedEvent()
    {
        FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true };
        FakeConsumer standby = new FakeConsumer();
        MxAccessEventQueue queue = new MxAccessEventQueue();
        AlarmCommandHandler handler = new AlarmCommandHandler(
            queue,
            () => primary,
            threadAffinityCheck: null,
            comFactory: null,
            standbyFactory: _ => standby);

        SubscribeAlarmsCommand command = new SubscribeAlarmsCommand
        {
            SubscriptionExpression = @"\\HOST\Galaxy!Area",
            ForcedMode = AlarmProviderMode.Unspecified,
            Failover = new AlarmFailoverConfig
            {
                ConsecutiveFailureThreshold = 1,
                FailbackProbeIntervalSeconds = 1,
                FailbackStableProbes = 1,
            },
        };
        command.WatchList.Add(new AlarmSubtagTarget { AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi" });

        // Subscribe: standby is armed cleanly; the primary subscribe throws and,
        // at threshold 1, the failover composite switches to standby and raises
        // ProviderModeChanged. The handler enqueues the proto event.
        handler.Subscribe(command, "s1");

        IReadOnlyList<WorkerEvent> drained = queue.Drain(0);
        Assert.Single(drained);
        MxEvent evt = drained[0].Event;
        Assert.Equal(MxEventFamily.OnAlarmProviderModeChanged, evt.Family);
        Assert.Equal("s1", evt.SessionId);
        Assert.NotNull(evt.OnAlarmProviderModeChanged);
        Assert.Equal(AlarmProviderMode.Subtag, evt.OnAlarmProviderModeChanged.Mode);
    }

    /// <summary>
    ///     A non-failover subscribe (alarmmgr-only) never enqueues a
    ///     provider-mode-changed event, and a subsequent Unsubscribe detaches
    ///     the handler so no event leaks.
    /// </summary>
    [Fact]
    public void Subscribe_AlarmmgrOnly_DoesNotEnqueueProviderModeChangedEvent()
    {
        FakeConsumer consumer = new FakeConsumer();
        MxAccessEventQueue queue = new MxAccessEventQueue();
        AlarmCommandHandler handler = new AlarmCommandHandler(queue, () => consumer);

        handler.Subscribe(
            new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
        handler.Unsubscribe();

        Assert.Empty(queue.Drain(0));
    }

    /// <summary>
    ///     The mapper builds a well-formed OnAlarmProviderModeChanged
    ///     MxEvent — correct family and populated body fields.
    /// </summary>
    [Fact]
    public void Mapper_CreateOnAlarmProviderModeChanged_PopulatesFamilyAndBody()
    {
        MxAccessEventMapper mapper = new MxAccessEventMapper();
        DateTime at = new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);

        MxEvent evt = mapper.CreateOnAlarmProviderModeChanged(
            "session-7", AlarmProviderMode.Subtag, "primary PollOnce failed", unchecked((int)0x80004005), at);

        Assert.Equal(MxEventFamily.OnAlarmProviderModeChanged, evt.Family);
        Assert.Equal("session-7", evt.SessionId);
        Assert.NotNull(evt.OnAlarmProviderModeChanged);
        Assert.Equal(AlarmProviderMode.Subtag, evt.OnAlarmProviderModeChanged.Mode);
        Assert.Equal("primary PollOnce failed", evt.OnAlarmProviderModeChanged.Reason);
        Assert.Equal(unchecked((int)0x80004005), evt.OnAlarmProviderModeChanged.Hresult);
        Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
    }

    private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
    {
        return new MxAlarmSnapshotRecord
        {
            AlarmGuid = Guid.NewGuid(),
            ProviderName = provider,
            Group = group,
            TagName = tag,
            Type = "DSC",
            Priority = 500,
            State = MxAlarmStateKind.UnackAlm,
        };
    }

    private sealed class FakeConsumer : IMxAccessAlarmConsumer
    {
#pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions.
        /// <summary>Emitted when an alarm state transition occurs.</summary>
        public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
#pragma warning restore CS0067

        /// <summary>Gets the last subscription request.</summary>
        public string? LastSubscription { get; private set; }
        /// <summary>Gets the last acknowledged alarm GUID.</summary>
        public Guid LastAckGuid { get; private set; }
        /// <summary>Gets the last acknowledged operator name.</summary>
        public string? LastAckOperatorName { get; private set; }
        /// <summary>Gets or sets the return value for acknowledge operations.</summary>
        public int AcknowledgeReturn { get; set; }
        /// <summary>Gets or sets the snapshot result to return.</summary>
        public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
            Array.Empty<MxAlarmSnapshotRecord>();
        /// <summary>Gets or sets a value indicating whether to throw on subscribe.</summary>
        public bool ThrowOnSubscribe { get; set; }
        /// <summary>Gets a value indicating whether the consumer has been disposed.</summary>
        public bool Disposed { get; private set; }

        /// <inheritdoc />
        public void Subscribe(string subscription)
        {
            LastSubscription = subscription;
            if (ThrowOnSubscribe)
            {
                throw new InvalidOperationException("simulated wnwrap subscribe failure");
            }
        }

        /// <inheritdoc />
        public int AcknowledgeByGuid(
            Guid alarmGuid, string ackComment, string ackOperatorName,
            string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
        {
            LastAckGuid = alarmGuid;
            LastAckOperatorName = ackOperatorName;
            return AcknowledgeReturn;
        }

        /// <inheritdoc />
        public int AcknowledgeByName(
            string alarmName, string providerName, string groupName,
            string ackComment, string ackOperatorName, string ackOperatorNode,
            string ackOperatorDomain, string ackOperatorFullName)
        {
            LastAckByNameTuple = (alarmName, providerName, groupName);
            LastAckOperatorName = ackOperatorName;
            return AcknowledgeReturn;
        }

        /// <summary>Gets the last acknowledge-by-name parameters.</summary>
        public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }

        /// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
        public bool SnapshotTruncated { get; set; }

        /// <inheritdoc />
        public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
        {
            truncated = SnapshotTruncated;
            return SnapshotResult;
        }

        /// <summary>Gets the number of times polled.</summary>
        public int PollCount { get; private set; }

        /// <inheritdoc />
        public void PollOnce()
        {
            PollCount++;
        }

        /// <inheritdoc />
        public void Dispose()
        {
            Disposed = true;
        }
    }
}
