using System.Buffers;
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography.X509Certificates;
using Google.Protobuf;
using MQTTnet;
using MQTTnet.Protocol;
using Org.Eclipse.Tahu.Protobuf;

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

/// <summary>
///     A controllable Sparkplug B <b>edge node</b> — the driver's counterparty on a real broker.
///     Registers an NDEATH Last Will at CONNECT, publishes NBIRTH/DBIRTH then N/DDATA, honours a
///     <c>Node Control/Rebirth</c> NCMD, and exposes the §3.6 pathologies (alias reuse across a
///     rebirth, a seq gap, a stale-<c>bdSeq</c> death) as explicit, on-demand operations.
/// </summary>
/// <remarks>
///     <para>
///         <b>Encodes with the generated Tahu types, frames topics independently.</b> The payloads are
///         built from <see cref="Payload"/> — the same generated schema the driver decodes with, which
///         is what makes this fixture a genuine encode/decode symmetry check. The <i>topic</i> strings
///         are composed here from literals rather than through the driver's own
///         <c>SparkplugTopic.Format</c>: a simulator that borrowed the parser's formatter could not
///         detect a bug in it, because both sides of the comparison would be wrong together.
///     </para>
///     <para>
///         <b>What "the way a real edge node does it" means, concretely, and why each detail matters:</b>
///     </para>
///     <list type="bullet">
///         <item>
///             <b>NDEATH is the broker-published Last Will, registered at CONNECT</b> — not a message
///             this class publishes on the way out. It carries a <c>bdSeq</c> metric and <b>no
///             <c>seq</c></b>, because it is not a member of the node's sequence.
///             <see cref="KillAsync"/> makes the broker fire it.
///         </item>
///         <item>
///             <b><c>bdSeq</c> increments once per CONNECT</b> and appears in both the NBIRTH and the
///             NDEATH of that session — that pairing is the only thing that lets a host tell a
///             just-delivered stale will from a real death.
///         </item>
///         <item>
///             <b>NBIRTH restarts <c>seq</c> at 0</b>; DBIRTH/NDATA/DDATA/DDEATH continue it, wrapping
///             255 → 0.
///         </item>
///         <item>
///             <b>DATA metrics carry an alias and nothing else</b> — no name, no datatype. That is what
///             every real post-birth DATA message looks like, and a driver that binds tags to aliases
///             rather than names passes every test written against a friendlier simulator.
///         </item>
///         <item>
///             <b>Signed integers ride as two's complement in the UNSIGNED proto field.</b> A
///             <c>DataType.Int32</c> metric holding −1234 goes on the wire as <c>int_value =
///             4294966062</c>. Encoding it "helpfully" as a positive number would silently retire the
///             driver's <c>ReinterpretSigned</c> path from the live gate.
///         </item>
///     </list>
///     <para>
///         Not thread-safe for concurrent publishes: <c>seq</c> is a single counter and interleaving
///         publishes would manufacture the very gaps the tests are measuring. Drive one node from one
///         test at a time; the NCMD-triggered rebirth is serialized against callers by
///         <see cref="_publishGate"/>.
///     </para>
/// </remarks>
public sealed class SparkplugEdgeNode : IAsyncDisposable
{
    /// <summary>The well-known Sparkplug node-control metric an NCMD sets to trigger a rebirth.</summary>
    public const string RebirthMetricName = "Node Control/Rebirth";

    /// <summary>The Sparkplug session-token metric name, carried by NBIRTH and NDEATH.</summary>
    public const string BdSeqMetricName = "bdSeq";

    private const string Namespace = "spBv1.0";

    private readonly SparkplugEdgeNodeOptions _options;
    private readonly Action<string>? _log;
    private readonly SemaphoreSlim _publishGate = new(1, 1);

    /// <summary>Device id → its metric catalog. Ordered so a birth's metric order is deterministic.</summary>
    private readonly ConcurrentDictionary<string, IReadOnlyList<SparkplugMetricSeed>> _devices = new(StringComparer.Ordinal);

    private IMqttClient? _client;
    private IReadOnlyList<SparkplugMetricSeed> _nodeMetrics = [];
    private ulong _bdSeq;
    private int _sessions;
    private byte _seq;
    private bool _seqStarted;
    private int _rebirthCount;
    private int _disposed;
    private TaskCompletionSource<int> _rebirthSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);

    /// <summary>Initializes a new simulated edge node. Does no I/O — call <see cref="ConnectAsync"/>.</summary>
    /// <param name="options">Broker connection + Sparkplug identity.</param>
    /// <param name="log">Optional line sink for the standalone runner's console output.</param>
    public SparkplugEdgeNode(SparkplugEdgeNodeOptions options, Action<string>? log = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        ArgumentException.ThrowIfNullOrWhiteSpace(options.GroupId);
        ArgumentException.ThrowIfNullOrWhiteSpace(options.EdgeNodeId);

        _options = options;
        _log = log;
    }

    /// <summary>The Sparkplug group id.</summary>
    public string GroupId => _options.GroupId;

    /// <summary>The Sparkplug edge-node id.</summary>
    public string EdgeNodeId => _options.EdgeNodeId;

    /// <summary>The current session token — incremented on every <see cref="ConnectAsync"/>.</summary>
    public ulong BdSeq => Volatile.Read(ref _bdSeq);

    /// <summary>The <c>seq</c> of the most recently published sequenced message.</summary>
    public byte LastSeq => Volatile.Read(ref _seq);

    /// <summary>How many rebirth NCMDs this node has honoured since construction.</summary>
    public int RebirthCount => Volatile.Read(ref _rebirthCount);

    /// <summary>Whether the underlying MQTT client currently holds a session.</summary>
    public bool IsConnected => _client?.IsConnected ?? false;

    /// <summary>The device ids this node currently publishes for.</summary>
    public IReadOnlyCollection<string> Devices => [.. _devices.Keys];

    /// <summary>
    ///     Replaces this node's own (NBIRTH-level) metric catalog. Takes effect at the next birth —
    ///     which is exactly how an edge node reassigns an alias across a rebirth.
    /// </summary>
    /// <param name="metrics">The catalog.</param>
    public void SetNodeMetrics(params SparkplugMetricSeed[] metrics) =>
        _nodeMetrics = [.. metrics ?? []];

    /// <summary>Replaces one device's (DBIRTH-level) metric catalog. Takes effect at the next birth.</summary>
    /// <param name="deviceId">The device id.</param>
    /// <param name="metrics">The catalog.</param>
    public void SetDeviceMetrics(string deviceId, params SparkplugMetricSeed[] metrics)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
        _devices[deviceId] = [.. metrics ?? []];
    }

    /// <summary>
    ///     Connects to the broker with an NDEATH Last Will registered for this session, and subscribes
    ///     the node's NCMD topic so a rebirth request can be honoured.
    /// </summary>
    /// <remarks>
    ///     <b>The Will is registered here or not at all.</b> MQTT fixes a client's will at CONNECT; a
    ///     "death message" published later by the client itself is a different thing entirely and would
    ///     never fire for the case that matters — the node dying without getting to say so.
    /// </remarks>
    /// <param name="cancellationToken">Cancellation for the connect.</param>
    /// <returns>A task that completes when the session is established and NCMD is subscribed.</returns>
    public async Task ConnectAsync(CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);

        await DropClientAsync(graceful: true).ConfigureAwait(false);

        // A NEW session ⇒ a new session token, starting at 0 and incrementing per CONNECT. Both the
        // Will registered below and the next NBIRTH carry it, which is the pairing a host uses to
        // discard a previous session's late-delivered will.
        Volatile.Write(ref _bdSeq, (ulong)(Interlocked.Increment(ref _sessions) - 1));

        var client = new MqttClientFactory().CreateMqttClient();
        client.ApplicationMessageReceivedAsync += OnMessageAsync;

        var clientId = _options.ClientId is { Length: > 0 } id ? id : $"sim-{Guid.NewGuid():N}";

        var builder = new MqttClientOptionsBuilder()
            .WithTcpServer(_options.Host, _options.Port)
            .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
            .WithCleanSession(true)
            .WithClientId(clientId)
            .WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
            .WithTimeout(TimeSpan.FromSeconds(_options.TimeoutSeconds))

            // NDEATH: QoS 1, retain false, no seq, carries this session's bdSeq. Sparkplug B v3.0 §6.
            .WithWillTopic(NodeTopic("NDEATH"))
            .WithWillPayload(BuildDeathPayload(CurrentBdSeq()).ToByteArray())
            .WithWillQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
            .WithWillRetain(false);

        if (!string.IsNullOrEmpty(_options.Username))
        {
            builder = builder.WithCredentials(_options.Username, _options.Password ?? string.Empty);
        }

        builder = builder.WithTlsOptions(ConfigureTls);

        _client = client;
        await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false);

        var subscribe = new MqttClientSubscribeOptionsBuilder()
            .WithTopicFilter(f => f
                .WithTopic(NodeTopic("NCMD"))
                .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce))
            .Build();
        await client.SubscribeAsync(subscribe, cancellationToken).ConfigureAwait(false);

        Log($"connected as '{clientId}' (bdSeq {CurrentBdSeq()}), listening on {NodeTopic("NCMD")}");
    }

    /// <summary>
    ///     Publishes the node's birth certificate — NBIRTH at <c>seq = 0</c>, then one DBIRTH per
    ///     device continuing the same sequence.
    /// </summary>
    /// <param name="cancellationToken">Cancellation for the publishes.</param>
    /// <returns>A task that completes when every birth message has been published.</returns>
    public async Task BirthAsync(CancellationToken cancellationToken)
    {
        await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            await BirthCoreAsync(cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            _publishGate.Release();
        }
    }

    /// <summary>
    ///     Publishes an NDATA carrying the named node-level metrics, encoded the way a real edge node
    ///     does: <b>alias only</b>, resolved from the current birth catalog.
    /// </summary>
    /// <param name="values">Metric name → new value. A name not in the catalog throws.</param>
    /// <param name="cancellationToken">Cancellation for the publish.</param>
    /// <returns>A task that completes when the message has been published.</returns>
    public Task PublishNodeDataAsync(
        IReadOnlyList<(string Metric, object? Value)> values,
        CancellationToken cancellationToken) =>
        PublishDataAsync(device: null, values, cancellationToken);

    /// <summary>Publishes a DDATA for one device — alias only, same sequence as the node's own stream.</summary>
    /// <param name="deviceId">The device id.</param>
    /// <param name="values">Metric name → new value.</param>
    /// <param name="cancellationToken">Cancellation for the publish.</param>
    /// <returns>A task that completes when the message has been published.</returns>
    public Task PublishDeviceDataAsync(
        string deviceId,
        IReadOnlyList<(string Metric, object? Value)> values,
        CancellationToken cancellationToken) =>
        PublishDataAsync(deviceId, values, cancellationToken);

    /// <summary>
    ///     Publishes an NDATA whose metrics carry an alias the current birth does <b>not</b> declare —
    ///     the "unknown alias" arm of §3.6.
    /// </summary>
    /// <param name="alias">The undeclared alias.</param>
    /// <param name="value">A double value, encoded as <c>double_value</c>.</param>
    /// <param name="cancellationToken">Cancellation for the publish.</param>
    /// <returns>A task that completes when the message has been published.</returns>
    public async Task PublishUnknownAliasDataAsync(ulong alias, double value, CancellationToken cancellationToken)
    {
        await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            var payload = NewSequencedPayload();
            payload.Metrics.Add(new Payload.Types.Metric
            {
                Alias = alias,
                Timestamp = NowMs(),
                DoubleValue = value,
            });

            await PublishAsync(NodeTopic("NDATA"), payload, _options.DataQos, cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            _publishGate.Release();
        }
    }

    /// <summary>
    ///     Burns <paramref name="count"/> sequence numbers without publishing them — the next message
    ///     therefore arrives with a <c>seq</c> the host was not expecting. This is how a lost message
    ///     looks from the receiving end, produced deterministically instead of by hoping for packet loss.
    /// </summary>
    /// <param name="count">How many sequence numbers to skip; must be at least 1.</param>
    public void SkipSeq(int count = 1)
    {
        ArgumentOutOfRangeException.ThrowIfLessThan(count, 1);
        for (var i = 0; i < count; i++)
        {
            NextSeq();
        }
    }

    /// <summary>
    ///     Publishes an NDEATH <b>directly</b>, carrying <paramref name="bdSeq"/>. Two uses: a stale
    ///     token (a previous session's will, delivered late) which a correct host must ignore, and the
    ///     current token, which it must act on.
    /// </summary>
    /// <param name="bdSeq">The session token to stamp; <see langword="null"/> uses the current one.</param>
    /// <param name="cancellationToken">Cancellation for the publish.</param>
    /// <returns>A task that completes when the message has been published.</returns>
    /// <remarks>
    ///     A real NDEATH is broker-issued (see <see cref="KillAsync"/>); this is the deterministic
    ///     equivalent for the ordering case a test cannot otherwise stage — a will that arrives
    ///     <i>after</i> the reconnect's NBIRTH.
    /// </remarks>
    public async Task PublishNodeDeathAsync(ulong? bdSeq, CancellationToken cancellationToken)
    {
        await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            // QoS 1 and NO seq — an NDEATH is not a member of the node's sequence.
            await PublishAsync(
                NodeTopic("NDEATH"),
                BuildDeathPayload(bdSeq ?? CurrentBdSeq()),
                qos: 1,
                cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            _publishGate.Release();
        }
    }

    /// <summary>Publishes a DDEATH for one device — sequenced, unlike an NDEATH.</summary>
    /// <param name="deviceId">The device id.</param>
    /// <param name="cancellationToken">Cancellation for the publish.</param>
    /// <returns>A task that completes when the message has been published.</returns>
    public async Task PublishDeviceDeathAsync(string deviceId, CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);

        await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            await PublishAsync(
                DeviceTopic("DDEATH", deviceId),
                NewSequencedPayload(),
                _options.DataQos,
                cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            _publishGate.Release();
        }
    }

    /// <summary>
    ///     Ends the session in a way that makes the <b>broker</b> publish this node's registered
    ///     NDEATH Will — the realistic death path.
    /// </summary>
    /// <param name="cancellationToken">Cancellation for the disconnect.</param>
    /// <returns>A task that completes once the client has gone.</returns>
    /// <remarks>
    ///     Uses the MQTT 5 <c>DISCONNECT</c> reason code <c>0x04 Disconnect with Will Message</c>, which
    ///     is the protocol's own "I am going away, publish my will" signal. Deliberately chosen over
    ///     yanking the socket: both produce the will, but only this one does so <i>deterministically</i>
    ///     and immediately — a killed socket leaves the broker waiting out the keep-alive, which would
    ///     make the death test depend on a 30-second timer.
    /// </remarks>
    public async Task KillAsync(CancellationToken cancellationToken)
    {
        var client = _client;
        if (client is null)
        {
            return;
        }

        var options = new MqttClientDisconnectOptionsBuilder()
            .WithReason(MqttClientDisconnectOptionsReason.DisconnectWithWillMessage)
            .Build();

        try
        {
            await client.DisconnectAsync(options, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            Log($"kill: disconnect threw ({ex.GetType().Name}); the socket is gone either way");
        }

        client.ApplicationMessageReceivedAsync -= OnMessageAsync;
        client.Dispose();
        _client = null;
        Log($"killed session (bdSeq {CurrentBdSeq()}); the broker owns the NDEATH now");
    }

    /// <summary>
    ///     Awaits the <paramref name="count"/>-th honoured rebirth NCMD. Pre-checks what has already
    ///     happened, so a caller that registers after the command landed is served immediately.
    /// </summary>
    /// <param name="count">The rebirth count to wait for.</param>
    /// <param name="timeout">Bound on the wait.</param>
    /// <param name="cancellationToken">Caller cancellation.</param>
    /// <returns>The rebirth count once it has reached <paramref name="count"/>.</returns>
    public async Task<int> WaitForRebirthAsync(int count, TimeSpan timeout, CancellationToken cancellationToken)
    {
        var deadline = DateTime.UtcNow + timeout;
        while (true)
        {
            var observed = RebirthCount;
            if (observed >= count)
            {
                return observed;
            }

            var remaining = deadline - DateTime.UtcNow;
            if (remaining <= TimeSpan.Zero)
            {
                throw new TimeoutException(
                    $"Simulated edge node '{GroupId}/{EdgeNodeId}' honoured {observed} rebirth request(s) "
                    + $"within {timeout.TotalSeconds:0}s; expected at least {count}. Is the driver's "
                    + "requestRebirthOnGap on, and is the node's NCMD subscription live?");
            }

            var signal = Volatile.Read(ref _rebirthSignal);
            try
            {
                await signal.Task.WaitAsync(remaining, cancellationToken).ConfigureAwait(false);
            }
            catch (TimeoutException)
            {
                // Loop; the deadline check above produces the diagnostic message.
            }
        }
    }

    /// <summary>
    ///     The standalone (container) loop: birth once, then publish node + device data every
    ///     <paramref name="interval"/> until cancelled. Rebirth NCMDs are honoured throughout.
    /// </summary>
    /// <param name="interval">Publish cadence.</param>
    /// <param name="cancellationToken">Stops the loop.</param>
    /// <returns>A task that completes when the loop is cancelled.</returns>
    public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
    {
        await BirthAsync(cancellationToken).ConfigureAwait(false);

        var tick = 0;
        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                break;
            }

            tick++;
            try
            {
                await PublishNodeDataAsync(
                    [.. _nodeMetrics
                        .Where(m => m.Name is not BdSeqMetricName and not RebirthMetricName)
                        .Select(m => (m.Name, Advance(m, tick)))],
                    cancellationToken).ConfigureAwait(false);

                foreach (var (device, metrics) in _devices)
                {
                    await PublishDeviceDataAsync(
                        device,
                        [.. metrics.Select(m => (m.Name, Advance(m, tick)))],
                        cancellationToken).ConfigureAwait(false);
                }
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                Log($"publish failed: {ex.GetType().Name}: {ex.Message}");
            }
        }
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
        {
            return;
        }

        await DropClientAsync(graceful: true).ConfigureAwait(false);
        _publishGate.Dispose();
    }

    // ---- publishing ----

    private async Task BirthCoreAsync(CancellationToken cancellationToken)
    {
        // An NBIRTH restarts the sequence: seq = 0, by spec, on the birth itself.
        Volatile.Write(ref _seq, 0);
        _seqStarted = true;

        var nbirth = new Payload { Timestamp = NowMs(), Seq = 0UL };

        // bdSeq first, as Tahu's own reference edge node emits it: it is the session token the
        // matching NDEATH will carry.
        nbirth.Metrics.Add(new Payload.Types.Metric
        {
            Name = BdSeqMetricName,
            Datatype = (uint)DataType.Int64,
            Timestamp = NowMs(),
            LongValue = CurrentBdSeq(),
        });

        // Every conformant edge node declares the rebirth control point in its NBIRTH.
        nbirth.Metrics.Add(new Payload.Types.Metric
        {
            Name = RebirthMetricName,
            Datatype = (uint)DataType.Boolean,
            Timestamp = NowMs(),
            BooleanValue = false,
        });

        foreach (var metric in _nodeMetrics)
        {
            nbirth.Metrics.Add(EncodeBirthMetric(metric));
        }

        await PublishAsync(NodeTopic("NBIRTH"), nbirth, _options.DataQos, cancellationToken).ConfigureAwait(false);

        foreach (var (device, metrics) in _devices)
        {
            // A DBIRTH is a sequenced member of the edge node's stream — it does NOT restart seq, and
            // it carries no bdSeq.
            var dbirth = NewSequencedPayload();
            foreach (var metric in metrics)
            {
                dbirth.Metrics.Add(EncodeBirthMetric(metric));
            }

            await PublishAsync(DeviceTopic("DBIRTH", device), dbirth, _options.DataQos, cancellationToken)
                .ConfigureAwait(false);
        }

        Log($"birthed (bdSeq {CurrentBdSeq()}, {_nodeMetrics.Count} node metric(s), {_devices.Count} device(s))");
    }

    private async Task PublishDataAsync(
        string? device,
        IReadOnlyList<(string Metric, object? Value)> values,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(values);

        var catalog = device is null
            ? _nodeMetrics
            : _devices.TryGetValue(device, out var found) ? found : [];

        await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            var payload = NewSequencedPayload();
            foreach (var (name, value) in values)
            {
                var seed = catalog.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.Ordinal))
                    ?? throw new InvalidOperationException(
                        $"Metric '{name}' is not in the current birth catalog for "
                        + $"'{GroupId}/{EdgeNodeId}{(device is null ? "" : "/" + device)}'. A DATA message can only "
                        + "carry metrics a birth declared — that is the whole point of the alias table.");

                payload.Metrics.Add(EncodeDataMetric(seed.WithValue(value)));
            }

            var topic = device is null ? NodeTopic("NDATA") : DeviceTopic("DDATA", device);
            await PublishAsync(topic, payload, _options.DataQos, cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            _publishGate.Release();
        }
    }

    private async Task PublishAsync(string topic, Payload payload, int qos, CancellationToken cancellationToken)
    {
        var client = _client ?? throw new InvalidOperationException(
            $"Simulated edge node '{GroupId}/{EdgeNodeId}' is not connected; call ConnectAsync first.");

        var message = new MqttApplicationMessageBuilder()
            .WithTopic(topic)
            .WithPayload(payload.ToByteArray())
            .WithQualityOfServiceLevel((MqttQualityOfServiceLevel)Math.Clamp(qos, 0, 2))
            .WithRetainFlag(false)      // Sparkplug never retains birth or data messages.
            .Build();

        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        deadline.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
        await client.PublishAsync(message, deadline.Token).ConfigureAwait(false);
    }

    // ---- inbound NCMD ----

    private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args)
    {
        try
        {
            if (!IsRebirthCommand(args.ApplicationMessage))
            {
                return;
            }

            var honoured = Interlocked.Increment(ref _rebirthCount);
            Log($"rebirth NCMD #{honoured} received; republishing metadata");

            await _publishGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
            try
            {
                await BirthCoreAsync(CancellationToken.None).ConfigureAwait(false);
            }
            finally
            {
                _publishGate.Release();
            }

            // Swap the signal AFTER the birth is on the wire, so a waiter that wakes on it can
            // immediately assert against the republished state rather than racing it.
            var previous = Interlocked.Exchange(
                ref _rebirthSignal,
                new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously));
            previous.TrySetResult(honoured);
        }
        catch (Exception ex)
        {
            // This runs on MQTTnet's dispatcher thread; a throw here stalls delivery for the client.
            Log($"NCMD handling failed: {ex.GetType().Name}: {ex.Message}");
        }
    }

    private bool IsRebirthCommand(MqttApplicationMessage message)
    {
        if (!string.Equals(message.Topic, NodeTopic("NCMD"), StringComparison.Ordinal))
        {
            return false;
        }

        var body = message.Payload;
        var bytes = body.IsSingleSegment ? body.FirstSpan.ToArray() : body.ToArray();

        Payload decoded;
        try
        {
            decoded = Payload.Parser.ParseFrom(bytes);
        }
        catch (InvalidProtocolBufferException)
        {
            Log("NCMD payload was not a Sparkplug payload; ignored");
            return false;
        }

        foreach (var metric in decoded.Metrics)
        {
            if (string.Equals(metric.Name, RebirthMetricName, StringComparison.Ordinal)
                && metric.ValueCase == Payload.Types.Metric.ValueOneofCase.BooleanValue
                && metric.BooleanValue)
            {
                return true;
            }
        }

        return false;
    }

    // ---- encoding ----

    /// <summary>
    ///     A birth metric: name, alias, datatype and value — everything a host needs to build its alias
    ///     table.
    /// </summary>
    private static Payload.Types.Metric EncodeBirthMetric(SparkplugMetricSeed seed)
    {
        var metric = new Payload.Types.Metric
        {
            Name = seed.Name,
            Alias = seed.Alias,
            Datatype = (uint)seed.DataType,
            Timestamp = NowMs(),
        };

        ApplyValue(metric, seed.DataType, seed.Value);
        return metric;
    }

    /// <summary>
    ///     A DATA metric: <b>alias only</b>. No name, no datatype — which is how a real edge node
    ///     publishes after a birth, and the shape that makes bind-by-name load-bearing on the host.
    /// </summary>
    private static Payload.Types.Metric EncodeDataMetric(SparkplugMetricSeed seed)
    {
        var metric = new Payload.Types.Metric
        {
            Alias = seed.Alias,
            Timestamp = NowMs(),
        };

        ApplyValue(metric, seed.DataType, seed.Value);
        return metric;
    }

    /// <summary>
    ///     Writes a value into the metric's <c>oneof</c>, in the field Sparkplug's datatype dictates.
    /// </summary>
    /// <remarks>
    ///     <b>Signed integers go on the wire as two's complement in the UNSIGNED field</b> — Int8/16/32
    ///     in <c>int_value</c>, Int64 in <c>long_value</c>. This is the encoding a host has to undo, and
    ///     writing a negative value any other way would quietly retire that code path from the gate.
    /// </remarks>
    private static void ApplyValue(Payload.Types.Metric metric, DataType dataType, object? value)
    {
        if (value is null)
        {
            // An explicit null, not an omitted value: the two mean different things, and only the
            // explicit form says "this metric exists and currently has no value".
            metric.IsNull = true;
            return;
        }

        switch (dataType)
        {
            case DataType.Int8:
                metric.IntValue = unchecked((uint)(sbyte)Convert.ToSByte(value, CultureInfo.InvariantCulture));
                break;
            case DataType.Int16:
                metric.IntValue = unchecked((uint)Convert.ToInt16(value, CultureInfo.InvariantCulture));
                break;
            case DataType.Int32:
                metric.IntValue = unchecked((uint)Convert.ToInt32(value, CultureInfo.InvariantCulture));
                break;
            case DataType.Int64:
                metric.LongValue = unchecked((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture));
                break;
            case DataType.Uint8:
            case DataType.Uint16:
            case DataType.Uint32:
                metric.IntValue = Convert.ToUInt32(value, CultureInfo.InvariantCulture);
                break;
            case DataType.Uint64:
                metric.LongValue = Convert.ToUInt64(value, CultureInfo.InvariantCulture);
                break;
            case DataType.Float:
                metric.FloatValue = Convert.ToSingle(value, CultureInfo.InvariantCulture);
                break;
            case DataType.Double:
                metric.DoubleValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
                break;
            case DataType.Boolean:
                metric.BooleanValue = Convert.ToBoolean(value, CultureInfo.InvariantCulture);
                break;
            case DataType.DateTime:
                metric.LongValue = value is DateTime dt
                    ? (ulong)new DateTimeOffset(dt.ToUniversalTime()).ToUnixTimeMilliseconds()
                    : Convert.ToUInt64(value, CultureInfo.InvariantCulture);
                break;
            case DataType.String:
            case DataType.Text:
            case DataType.Uuid:
                metric.StringValue = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
                break;
            case DataType.Bytes:
            case DataType.File:
                metric.BytesValue = ByteString.CopyFrom((byte[])value);
                break;
            default:
                throw new NotSupportedException(
                    $"The simulator does not encode Sparkplug datatype {dataType}; v1 of the driver does "
                    + "not consume it either.");
        }
    }

    private Payload NewSequencedPayload()
    {
        var payload = new Payload { Timestamp = NowMs(), Seq = NextSeq() };
        return payload;
    }

    /// <summary>Advances the wrapping 0–255 sequence and returns the value to stamp.</summary>
    private ulong NextSeq()
    {
        if (!_seqStarted)
        {
            // Nothing has birthed yet: start the stream at 0 rather than at 1, so a data-before-birth
            // test still produces a legal-looking stream.
            _seqStarted = true;
            Volatile.Write(ref _seq, 0);
            return 0UL;
        }

        var next = unchecked((byte)(Volatile.Read(ref _seq) + 1));
        Volatile.Write(ref _seq, next);
        return next;
    }

    private Payload BuildDeathPayload(ulong bdSeq)
    {
        // NO seq: an NDEATH is the broker-published Last Will and is not a member of the node's
        // sequence. Adding one here would be the single most plausible-looking way to make every
        // death look like a gap on the host.
        var payload = new Payload { Timestamp = NowMs() };
        payload.Metrics.Add(new Payload.Types.Metric
        {
            Name = BdSeqMetricName,
            Datatype = (uint)DataType.Int64,
            Timestamp = NowMs(),
            LongValue = bdSeq,
        });

        return payload;
    }

    // ---- helpers ----

    private ulong CurrentBdSeq() => Volatile.Read(ref _bdSeq);

    private string NodeTopic(string type) => $"{Namespace}/{_options.GroupId}/{type}/{_options.EdgeNodeId}";

    private string DeviceTopic(string type, string device) =>
        $"{Namespace}/{_options.GroupId}/{type}/{_options.EdgeNodeId}/{device}";

    private static ulong NowMs() => (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

    /// <summary>Nudges a seed's value so the standalone loop publishes something that visibly moves.</summary>
    private static object? Advance(SparkplugMetricSeed seed, int tick) => seed.DataType switch
    {
        DataType.Float => 20f + (tick % 50) / 10f,
        DataType.Double => 100d + (tick % 100) / 10d,
        DataType.Int32 or DataType.Int16 or DataType.Int8 => -tick,
        DataType.Int64 => (long)tick,
        DataType.Uint8 or DataType.Uint16 or DataType.Uint32 or DataType.Uint64 => (uint)tick,
        DataType.Boolean => tick % 2 == 0,
        DataType.String or DataType.Text or DataType.Uuid => $"tick-{tick}",
        _ => seed.Value,
    };

    private void ConfigureTls(MqttClientTlsOptionsBuilder tls)
    {
        if (!_options.UseTls)
        {
            tls.UseTls(false);
            return;
        }

        tls.UseTls(true).WithTargetHost(_options.Host);

        if (_options.AllowUntrustedServerCertificate)
        {
            tls.WithAllowUntrustedCertificates(true)
                .WithIgnoreCertificateChainErrors(true)
                .WithCertificateValidationHandler(static _ => true);
            return;
        }

        if (string.IsNullOrWhiteSpace(_options.CaCertificatePath))
        {
            return;     // OS trust store.
        }

        var path = _options.CaCertificatePath;
        var roots = new Lazy<X509Certificate2Collection?>(
            () =>
            {
                try
                {
                    var collection = new X509Certificate2Collection();
                    collection.ImportFromPemFile(path);
                    return collection.Count == 0 ? null : collection;
                }
                catch (Exception)
                {
                    return null;
                }
            },
            LazyThreadSafetyMode.ExecutionAndPublication);

        tls.WithCertificateValidationHandler(args =>
        {
            if (roots.Value is not { Count: > 0 } trusted || args.Certificate is null)
            {
                return false;   // Fail closed, exactly as the driver's own pin does.
            }

            using var chain = new X509Chain();
            chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
            chain.ChainPolicy.CustomTrustStore.AddRange(trusted);
            chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;

            var presented = args.Certificate as X509Certificate2
                ?? X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));

            return chain.Build(presented);
        });
    }

    private async Task DropClientAsync(bool graceful)
    {
        var client = _client;
        _client = null;
        if (client is null)
        {
            return;
        }

        client.ApplicationMessageReceivedAsync -= OnMessageAsync;

        if (graceful)
        {
            try
            {
                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
                await client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false);
            }
            catch (Exception)
            {
                // The broker may already be gone; teardown is best-effort.
            }
        }

        client.Dispose();
    }

    private void Log(string message) => _log?.Invoke($"[{GroupId}/{EdgeNodeId}] {message}");
}
