using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;

namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;

/// <summary>
///     Covers <see cref="SequenceTracker"/> — design doc §3.6 invariant #3. Two halves, each with a
///     distinct failure mode the tests below pin from both directions:
///     <list type="bullet">
///         <item>
///             <b><c>seq</c> gap detection across the 0–255 wrap.</b> Reading 255→0 as a gap
///             rebirth-storms every edge node once per 256 messages; missing a real gap serves stale
///             data at Good quality forever. Both directions are asserted.
///         </item>
///         <item>
///             <b><c>bdSeq</c> birth↔death tie.</b> A late Last-Will NDEATH from a previous session
///             arriving after the new NBIRTH must not kill the freshly-born node.
///         </item>
///     </list>
/// </summary>
public sealed class SequenceTrackerTests
{
    // ---------------------------------------------------------------------------------------
    // seq — the wrap boundary
    // ---------------------------------------------------------------------------------------

    /// <summary>
    ///     The plan's headline case: 255 → 0 is the sequence continuing, not a gap. A tracker that
    ///     reads the wrap as a gap requests a rebirth every 256 messages, and a busy edge node then
    ///     spends its life republishing births instead of data.
    /// </summary>
    [Fact]
    public void Sequence_WrapAt255IsContiguous_NotAGap()
    {
        var s = new SequenceTracker();

        s.Accept(254).ShouldBeTrue();
        s.Accept(255).ShouldBeTrue();
        s.Accept(0).ShouldBeTrue();   // wrap
    }

    /// <summary>
    ///     The plan's mirror case: a skipped value is a genuine gap. Missing it means the driver
    ///     silently keeps serving values it knows are behind the edge node's real state.
    /// </summary>
    [Fact]
    public void Sequence_SkippedValue_IsGap()
    {
        var s = new SequenceTracker();

        s.Accept(10).ShouldBeTrue();
        s.Accept(12).ShouldBeFalse();   // gap → caller requests rebirth
    }

    /// <summary>
    ///     Two-and-a-bit full cycles of the 0–255 range report no gap at any point. The pair of
    ///     single-step tests above can both pass on an off-by-one wrap that only misfires at one
    ///     specific value; walking every value in the range leaves nowhere for that to hide.
    /// </summary>
    [Fact]
    public void Sequence_FullWrapCycles_NeverReportAGap()
    {
        var s = new SequenceTracker();
        s.AcceptNodeBirth(0).ShouldBeTrue();

        for (var i = 1; i <= 600; i++)
        {
            var seq = (ulong)(i % 256);
            s.Accept(seq).ShouldBeTrue($"seq {seq} (message {i}) was reported as a gap");
        }
    }

    /// <summary>
    ///     A gap is reported <b>once</b> and the tracker then resynchronizes onto the observed
    ///     sequence. Leaving the baseline pinned at the pre-gap value would make every subsequent
    ///     message report a gap too, turning one lost message into a permanent rebirth storm — the
    ///     same pathology as a wrong wrap boundary, reached by a different route.
    /// </summary>
    [Fact]
    public void Sequence_GapReportedOnce_ThenResynchronizes()
    {
        var s = new SequenceTracker();

        s.Accept(10).ShouldBeTrue();
        s.Accept(13).ShouldBeFalse();   // three messages lost
        s.Accept(14).ShouldBeTrue();    // resynchronized onto 13
        s.Accept(15).ShouldBeTrue();
    }

    /// <summary>
    ///     A repeated sequence number is not contiguous. Sparkplug publishes DATA at QoS 0, so a
    ///     duplicate is not a broker redelivery — it is a publisher whose stream no longer matches
    ///     what the driver can reason about, and a rebirth is the cheap correct answer.
    /// </summary>
    [Fact]
    public void Sequence_RepeatedValue_IsGap()
    {
        var s = new SequenceTracker();

        s.Accept(10).ShouldBeTrue();
        s.Accept(10).ShouldBeFalse();
    }

    /// <summary>A sequence number going backwards is a gap, not a wrap.</summary>
    [Fact]
    public void Sequence_BackwardJump_IsGap()
    {
        var s = new SequenceTracker();

        s.Accept(10).ShouldBeTrue();
        s.Accept(9).ShouldBeFalse();
    }

    /// <summary>
    ///     The first message cannot be a gap — there is no baseline to measure it against — but the
    ///     tracker must still report that it is <b>not</b> birth-synchronized, because a first
    ///     message that was not a birth means the driver joined mid-stream and has no alias table.
    ///     Conflating the two would let a late join look like a healthy synchronized node.
    /// </summary>
    [Fact]
    public void Sequence_FirstMessage_IsNotAGap_ButIsNotBirthSynchronized()
    {
        var s = new SequenceTracker();

        s.IsBirthSynchronized.ShouldBeFalse();
        s.LastSeq.ShouldBeNull();

        s.Accept(137).ShouldBeTrue();

        s.LastSeq.ShouldBe((byte)137);
        s.IsBirthSynchronized.ShouldBeFalse("a mid-stream first message is a late join, not a birth");
    }

    /// <summary>
    ///     <b>The aliasing test.</b> The codec hands over <c>seq</c> as a <see cref="ulong"/> and
    ///     deliberately declines to truncate it, so an out-of-range value reaches this type intact. A
    ///     tracker that masks it into range turns 256 into a perfectly plausible 0 — and with the
    ///     baseline sitting at 255, that is exactly the value the wrap expects next, so the bogus
    ///     message would be accepted as contiguous.
    /// </summary>
    [Fact]
    public void Sequence_OutOfRangeSeq_IsRejected_AndDoesNotAliasOntoTheExpectedValue()
    {
        var s = new SequenceTracker();
        s.Accept(255).ShouldBeTrue();

        // 256 & 0xFF == 0, and 0 is precisely what follows 255. Masking would return true here.
        s.Accept(256).ShouldBeFalse();
    }

    /// <summary>
    ///     A rejected out-of-range value does not become the baseline either: the tracker keeps the
    ///     last credible sequence number, so the following genuine message is still measured against
    ///     something real.
    /// </summary>
    [Fact]
    public void Sequence_OutOfRangeSeq_DoesNotBecomeTheBaseline()
    {
        var s = new SequenceTracker();
        s.Accept(10).ShouldBeTrue();

        s.Accept(ulong.MaxValue).ShouldBeFalse();

        s.LastSeq.ShouldBe((byte)10);
        s.Accept(11).ShouldBeTrue("the baseline must still be the last credible seq, 10");
    }

    /// <summary>
    ///     A message carrying no sequence number at all is a spec violation the tracker cannot place
    ///     in the stream. It reports a gap (the safe direction — a rebirth resynchronizes everything)
    ///     and leaves the baseline alone.
    /// </summary>
    [Fact]
    public void Sequence_AbsentSeq_IsGap_AndDoesNotAdvanceTheBaseline()
    {
        var s = new SequenceTracker();
        s.Accept(10).ShouldBeTrue();

        s.Accept(null).ShouldBeFalse();

        s.LastSeq.ShouldBe((byte)10);
        s.Accept(11).ShouldBeTrue();
    }

    // ---------------------------------------------------------------------------------------
    // seq — births restart the sequence
    // ---------------------------------------------------------------------------------------

    /// <summary>
    ///     A birth restarts the sequence at 0, so it is never itself a gap however far the previous
    ///     session had counted. A tracker that ran the birth through the ordinary contiguity check
    ///     would flag a gap on the very message that just fixed everything, and request a rebirth in
    ///     response to a birth.
    /// </summary>
    [Fact]
    public void Birth_RestartsTheSequence_SoABirthIsNeverAGap()
    {
        var s = new SequenceTracker();
        s.Accept(200).ShouldBeTrue();

        s.AcceptNodeBirth(0).ShouldBeTrue();

        s.LastSeq.ShouldBe((byte)0);
        s.IsBirthSynchronized.ShouldBeTrue();
        s.Accept(1).ShouldBeTrue();
    }

    /// <summary>
    ///     A gap detected <i>after</i> a birth is genuine — the birth resets the baseline, it does not
    ///     suppress detection. Without this the previous test could be satisfied by a tracker that
    ///     simply stopped checking once a birth arrived.
    /// </summary>
    [Fact]
    public void Birth_ThenSkippedValue_IsStillAGap()
    {
        var s = new SequenceTracker();
        s.AcceptNodeBirth(0).ShouldBeTrue();

        s.Accept(1).ShouldBeTrue();
        s.Accept(3).ShouldBeFalse();
    }

    /// <summary>
    ///     A birth whose <c>seq</c> is in range but not the spec-required 0 is adopted as the
    ///     baseline rather than rejected. Refusing it would leave the tracker unsynchronized against
    ///     a publisher whose stream is otherwise perfectly followable, and every following message
    ///     would demand another rebirth.
    /// </summary>
    [Fact]
    public void Birth_WithNonZeroInRangeSeq_IsAdoptedAsTheBaseline()
    {
        var s = new SequenceTracker();

        s.AcceptNodeBirth(7).ShouldBeTrue();

        s.LastSeq.ShouldBe((byte)7);
        s.IsBirthSynchronized.ShouldBeTrue();
        s.Accept(8).ShouldBeTrue();
    }

    /// <summary>
    ///     A birth whose <c>seq</c> is out of range establishes no baseline — the same
    ///     no-masking rule as the data path, applied where it matters most.
    /// </summary>
    [Fact]
    public void Birth_WithOutOfRangeSeq_EstablishesNoBaseline()
    {
        var s = new SequenceTracker();

        s.AcceptNodeBirth(256).ShouldBeFalse();

        s.LastSeq.ShouldBeNull();
        s.IsBirthSynchronized.ShouldBeFalse();
    }

    /// <summary>
    ///     <b>A DBIRTH continues the edge node's sequence — it does not restart it, and it must not be
    ///     routed through <see cref="SequenceTracker.AcceptNodeBirth"/>.</b> Only the NBIRTH resets
    ///     <c>seq</c>, and only the NBIRTH/NDEATH carry <c>bdSeq</c>. Sending a DBIRTH to the node-birth
    ///     entry point would rebase the sequence mid-stream <i>and</i> — having no <c>bdSeq</c> to pass
    ///     — wipe the session token, at which point a stale Last Will finds nothing to be compared
    ///     against and kills the live node. This test pins the shape at the tracker's own boundary so
    ///     the ingest state machine has something to fail against if it wires the call sites the other
    ///     way round.
    /// </summary>
    [Fact]
    public void DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone()
    {
        var s = new SequenceTracker();
        s.AcceptNodeBirth(0, bdSeq: 7).ShouldBeTrue();

        s.Accept(1).ShouldBeTrue();   // the DBIRTH — next in the node's stream, not a restart
        s.Accept(2).ShouldBeTrue();   // first DDATA

        s.BirthBdSeq.ShouldBe(7UL, "a device birth must not disturb the node's session token");
        s.IsDeathForCurrentSession(6).ShouldBeFalse("the stale-will guard must still have a token to compare");
    }

    /// <summary>
    ///     <see cref="SequenceTracker.Reset"/> returns the tracker to its virgin state. This is the
    ///     reconnect seam: after a dropped connection the driver has missed an unknown number of
    ///     messages, so the baseline it held is no longer evidence of anything.
    /// </summary>
    [Fact]
    public void Reset_ClearsTheBaselineAndTheBirthSynchronizedFlag()
    {
        var s = new SequenceTracker();
        s.AcceptNodeBirth(0, bdSeq: 4).ShouldBeTrue();
        s.Accept(1).ShouldBeTrue();

        s.Reset();

        s.LastSeq.ShouldBeNull();
        s.IsBirthSynchronized.ShouldBeFalse();
        s.BirthBdSeq.ShouldBeNull();
        s.Accept(99).ShouldBeTrue("a virgin tracker cannot detect a gap on its first message");
    }

    // ---------------------------------------------------------------------------------------
    // bdSeq — the birth↔death tie
    // ---------------------------------------------------------------------------------------

    /// <summary>
    ///     <b>The case the tie exists for.</b> A node's connection drops, it reconnects and publishes
    ///     a fresh NBIRTH — and only then does the broker notice the old connection died and deliver
    ///     its Last Will NDEATH, carrying the <i>previous</i> session's bdSeq. Without the compare,
    ///     that stale will marks a live, freshly-born node dead and every one of its tags goes Bad.
    /// </summary>
    [Fact]
    public void Death_WithStaleBdSeqArrivingAfterARebirth_DoesNotApply()
    {
        var s = new SequenceTracker();
        s.AcceptNodeBirth(0, bdSeq: 7);   // session 7 — the one that is about to die
        s.AcceptNodeBirth(0, bdSeq: 8);   // session 8 — the node is back, and alive

        s.IsDeathForCurrentSession(7).ShouldBeFalse("session 7's will must not kill session 8");
    }

    /// <summary>A death whose bdSeq matches the current birth is the real thing and applies.</summary>
    [Fact]
    public void Death_WithMatchingBdSeq_Applies()
    {
        var s = new SequenceTracker();
        s.AcceptNodeBirth(0, bdSeq: 8);

        s.IsDeathForCurrentSession(8).ShouldBeTrue();
    }

    /// <summary>
    ///     A death arriving before any birth was ever seen applies. The tracker has nothing to
    ///     disprove it with, and the two failure directions are not symmetric: acting on a death
    ///     wrongly marks tags stale until the next birth restores them (§3.6 invariant #4), while
    ///     ignoring a real death leaves a dead node's last values flowing at Good quality forever.
    /// </summary>
    [Fact]
    public void Death_BeforeAnyBirth_Applies()
    {
        var s = new SequenceTracker();

        s.IsDeathForCurrentSession(7).ShouldBeTrue();
    }

    /// <summary>
    ///     A death carrying no bdSeq metric cannot be tied to anything, so it applies — same
    ///     fail-toward-stale reasoning as above.
    /// </summary>
    [Fact]
    public void Death_WithNoBdSeq_Applies()
    {
        var s = new SequenceTracker();
        s.AcceptNodeBirth(0, bdSeq: 8);

        s.IsDeathForCurrentSession(null).ShouldBeTrue();
    }

    /// <summary>A death applies when the <i>birth</i> was the one that carried no bdSeq.</summary>
    [Fact]
    public void Death_WhenTheBirthCarriedNoBdSeq_Applies()
    {
        var s = new SequenceTracker();
        s.AcceptNodeBirth(0, bdSeq: null);

        s.IsDeathForCurrentSession(7).ShouldBeTrue();
    }

    /// <summary>
    ///     A birth records its bdSeq even when its <c>seq</c> was unusable. Session identity and
    ///     sequence position are independent facts; discarding the tie because the sequence number
    ///     was garbled would leave the next death unmatchable.
    /// </summary>
    [Fact]
    public void Birth_WithUnusableSeq_StillRecordsItsBdSeq()
    {
        var s = new SequenceTracker();

        s.AcceptNodeBirth(256, bdSeq: 8).ShouldBeFalse();

        s.BirthBdSeq.ShouldBe(8UL);
        s.IsDeathForCurrentSession(7).ShouldBeFalse();
    }

    // ---------------------------------------------------------------------------------------
    // bdSeq — reading it out of a payload
    // ---------------------------------------------------------------------------------------

    /// <summary>
    ///     <c>bdSeq</c> is not a top-level payload field: it rides as a metric named <c>bdSeq</c> in
    ///     the NBIRTH and the NDEATH.
    /// </summary>
    [Fact]
    public void TryReadBdSeq_ReadsTheBdSeqMetric()
    {
        var payload = PayloadWith(Metric("bdSeq", DataType.Int64, 8UL));

        SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
        bdSeq.ShouldBe(8UL);
    }

    /// <summary>
    ///     Sparkplug carries <c>Int64</c> as two's complement in an unsigned field, so a negative
    ///     bdSeq reaches the projection as an enormous <see cref="ulong"/>. Reading it raw would
    ///     mint a plausible-looking session token out of nonsense; the reinterpretation is what
    ///     exposes it as negative so it can be refused.
    /// </summary>
    [Fact]
    public void TryReadBdSeq_NegativeSignedValue_IsRefused_NotReadAsAHugeToken()
    {
        var payload = PayloadWith(Metric("bdSeq", DataType.Int64, unchecked((ulong)-1L)));

        SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
        bdSeq.ShouldBe(0UL);
    }

    /// <summary>
    ///     An unsigned bdSeq is read as-is — no reinterpretation applies. (The generated enum member
    ///     is <c>Uint64</c>, not <c>UInt64</c>: protobuf's C# codegen lowercases the interior of the
    ///     proto's <c>UInt64</c>.)
    /// </summary>
    [Fact]
    public void TryReadBdSeq_UnsignedValue_IsReadAsIs()
    {
        var payload = PayloadWith(Metric("bdSeq", DataType.Uint64, 9UL));

        SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue();
        bdSeq.ShouldBe(9UL);
    }

    /// <summary>A payload that carries no bdSeq metric reports absence rather than a default.</summary>
    [Fact]
    public void TryReadBdSeq_PayloadWithoutTheMetric_ReportsAbsence()
    {
        var payload = PayloadWith(Metric("Temperature", DataType.Float, 21.5f));

        SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse();
        bdSeq.ShouldBe(0UL);
    }

    /// <summary>
    ///     An undecodable payload yields no bdSeq. It presents as metric-less exactly like a valid
    ///     but empty one, and only <see cref="SparkplugPayload.IsValid"/> tells them apart.
    /// </summary>
    [Fact]
    public void TryReadBdSeq_InvalidPayload_ReportsAbsence()
    {
        SequenceTracker.TryReadBdSeq(SparkplugPayload.Invalid, out _).ShouldBeFalse();
    }

    /// <summary>A null payload is a verdict, not an exception — this runs on the dispatcher thread.</summary>
    [Fact]
    public void TryReadBdSeq_NullPayload_ReportsAbsence_DoesNotThrow()
    {
        Should.NotThrow(() => SequenceTracker.TryReadBdSeq(null, out _).ShouldBeFalse());
    }

    /// <summary>
    ///     A bdSeq metric with no scalar value (explicitly null, or a kind v1 does not support) is
    ///     not a session token.
    /// </summary>
    [Fact]
    public void TryReadBdSeq_NonScalarMetric_ReportsAbsence()
    {
        var payload = PayloadWith(new SparkplugMetric("bdSeq", null, DataType.Int64, null, SparkplugValueKind.Null, null));

        SequenceTracker.TryReadBdSeq(payload, out _).ShouldBeFalse();
    }

    private static SparkplugPayload PayloadWith(params SparkplugMetric[] metrics) =>
        new(IsValid: true, Seq: 0UL, TimestampMs: 1UL, Metrics: metrics);

    private static SparkplugMetric Metric(string name, DataType dataType, object value) =>
        new(name, Alias: null, dataType, TimestampMs: null, SparkplugValueKind.Scalar, value);
}
