using System;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using Xunit;

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

/// <summary>
///     Unit tests for the subtag-fallback synthesis state machine. The machine
///     consumes normalized subtag value changes (active/acked/priority) and
///     emits <see cref="MxAlarmTransitionEvent"/> records mirroring the wnwrap
///     consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions. No COM
///     or AVEVA install is required.
/// </summary>
public sealed class SubtagAlarmStateMachineTests
{
    private static AlarmSubtagTarget Target() => new()
    {
        AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
        SourceObjectReference = "Tank01",
        ActiveSubtag = "Tank01.Level.HiHi.active",
        AckedSubtag = "Tank01.Level.HiHi.acked",
        AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
    };

    /// <summary>Verifies that an active transition from false to true emits an unacked raise.</summary>
    [Fact]
    public void ActiveFalseToTrue_EmitsRaise()
    {
        var sm = new SubtagAlarmStateMachine(new[] { Target() });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
        var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
        var e = Assert.Single(events);
        Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
        Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
        Assert.Equal("Tank01.Level.HiHi", e.Record.TagName);
        Assert.Equal("Galaxy", e.Record.ProviderName);
        Assert.Equal("Area", e.Record.Group);
    }

    /// <summary>Verifies that a subtag target built from a real Galaxy area round-trips to native alarmmgr provider/group/tag-name fields.</summary>
    [Fact]
    public void ActiveFalseToTrue_AlarmMgrShape_EmitsNativeProviderGroupTagName()
    {
        // Reference parity: a subtag target composed from the object's real Galaxy
        // area must round-trip to exactly the native alarmmgr (wnwrap) record fields:
        // Provider "Galaxy", Group = the real area "TestArea", and the object-rooted
        // TagName "TestMachine_001.TestAlarm001".
        var target = new AlarmSubtagTarget
        {
            AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
            SourceObjectReference = "TestMachine_001",
            ActiveSubtag = "TestMachine_001.TestAlarm001.InAlarm",
        };
        var sm = new SubtagAlarmStateMachine(new[] { target });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
        var events = sm.Apply("TestMachine_001.TestAlarm001.InAlarm", true, ts);
        var e = Assert.Single(events);
        Assert.Equal("Galaxy", e.Record.ProviderName);
        Assert.Equal("TestArea", e.Record.Group);
        Assert.Equal("TestMachine_001.TestAlarm001", e.Record.TagName);
    }

    /// <summary>Verifies that when the alarm reference has no provider "!" separator, the whole reference is used as the tag name.</summary>
    [Fact]
    public void ActiveFalseToTrue_NoProviderBang_UsesWholeReferenceAsTagName()
    {
        var target = new AlarmSubtagTarget
        {
            AlarmFullReference = "Tank01.Level.HiHi",
            SourceObjectReference = string.Empty,
            ActiveSubtag = "Tank01.Level.HiHi.active",
        };
        var sm = new SubtagAlarmStateMachine(new[] { target });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
        var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
        var e = Assert.Single(events);
        Assert.Equal("Tank01.Level.HiHi", e.Record.TagName);
        Assert.Equal(string.Empty, e.Record.ProviderName);
        Assert.Equal(string.Empty, e.Record.Group);
    }

    /// <summary>Verifies that an out-of-order un-ack arriving before the active-false clear still results in an AckRtn transition.</summary>
    [Fact]
    public void OutOfOrderAckThenClear_StillEmitsAckRtn()
    {
        var sm = new SubtagAlarmStateMachine(new[] { Target() });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
        sm.Apply("Tank01.Level.HiHi.active", true, ts);
        sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(2));
        // Out-of-order un-ack arrives before the active=false clear.
        sm.Apply("Tank01.Level.HiHi.acked", false, ts.AddSeconds(3));
        var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
        var e = Assert.Single(events);
        Assert.Equal(MxAlarmStateKind.AckRtn, e.Record.State);
    }

    /// <summary>Verifies that constructing the state machine with two targets sharing an active subtag address throws.</summary>
    [Fact]
    public void DuplicateActiveSubtag_Throws()
    {
        var first = new AlarmSubtagTarget
        {
            AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
            SourceObjectReference = "Tank01",
            ActiveSubtag = "Shared.active",
        };
        var second = new AlarmSubtagTarget
        {
            AlarmFullReference = "Galaxy!Area.Tank02.Level.HiHi",
            SourceObjectReference = "Tank02",
            ActiveSubtag = "Shared.active",
        };
        Assert.Throws<ArgumentException>(() => new SubtagAlarmStateMachine(new[] { first, second }));
    }

    /// <summary>
    ///     Two watch-list entries sharing an
    ///     <see cref="AlarmSubtagTarget.AlarmFullReference"/> (but using distinct
    ///     subtag addresses) must throw at construction, symmetric with the
    ///     duplicate-address guard, rather than silently overwriting the earlier
    ///     reference's state and orphaning its bound addresses.
    /// </summary>
    [Fact]
    public void DuplicateAlarmFullReference_Throws()
    {
        var first = new AlarmSubtagTarget
        {
            AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
            SourceObjectReference = "Tank01",
            ActiveSubtag = "Tank01.Level.HiHi.active",
        };
        var second = new AlarmSubtagTarget
        {
            AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
            SourceObjectReference = "Tank01",
            ActiveSubtag = "Other.active",
        };
        Assert.Throws<ArgumentException>(() => new SubtagAlarmStateMachine(new[] { first, second }));
    }

    /// <summary>Verifies that acking an active, unacked alarm emits an AckAlm transition from UnackAlm.</summary>
    [Fact]
    public void AckedTrueWhileActive_EmitsAck()
    {
        var sm = new SubtagAlarmStateMachine(new[] { Target() });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
        sm.Apply("Tank01.Level.HiHi.active", true, ts);
        var events = sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(5));
        var e = Assert.Single(events);
        Assert.Equal(MxAlarmStateKind.AckAlm, e.Record.State);
        Assert.Equal(MxAlarmStateKind.UnackAlm, e.PreviousState);
    }

    /// <summary>Verifies that clearing an active, unacked alarm emits an UnackRtn transition.</summary>
    [Fact]
    public void ActiveTrueToFalse_WhileUnacked_EmitsUnackRtn()
    {
        var sm = new SubtagAlarmStateMachine(new[] { Target() });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
        sm.Apply("Tank01.Level.HiHi.active", true, ts);
        var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
        var e = Assert.Single(events);
        Assert.Equal(MxAlarmStateKind.UnackRtn, e.Record.State);
    }

    /// <summary>Verifies that clearing an active, acked alarm emits an AckRtn transition.</summary>
    [Fact]
    public void ActiveTrueToFalse_WhileAcked_EmitsAckRtn()
    {
        var sm = new SubtagAlarmStateMachine(new[] { Target() });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
        sm.Apply("Tank01.Level.HiHi.active", true, ts);
        sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(2));
        var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
        var e = Assert.Single(events);
        Assert.Equal(MxAlarmStateKind.AckRtn, e.Record.State);
    }

    /// <summary>Verifies that <c>SnapshotActive</c> reflects the current active and acked state.</summary>
    [Fact]
    public void Snapshot_ReflectsActiveAndAckedState()
    {
        var sm = new SubtagAlarmStateMachine(new[] { Target() });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
        sm.Apply("Tank01.Level.HiHi.active", true, ts);
        sm.Apply("Tank01.Level.HiHi.acked", true, ts);
        var snap = Assert.Single(sm.SnapshotActive());
        Assert.Equal(MxAlarmStateKind.AckAlm, snap.State);
    }

    /// <summary>Verifies that a value change on an address not bound to any target produces no events.</summary>
    [Fact]
    public void UnknownAddress_NoEvents()
    {
        var sm = new SubtagAlarmStateMachine(new[] { Target() });
        var events = sm.Apply("Some.Other.Tag.active", true, DateTime.UtcNow);
        Assert.Empty(events);
    }

    /// <summary>
    ///     An ack arriving while the alarm is NOT
    ///     active must emit nothing and must NOT latch
    ///     <c>AckedDuringEpisode</c> — otherwise a stale ack from a prior episode
    ///     would mis-latch the next raise into a spurious ACK_RTN on clear. The
    ///     subsequent raise/clear must therefore still emit UNACK_RTN.
    /// </summary>
    [Fact]
    public void AckedTrueWhileInactive_EmitsNothingAndDoesNotLatch()
    {
        var sm = new SubtagAlarmStateMachine(new[] { Target() });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);

        // Ack with no preceding active raise: must be a no-op.
        var ackEvents = sm.Apply("Tank01.Level.HiHi.acked", true, ts);
        Assert.Empty(ackEvents);

        // A fresh episode: raise then clear. Because the earlier ack must not
        // have latched AckedDuringEpisode, the clear must be UNACK_RTN.
        sm.Apply("Tank01.Level.HiHi.active", true, ts.AddSeconds(5));
        var clearEvents = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
        var clear = Assert.Single(clearEvents);
        Assert.Equal(MxAlarmStateKind.UnackRtn, clear.Record.State);
    }

    /// <summary>
    ///     A priority-subtag value change must flow
    ///     through <c>CoerceInt</c> into the emitted record's
    ///     <see cref="MxAlarmSnapshotRecord.Priority"/>. A non-numeric value must
    ///     leave the prior priority unchanged (the CoerceInt fallback path).
    /// </summary>
    [Fact]
    public void PriorityChange_FlowsIntoEmittedRecord()
    {
        var target = new AlarmSubtagTarget
        {
            AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
            SourceObjectReference = "Tank01",
            ActiveSubtag = "Tank01.Level.HiHi.active",
            AckedSubtag = "Tank01.Level.HiHi.acked",
            PrioritySubtag = "Tank01.Level.HiHi.priority",
        };
        var sm = new SubtagAlarmStateMachine(new[] { target });
        var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);

        // A priority change alone emits nothing but records the priority.
        var priorityEvents = sm.Apply("Tank01.Level.HiHi.priority", 750, ts);
        Assert.Empty(priorityEvents);

        // Raise: the emitted record carries the recorded priority.
        var raiseEvents = sm.Apply("Tank01.Level.HiHi.active", true, ts.AddSeconds(1));
        var raise = Assert.Single(raiseEvents);
        Assert.Equal(750, raise.Record.Priority);

        // A non-numeric priority must fall back to the existing value, not zero.
        sm.Apply("Tank01.Level.HiHi.priority", "not-a-number", ts.AddSeconds(2));
        var snap = Assert.Single(sm.SnapshotActive());
        Assert.Equal(750, snap.Priority);
    }
}
