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

/// <summary>
///     Per-edge-node Sparkplug B stream-continuity state: the wrapping <c>seq</c> counter that says
///     whether the driver has missed a message, and the <c>bdSeq</c> session token that ties an
///     NDEATH to the NBIRTH it belongs to. Design doc §3.6 invariant #3.
/// </summary>
/// <remarks>
///     <para>
///         <b>One instance per edge node — this object <i>is</i> the edge node's stream state.</b> It
///         holds no key and no map. Sparkplug numbers messages per edge node (an edge node's devices
///         share its counter, so a DDATA advances the same sequence an NDATA does), and the ingest
///         state machine already keeps per-scope state for the alias table and birth cache; giving
///         this type a second internal dictionary would mean two maps whose lifetimes must be kept in
///         step by hand, and a tracker that outlived a removed edge node would go on answering
///         questions about a stream nobody is reading.
///     </para>
///     <para>
///         <b>This type detects; it does not react.</b> It never publishes, never requests a rebirth
///         and never emits a quality change — the ingest state machine reads its verdicts and decides.
///         The split matters because "was a message lost" is a fact about the wire, while "request a
///         rebirth now" is a policy gated on <c>requestRebirthOnGap</c>.
///     </para>
///     <para>
///         <b>Thread-safe under a plain monitor.</b> Sparkplug messages arrive on MQTTnet's shared
///         dispatcher thread while the reconnect path can call <see cref="Reset"/> from another, and
///         every operation here is a read-modify-write of two coupled fields — the immutable-snapshot
///         discipline <c>MqttSubscriptionManager</c> uses fits a set that is swapped wholesale, not a
///         counter that is advanced. The critical sections are a handful of instructions each and
///         nothing inside them can block, so the simple lock this repo uses elsewhere
///         (<c>MqttDriver._hostLock</c>) is the right shape.
///     </para>
///     <para>
///         <b>Nothing here throws, for any input.</b> Same reasoning as
///         <see cref="SparkplugCodec"/>: this sits behind an unauthenticated firehose on the
///         dispatcher thread, so a spec-violating publisher gets a verdict, never an exception that
///         would stall delivery for every subscription on the connection.
///     </para>
/// </remarks>
public sealed class SequenceTracker
{
    /// <summary>
    ///     The largest legal Sparkplug <c>seq</c>. The counter is a byte on the wire in spirit but a
    ///     <c>uint64</c> in the schema, so the range check lives here — see <see cref="Accept"/>.
    /// </summary>
    public const ulong MaxSeq = 255;

    private readonly object _gate = new();

    /// <summary>The last credible sequence number, meaningful only when <see cref="_hasLastSeq"/>.</summary>
    private byte _lastSeq;

    /// <summary>Whether any credible sequence number has been observed since the last reset.</summary>
    private bool _hasLastSeq;

    /// <summary>Whether the current baseline was established by a birth rather than adopted mid-stream.</summary>
    private bool _birthSynchronized;

    /// <summary>The current session's <c>bdSeq</c>, or null when no birth has supplied one.</summary>
    private ulong? _birthBdSeq;

    /// <summary>
    ///     Whether the current sequence baseline came from a birth. <see langword="false"/> also after
    ///     a first message that was <i>not</i> a birth — the driver joined mid-stream and holds no
    ///     alias table, which is a different situation from a gap even though both call for a rebirth.
    /// </summary>
    public bool IsBirthSynchronized
    {
        get { lock (_gate) return _birthSynchronized; }
    }

    /// <summary>
    ///     The last credible sequence number observed, or <see langword="null"/> when none has been —
    ///     before the first message, or after <see cref="Reset"/>.
    /// </summary>
    public byte? LastSeq
    {
        get { lock (_gate) return _hasLastSeq ? _lastSeq : null; }
    }

    /// <summary>
    ///     The <c>bdSeq</c> of the most recent birth, or <see langword="null"/> when no birth has
    ///     supplied one. This is the token <see cref="IsDeathForCurrentSession"/> matches against.
    /// </summary>
    public ulong? BirthBdSeq
    {
        get { lock (_gate) return _birthBdSeq; }
    }

    /// <summary>
    ///     Records an <b>NBIRTH</b>: restarts the sequence at the birth's <c>seq</c> and adopts its
    ///     <c>bdSeq</c> as the current session token.
    /// </summary>
    /// <param name="seq">
    ///     The birth payload's <c>seq</c> — <see cref="SparkplugPayload.Seq"/>, which is
    ///     <see langword="null"/> when the message carried none.
    /// </param>
    /// <param name="bdSeq">
    ///     The birth's session token, read from its <c>bdSeq</c> metric with
    ///     <see cref="TryReadBdSeq"/>; <see langword="null"/> when the birth carried none.
    /// </param>
    /// <returns>
    ///     <see langword="true"/> when the birth established a usable sequence baseline;
    ///     <see langword="false"/> when its <c>seq</c> was absent or out of range.
    /// </returns>
    /// <remarks>
    ///     <para>
    ///         <b>An NBIRTH is never a gap.</b> It restarts the sequence by definition, so it must not
    ///         be run through <see cref="Accept"/> — doing so would flag a gap on the very message
    ///         that just resynchronized everything, and answer a birth with a request for another one.
    ///     </para>
    ///     <para>
    ///         <b>A DBIRTH must NOT be offered here — it belongs to <see cref="Accept"/>.</b> Only the
    ///         NBIRTH restarts the sequence; a DBIRTH carries the next number in the edge node's
    ///         ongoing stream, and only the NBIRTH (with the NDEATH) carries <c>bdSeq</c> at all.
    ///         Routing a DBIRTH here would do two wrong things at once: silently rebase the sequence
    ///         mid-stream, and — because the DBIRTH has no <c>bdSeq</c> to pass — <b>wipe the node's
    ///         session token to null</b>. A stale Last Will arriving afterwards would then find
    ///         nothing to be compared against, fall through the fail-toward-stale rule in
    ///         <see cref="IsDeathForCurrentSession"/>, and kill the live node this type exists to
    ///         protect. The method is named for the node deliberately, so that call site reads wrong.
    ///     </para>
    ///     <para>
    ///         <b>An in-range <c>seq</c> other than the spec-required 0 is adopted, not refused.</b>
    ///         Refusing it would leave the tracker unsynchronized against a publisher whose stream is
    ///         otherwise perfectly followable, and every message that followed would demand a fresh
    ///         rebirth — the storm this type exists to prevent, arrived at by being strict.
    ///     </para>
    ///     <para>
    ///         <b>The <c>bdSeq</c> is recorded even when the <c>seq</c> was unusable.</b> Session
    ///         identity and position-in-stream are independent facts; discarding the tie because the
    ///         sequence number was garbled would leave the next NDEATH unmatchable, which is the one
    ///         thing that lets a stale will kill a live node.
    ///     </para>
    /// </remarks>
    public bool AcceptNodeBirth(ulong? seq, ulong? bdSeq = null)
    {
        lock (_gate)
        {
            _birthBdSeq = bdSeq;

            if (seq is not { } value || value > MaxSeq)
            {
                _hasLastSeq = false;
                _birthSynchronized = false;
                return false;
            }

            _lastSeq = (byte)value;
            _hasLastSeq = true;
            _birthSynchronized = true;
            return true;
        }
    }

    /// <summary>
    ///     Offers the next sequenced message's <c>seq</c> — <b>DBIRTH</b>, NDATA, DDATA or DDEATH —
    ///     and reports whether the stream is still contiguous.
    /// </summary>
    /// <param name="seq">
    ///     The payload's <c>seq</c> — <see cref="SparkplugPayload.Seq"/>, which is
    ///     <see langword="null"/> when the message carried none.
    /// </param>
    /// <returns>
    ///     <see langword="true"/> when this <c>seq</c> is the one that follows the last;
    ///     <see langword="false"/> when a message was missed, when the value is out of range, or when
    ///     it is absent. A <see langword="false"/> is the caller's cue to request a rebirth.
    /// </returns>
    /// <remarks>
    ///     <para>
    ///         <b>The wrap is the whole point: next-expected is <c>(last + 1) &amp; 0xFF</c>, so
    ///         255 → 0 is the sequence continuing, not a gap.</b> Getting the boundary wrong fails
    ///         loudly in one direction and silently in the other. Read the wrap as a gap and every
    ///         edge node is asked to rebirth once per 256 messages, so a busy node spends its life
    ///         republishing births instead of publishing data. Miss a real gap and the driver serves
    ///         values it knows are behind the device, at Good quality, indefinitely.
    ///     </para>
    ///     <para>
    ///         <b>A gap is reported once, then the tracker resynchronizes onto the observed value.</b>
    ///         Holding the baseline at the pre-gap number would make every following message report a
    ///         gap too, so a single lost message would become a permanent rebirth storm.
    ///     </para>
    ///     <para>
    ///         <b>An out-of-range <c>seq</c> is refused, never masked into range.</b> The codec hands
    ///         this over as a <see cref="ulong"/> precisely so a spec-violating publisher's value is
    ///         not silently truncated into a plausible-looking one, and masking would finish the job
    ///         it declined to do: with the baseline at 255, a <c>seq</c> of 256 masks to 0 — exactly
    ///         the value the wrap expects next — and the bogus message would be accepted as
    ///         contiguous. Refused values do not become the baseline either, so the next genuine
    ///         message is still measured against the last credible one.
    ///     </para>
    ///     <para>
    ///         <b>The first message is not a gap</b> — there is nothing to measure it against. It is
    ///         adopted as the baseline, and <see cref="IsBirthSynchronized"/> stays
    ///         <see langword="false"/> to record that the driver joined mid-stream.
    ///     </para>
    ///     <para>
    ///         <b>NDEATH must not be offered here.</b> An NDEATH is the broker-published Last Will and
    ///         carries no <c>seq</c> at all; feeding it in would report a gap on a message that never
    ///         had a place in the sequence. It is tied to its birth by
    ///         <see cref="IsDeathForCurrentSession"/> instead. A DDEATH is published by the edge node
    ///         and <i>is</i> sequenced, so it does belong here.
    ///     </para>
    /// </remarks>
    public bool Accept(ulong? seq)
    {
        lock (_gate)
        {
            // Refused without becoming the baseline: see the out-of-range remarks above.
            if (seq is not { } value || value > MaxSeq)
            {
                return false;
            }

            var current = (byte)value;

            if (!_hasLastSeq)
            {
                _lastSeq = current;
                _hasLastSeq = true;
                return true;
            }

            var expected = (byte)((_lastSeq + 1) & 0xFF);

            // Resynchronize even on a gap — one lost message must not become a permanent storm.
            _lastSeq = current;

            return current == expected;
        }
    }

    /// <summary>
    ///     Reports whether an NDEATH belongs to the session currently believed alive — the
    ///     <c>bdSeq</c> tie of §3.6 invariant #3.
    /// </summary>
    /// <param name="deathBdSeq">
    ///     The death payload's <c>bdSeq</c>, read with <see cref="TryReadBdSeq"/>;
    ///     <see langword="null"/> when it carried none.
    /// </param>
    /// <returns>
    ///     <see langword="true"/> when the death applies and the node's metrics should go stale;
    ///     <see langword="false"/> when it is a previous session's will and must be ignored.
    /// </returns>
    /// <remarks>
    ///     <para>
    ///         <b>What this prevents.</b> An edge node's connection drops, it reconnects and publishes
    ///         a fresh NBIRTH — and only <i>then</i> does the broker notice the old connection died
    ///         and deliver its retained Last Will. The NDEATH arrives after the NBIRTH, carrying the
    ///         previous session's <c>bdSeq</c>. Without this comparison that stale will marks a live,
    ///         freshly-born node dead and drives every one of its tags to Bad quality, with no further
    ///         message coming to correct it until the node happens to rebirth again.
    ///     </para>
    ///     <para>
    ///         <b>Unknowns fail toward stale, deliberately.</b> A death arriving before any birth was
    ///         seen, a death carrying no <c>bdSeq</c>, or a birth that carried none all resolve to
    ///         <see langword="true"/>. The two error directions are not symmetric: acting on a death
    ///         wrongly marks tags stale until the next birth restores them (§3.6 invariant #4, a
    ///         self-correcting error an operator can see), while ignoring a real death leaves a dead
    ///         node's last values flowing at Good quality forever. Only a <i>positive mismatch</i> —
    ///         two known, different tokens — is evidence enough to discard a death.
    ///     </para>
    ///     <para>
    ///         <b>Pure.</b> It answers a question and changes nothing, so the caller can ask it while
    ///         logging or deciding. A death that <i>is</i> acted on needs no reset here: the node is
    ///         offline, and the next birth calls <see cref="AcceptNodeBirth"/> which restarts the sequence
    ///         anyway.
    ///     </para>
    /// </remarks>
    public bool IsDeathForCurrentSession(ulong? deathBdSeq)
    {
        lock (_gate)
        {
            if (_birthBdSeq is not { } birth || deathBdSeq is not { } death)
            {
                return true;
            }

            return birth == death;
        }
    }

    /// <summary>
    ///     Returns the tracker to its virgin state: no sequence baseline, not birth-synchronized, no
    ///     session token.
    /// </summary>
    /// <remarks>
    ///     The reconnect seam (§3.6 invariant #5 — late join). After a dropped connection the driver
    ///     has missed an unknown number of messages, so the baseline it was holding is no longer
    ///     evidence of anything: carrying it across would let the stream look contiguous purely
    ///     because the edge node happened to publish the right number of messages while the driver was
    ///     away.
    /// </remarks>
    public void Reset()
    {
        lock (_gate)
        {
            _lastSeq = 0;
            _hasLastSeq = false;
            _birthSynchronized = false;
            _birthBdSeq = null;
        }
    }

    /// <summary>
    ///     Reads the <c>bdSeq</c> session token out of a decoded NBIRTH or NDEATH payload.
    /// </summary>
    /// <param name="payload">The decoded payload; <see langword="null"/> and invalid are both handled.</param>
    /// <param name="bdSeq">The token on success; <c>0</c> otherwise.</param>
    /// <returns><see langword="true"/> when the payload carried a usable <c>bdSeq</c> metric.</returns>
    /// <remarks>
    ///     <para>
    ///         <b><c>bdSeq</c> is not a top-level payload field</b> — unlike <c>seq</c> it rides as an
    ///         ordinary metric named <c>bdSeq</c>, which is why reading it is a helper rather than a
    ///         property. The name is matched exactly (ordinal): the spec fixes it, so a case variant
    ///         is far more likely to be a different metric than a typo of this one.
    ///     </para>
    ///     <para>
    ///         <b>Signed values are reinterpreted before they are judged.</b> Sparkplug carries
    ///         <c>Int64</c> as two's complement in an unsigned field, so a negative <c>bdSeq</c>
    ///         reaches the projection as an enormous <see cref="ulong"/>. Read raw it would become a
    ///         plausible-looking session token minted out of nonsense; run through
    ///         <see cref="SparkplugCodec.ReinterpretSigned"/> it is visibly negative and refused.
    ///     </para>
    ///     <para>
    ///         <b>The value is treated as an opaque token, not as a 0–255 counter.</b> Its only job
    ///         here is equality against the birth's, and a range check could never make a match more
    ///         or less correct — it could only discard the one piece of evidence tying a death to its
    ///         birth. This is the opposite call from <c>seq</c>, whose range is load-bearing precisely
    ///         because its arithmetic wraps.
    ///     </para>
    /// </remarks>
    public static bool TryReadBdSeq(SparkplugPayload? payload, out ulong bdSeq)
    {
        bdSeq = 0;

        if (payload is not { IsValid: true })
        {
            return false;
        }

        foreach (var metric in payload.Metrics)
        {
            if (!string.Equals(metric.Name, "bdSeq", StringComparison.Ordinal))
            {
                continue;
            }

            if (metric.ValueKind != SparkplugValueKind.Scalar)
            {
                return false;
            }

            var value = metric.DataType is { } datatype
                ? SparkplugCodec.ReinterpretSigned(metric.Value, datatype)
                : metric.Value;

            switch (value)
            {
                case ulong u:
                    bdSeq = u;
                    return true;
                case uint u:
                    bdSeq = u;
                    return true;
                case long l when l >= 0:
                    bdSeq = (ulong)l;
                    return true;
                case int i when i >= 0:
                    bdSeq = (ulong)i;
                    return true;
                case short s when s >= 0:
                    bdSeq = (ulong)s;
                    return true;
                case sbyte sb when sb >= 0:
                    bdSeq = (ulong)sb;
                    return true;
                default:
                    // Negative, or a wire type no integer token can come from.
                    return false;
            }
        }

        return false;
    }
}
