using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;

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

/// <summary>
///     The MQTT / Sparkplug B driver shell: composes <see cref="MqttConnection"/> (broker session +
///     reconnect supervisor), <see cref="MqttSubscriptionManager"/> (authored table, topic routing,
///     value extraction) and the manager's <see cref="LastValueCache"/> (the push→poll bridge) into
///     the driver capability set the Core consumes.
/// </summary>
/// <remarks>
///     <para>
///         <b>Authored-only discovery.</b> Plain MQTT has no browsable address space — a broker will
///         happily carry thousands of topics this deployment has nothing to do with.
///         <see cref="DiscoverAsync"/> therefore replays exactly the raw tags the deploy artifact
///         authored (<see cref="MqttDriverOptions.RawTags"/>) and nothing else, and
///         <see cref="ITagDiscovery.SupportsOnlineDiscovery"/> stays <see langword="false"/> so the
///         universal browser never treats broker traffic as a tag catalogue. In
///         <see cref="MqttMode.Plain"/> the policy is <see cref="DiscoveryRediscoverPolicy.Once"/>:
///         the authored set — and every authored tag's datatype — is fully known synchronously, so
///         re-running discovery on a timer can only produce the same tree.
///     </para>
///     <para>
///         <b>Two ingest paths, one driver.</b> <see cref="MqttMode.Plain"/> routes through
///         <see cref="MqttSubscriptionManager"/> (authored topics); <see cref="MqttMode.SparkplugB"/>
///         routes through <see cref="SparkplugIngestor"/> (one group-wide filter, birth certificates,
///         alias resolution, seq gaps, rebirth NCMDs). Exactly one is live, selected by
///         <see cref="MqttDriverOptions.Mode"/> and rebuilt whenever
///         <see cref="ReinitializeAsync"/> changes anything either captures. They share the
///         driver-owned <see cref="LastValueCache"/>, so <c>IReadable</c> reads one store either way.
///     </para>
///     <para>
///         <b><see cref="OnRediscoveryNeeded"/> fires in Sparkplug B only, and only on a real change.</b>
///         A Sparkplug tag's <c>dataType</c> is optional — the birth certificate declares it — so the
///         discovered tree fills in asynchronously and the policy is
///         <see cref="DiscoveryRediscoverPolicy.UntilStable"/>. <see cref="SparkplugIngestor.BirthObserved"/>
///         is wired to <see cref="RaiseRediscoveryNeeded"/> through
///         <see cref="OnBirthObserved"/>, which fires <b>only</b> when the birth's metric-name SET
///         differs from the last one seen for that scope (and only for scopes an authored tag actually
///         binds). That gate is the anti-storm mechanism, not an optimisation: rediscovery triggers an
///         address-space rebuild, edge nodes re-birth freely — on their own timer, on every reconnect
///         via the late-join rebirth, and once per NCMD the ingestor's gap policy sends — and firing on
///         each of those would turn a healthy plant into a permanent rebuild loop. Plain mode never
///         raises it at all; its authored set changes only by redeploy.
///     </para>
///     <para>
///         <b>Composition order is load-bearing.</b> <see cref="MqttSubscriptionManager.AttachTo"/>
///         must run <i>before</i> any subscribe and before the connect completes — it is what wires
///         message delivery and, critically, the reconnect re-subscribe. The connection's
///         <c>Reconnected</c> handler is passed through unwrapped: the manager throws when it can
///         re-establish nothing, and that throw is precisely what tears the session down and retries.
///         Swallowing it would leave a driver that reports Connected and receives nothing.
///     </para>
///     <para>
///         <b>Constructor is connection-free.</b> It maps the authored TagConfig blobs (pure, no I/O)
///         so discovery and reads work off a configured-but-not-yet-connected driver; every network
///         operation happens in <see cref="InitializeAsync"/>.
///     </para>
/// </remarks>
public sealed class MqttDriver
    : IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable, IAsyncDisposable, IDisposable
{
    /// <summary>
    ///     Rough per-tag driver-attributable cost: the parsed <see cref="MqttTagDefinition"/> (five
    ///     strings — RawPath, topic, JSONPath and the record header) plus its cached
    ///     <see cref="DataValueSnapshot"/> and the two dictionary entries that index it. An estimate
    ///     by construction — <c>GetMemoryFootprint</c> exists to drive a cache-budget decision, not
    ///     to be exact.
    /// </summary>
    private const long ApproxBytesPerAuthoredTag = 512;

    /// <summary>
    ///     The single folder <see cref="DiscoverAsync"/> streams every variable under — and therefore
    ///     the only subtree path a rediscovery <c>ScopeHint</c> can name truthfully. See
    ///     <see cref="OnBirthObserved"/>.
    /// </summary>
    private const string DiscoveryFolderName = "Mqtt";

    /// <summary>How often the host-connectivity poll samples <see cref="MqttConnection.State"/>.</summary>
    private static readonly TimeSpan HostProbeInterval = TimeSpan.FromSeconds(1);

    private readonly string _driverInstanceId;
    private readonly ILogger? _logger;

    /// <summary>
    ///     Owned by the driver, not by the manager, so a manager rebuilt for changed ingest settings
    ///     inherits the observed values instead of silently regressing every node to
    ///     <c>BadWaitingForInitialData</c>.
    /// </summary>
    private readonly LastValueCache _values = new();

    private readonly object _hostLock = new();

    /// <summary>
    ///     Per authored Sparkplug scope, the signature of the metric-name set its most recent birth
    ///     declared — the rediscovery change gate's memory. Written on MQTTnet's dispatcher thread and
    ///     read nowhere else, but concurrent because a reconnect's birth flood and the dispatcher are
    ///     not guaranteed to be the same thread forever.
    /// </summary>
    /// <remarks>
    ///     <b>Deliberately NOT cleared on a death, a reconnect or an ingest rebuild.</b> The ingestor
    ///     drops its whole <see cref="SparkplugIngestor.Births"/> cache on every reconnect (an alias may
    ///     have been rebound while the driver was away) — but "the driver forgot" is not "the address
    ///     space changed". Clearing this alongside it would make every reconnect's late-join rebirth
    ///     flood look like a tree full of brand-new scopes and fire one rediscovery per authored scope
    ///     on every single flap, which is the storm this map exists to prevent. It survives so that an
    ///     identical rebirth stays silent.
    /// </remarks>
    private readonly ConcurrentDictionary<SparkplugScope, string> _birthSignatures = new();

    /// <summary>Guards <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> / <see cref="ShutdownAsync"/> against each other.</summary>
    private readonly SemaphoreSlim _lifecycleGate = new(1, 1);

    // _options / _subscriptions / _authoredRawPaths are written only under _lifecycleGate (or in the
    // ctor) and read freely elsewhere, WITHOUT the Volatile discipline _health / _hostState get.
    // That is deliberate, not an oversight: all three are reference/interface fields, so a reader
    // sees either the whole old object or the whole new one — never a torn one. The looser fields
    // are the ones a reader may legitimately observe one publish stale (a read served from the
    // previous authored table is a correct read of a value that was correct a microsecond ago);
    // _health and _hostState feed ServiceLevel and the status dashboard, where a stale per-core copy
    // is an operator-visible lie about whether the driver is up.
    private MqttDriverOptions _options;
    private MqttSubscriptionManager _subscriptions;

    /// <summary>
    ///     The Sparkplug B ingest path, or <see langword="null"/> in <see cref="MqttMode.Plain"/>.
    ///     Exactly one of this and <see cref="_subscriptions"/> is fed authored tags and attached to the
    ///     connection; the other exists but is never registered against, so it can neither route a
    ///     message nor answer a resolve.
    /// </summary>
    private SparkplugIngestor? _sparkplug;

    /// <summary>
    ///     RawPaths of the currently registered authored tags, in authoring order — the discovery
    ///     enumeration set. The manager resolves a RawPath to its definition but does not enumerate,
    ///     and enumerating from here is what makes "authored only" structural rather than incidental.
    /// </summary>
    private IReadOnlyList<string> _authoredRawPaths = [];

    /// <summary>
    ///     The Sparkplug scopes at least one authored tag binds — empty outside
    ///     <see cref="MqttMode.SparkplugB"/>. Rebuilt wholesale by <see cref="RegisterAuthoredTags"/>
    ///     alongside the authored table it is derived from.
    /// </summary>
    /// <remarks>
    ///     The rediscovery gate's first question. The ingestor applies a birth for <i>any</i> device
    ///     under an authored edge node (it has to — a DBIRTH is a sequenced member of that node's
    ///     stream), so on a large plant it observes births for devices this deployment never reads. Such
    ///     a birth cannot change <see cref="DiscoverAsync"/>'s output by construction, because discovery
    ///     replays the authored set — so firing rediscovery for it would rebuild the address space in
    ///     response to traffic the configuration has no opinion about. Gating on this set also bounds
    ///     <see cref="_birthSignatures"/> by <i>configuration</i> rather than by whatever the plant
    ///     happens to publish — the same rule the ingestor's own authored-edge-node filter follows.
    /// </remarks>
    private FrozenSet<SparkplugScope> _authoredScopes = FrozenSet<SparkplugScope>.Empty;

    private MqttConnection? _connection;
    private CancellationTokenSource? _hostProbeCts;

    private DriverHealth _health = new(DriverState.Unknown, null, null);
    private HostState _hostState = HostState.Unknown;
    private DateTime _hostStateChangedUtc = DateTime.UtcNow;
    private int _disposed;

    /// <summary>Initializes a new driver. Stores + maps configuration only — no network, no client.</summary>
    /// <param name="options">
    ///     Driver configuration, including the authored <see cref="MqttDriverOptions.RawTags"/>.
    ///     Task 9's factory deserializes it from the <c>DriverConfig</c> JSON; a later
    ///     <see cref="ReinitializeAsync"/> may replace it from a fresh config blob.
    /// </param>
    /// <param name="driverInstanceId">Stable logical id of this driver instance.</param>
    /// <param name="logger">Optional logger; never receives credentials or payload bodies.</param>
    public MqttDriver(MqttDriverOptions options, string driverInstanceId, ILogger<MqttDriver>? logger = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);

        _options = options;
        _driverInstanceId = driverInstanceId;
        _logger = logger;
        _subscriptions = CreateSubscriptionManager(options);
        _sparkplug = CreateSparkplugIngestor(options);
        RegisterAuthoredTags(options);
    }

    /// <inheritdoc />
    public event EventHandler<DataChangeEventArgs>? OnDataChange;

    /// <inheritdoc />
    public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;

    /// <inheritdoc />
    public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;

    /// <inheritdoc />
    public string DriverInstanceId => _driverInstanceId;

    /// <inheritdoc />
    public string DriverType => DriverTypeNames.Mqtt;

    /// <summary>
    ///     The ingest path this driver composes. Internal: the P2 Sparkplug handler feeds the same
    ///     <c>OnDataChange</c> + <see cref="LastValueCache"/> sinks through it, and the shell tests
    ///     drive <see cref="MqttSubscriptionManager.HandleMessage"/> to simulate broker traffic
    ///     without a broker.
    /// </summary>
    internal MqttSubscriptionManager Subscriptions => _subscriptions;

    /// <summary>
    ///     The Sparkplug B ingest path, or <see langword="null"/> outside
    ///     <see cref="MqttMode.SparkplugB"/>. Internal: the shell tests drive
    ///     <see cref="SparkplugIngestor.Dispatch"/> to simulate an edge node without a broker, and
    ///     Tasks 22/23 read its <see cref="SparkplugIngestor.Births"/>.
    /// </summary>
    internal SparkplugIngestor? Sparkplug => _sparkplug;

    /// <summary>Whether this driver instance ingests Sparkplug B rather than plain topics.</summary>
    private bool IsSparkplug => _options.Mode == MqttMode.SparkplugB;

    /// <summary>
    ///     Test seam: awaited inside <see cref="InitializeCoreAsync"/> <b>while the lifecycle gate is
    ///     held</b>, immediately before the broker connect. Lets a test park a lifecycle operation
    ///     mid-flight and prove <see cref="DisposeAsync"/> serializes behind it rather than racing
    ///     past a half-built session. Mirrors <see cref="MqttConnection"/>'s own
    ///     <c>AfterConnectHookForTests</c>, which exists for the same class of race. Always
    ///     <see langword="null"/> in production.
    /// </summary>
    internal Func<CancellationToken, Task>? BeforeConnectHookForTests { get; set; }

    // ---- IDriver: lifecycle ----

    /// <summary>
    ///     Adopts <paramref name="driverConfigJson"/> (falling back to the constructor's options when
    ///     it is blank or unusable), registers the authored tags, and opens the broker session.
    /// </summary>
    /// <remarks>
    ///     Ordering is the contract: register → attach → connect. Attaching before the connect
    ///     completes means the reconnect re-subscribe is wired for the very first drop, and the
    ///     manager's SUBSCRIBE transport is live before the OPC UA server's first subscribe arrives.
    /// </remarks>
    /// <param name="driverConfigJson">The driver configuration as JSON.</param>
    /// <param name="cancellationToken">Cancellation for the connect.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            _lifecycleGate.Release();
        }
    }

    /// <summary>
    ///     Applies a config delta in place. A tag-only delta re-registers the authored table without
    ///     touching the broker session; a delta that changes how the driver connects or ingests
    ///     rebuilds the session.
    /// </summary>
    /// <remarks>
    ///     <b>A bad delta never faults the driver.</b> Unparseable or unusable config is logged and
    ///     discarded, leaving the running configuration exactly as it was — a redeploy carrying one
    ///     malformed driver blob must not take a healthy driver's whole address space Bad. A genuine
    ///     connect failure against a <i>changed</i> endpoint is a different thing and does fault,
    ///     exactly as <see cref="InitializeAsync"/> would.
    /// </remarks>
    /// <param name="driverConfigJson">The driver configuration as JSON.</param>
    /// <param name="cancellationToken">Cancellation for any reconnect the delta forces.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        MqttDriverOptions? next;
        try
        {
            next = ParseOptions(driverConfigJson);
        }
        catch (Exception ex)
        {
            // Defence in depth: ParseOptions already swallows the JSON failure modes.
            _logger?.LogWarning(
                ex,
                "MQTT driver '{DriverId}': reinitialize config could not be read; keeping the running configuration.",
                _driverInstanceId);
            return;
        }

        if (next is null)
        {
            _logger?.LogWarning(
                "MQTT driver '{DriverId}': reinitialize config was blank or unusable; keeping the running configuration.",
                _driverInstanceId);
            return;
        }

        await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (!SameSession(next, _options))
            {
                // Broker endpoint / credentials / ingest shape changed — the live session cannot be
                // reused. Full rebuild; a failure here is a real fault, not a bad delta.
                _logger?.LogInformation(
                    "MQTT driver '{DriverId}': reinitialize changes the broker session; rebuilding.",
                    _driverInstanceId);
                await TeardownAsync().ConfigureAwait(false);
                await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
                return;
            }

            _options = next;
            RegisterAuthoredTags(next);
            _logger?.LogInformation(
                "MQTT driver '{DriverId}': reinitialize applied in place; {TagCount} authored tag(s).",
                _driverInstanceId,
                _authoredRawPaths.Count);
        }
        finally
        {
            _lifecycleGate.Release();
        }
    }

    /// <inheritdoc />
    public async Task ShutdownAsync(CancellationToken cancellationToken)
    {
        var lastMessage = ReadHealth().LastSuccessfulRead;
        if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken)
                .ConfigureAwait(false))
        {
            try
            {
                await TeardownAsync().ConfigureAwait(false);
            }
            finally
            {
                _lifecycleGate.Release();
            }
        }
        else
        {
            // A wedged initialize must not turn shutdown into a hang; tear the session down anyway.
            _logger?.LogWarning(
                "MQTT driver '{DriverId}': lifecycle gate still held at shutdown; tearing the session down anyway.",
                _driverInstanceId);
            await TeardownAsync().ConfigureAwait(false);
        }

        WriteHealth(new DriverHealth(DriverState.Unknown, lastMessage, null));
        TransitionHost(HostState.Unknown);
    }

    /// <summary>
    ///     Current health. Derived live from the connection's own state where one exists, so a
    ///     background reconnect is visible without the driver having to mirror every transition.
    ///     <c>LastSuccessfulRead</c> carries the last inbound message instant — MQTT never polls, so
    ///     "last message age" is the only evidence the session is actually delivering.
    /// </summary>
    /// <returns>The driver's current health snapshot.</returns>
    public DriverHealth GetHealth()
    {
        var stored = ReadHealth();
        var connection = _connection;
        if (connection is null)
        {
            return stored;
        }

        var lastMessage = connection.LastMessageUtc ?? stored.LastSuccessfulRead;
        return connection.State switch
        {
            MqttConnectionState.Connected => new DriverHealth(DriverState.Healthy, lastMessage, stored.LastError),
            MqttConnectionState.Reconnecting => new DriverHealth(DriverState.Reconnecting, lastMessage, stored.LastError),
            MqttConnectionState.Faulted => new DriverHealth(DriverState.Faulted, lastMessage, stored.LastError),
            // Disconnected before the first successful connect, or Disposed after teardown: the
            // stored snapshot (Initializing / Faulted / Unknown) is the more informative answer.
            _ => stored with { LastSuccessfulRead = lastMessage },
        };
    }

    /// <summary>
    ///     Approximate driver-attributable footprint: the authored definition table and the
    ///     last-value cache that indexes it. Both are sized by the authored tag count, which is the
    ///     only thing a deployment can grow.
    /// </summary>
    /// <returns>The approximate memory footprint in bytes.</returns>
    public long GetMemoryFootprint() => _authoredRawPaths.Count * ApproxBytesPerAuthoredTag;

    /// <summary>
    ///     No-op, deliberately. This driver holds no optional cache: the authored table is the
    ///     address space itself, and the last-value cache <b>is</b> <see cref="IReadable"/> — MQTT is
    ///     subscribe-first, so a dropped value is not re-fetchable, and flushing it would turn every
    ///     node Bad until its topic next published, which for a slow-changing tag can be never.
    ///     Birth/alias state (Sparkplug, P2) is correctness state for the same reason.
    /// </summary>
    /// <param name="cancellationToken">Unused; present for the <see cref="IDriver"/> shape.</param>
    /// <returns>A completed task.</returns>
    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    // ---- ITagDiscovery ----

    /// <inheritdoc />
    /// <remarks>
    ///     <para>
    ///         Plain mode discovers synchronously from the authored set — the RawPaths <i>and</i> their
    ///         datatypes are both in the deploy artifact — so one pass is all there is.
    ///     </para>
    ///     <para>
    ///         Sparkplug B is <see cref="DiscoveryRediscoverPolicy.UntilStable"/> because half of what
    ///         it discovers arrives on the wire: <c>dataType</c> is optional in the Sparkplug tag shape
    ///         (the birth certificate declares it), so a tag authored with nothing but its
    ///         <c>(group, node, device, metric)</c> tuple can only be streamed with the record's default
    ///         type until its NBIRTH/DBIRTH lands. The host's <c>UntilStable</c> contract — re-run
    ///         discovery until the captured set is non-empty and its signature repeats — is exactly the
    ///         retry that lets those passes catch a birth that had not yet arrived at connect.
    ///     </para>
    ///     <para>
    ///         <b>The retry is a floor, not the mechanism.</b> The host's stability signature is built
    ///         from FullReferences alone, so a pass whose only change is a filled-in datatype still
    ///         looks "stable" and the loop stops. That is why the same fill-in also fires
    ///         <see cref="OnRediscoveryNeeded"/> (see <see cref="OnBirthObserved"/>): a birth arriving
    ///         after the loop settled is the case the timer cannot cover.
    ///     </para>
    /// </remarks>
    public DiscoveryRediscoverPolicy RediscoverPolicy =>
        IsSparkplug ? DiscoveryRediscoverPolicy.UntilStable : DiscoveryRediscoverPolicy.Once;

    /// <inheritdoc />
    /// <remarks>
    ///     Always <see langword="false"/>: <see cref="DiscoverAsync"/> replays authored tags, it does
    ///     not enumerate a backend. A broker's live topic set is not a tag catalogue — the
    ///     <c>Driver.Mqtt.Browser</c> project is the surface for observing traffic during authoring.
    /// </remarks>
    public bool SupportsOnlineDiscovery => false;

    /// <summary>
    ///     Streams the authored tag set — and only that — into <paramref name="builder"/>.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         The enumeration source is the driver's own list of registered RawPaths, never anything
    ///         observed on the wire, so no amount of broker traffic can add a node. A RawPath whose
    ///         TagConfig failed to map is absent from the manager and is skipped here too — it surfaces
    ///         as <c>BadNodeIdUnknown</c> on read rather than as a wrongly-typed variable.
    ///     </para>
    ///     <para>
    ///         <b>Sparkplug B: the datatype is resolved per pass, the tag set never is.</b> An authored
    ///         tag is streamed on <i>every</i> pass, birth or no birth — it is part of the declared
    ///         configuration, not something the plant grants by publishing, and hiding it until a birth
    ///         arrives would make the node's existence depend on wire traffic (and, on a deployment
    ///         whose other tags do carry an authored type, would hide it behind a "non-empty and stable"
    ///         verdict the host reaches without it). What a birth changes is the <i>type</i>: see
    ///         <see cref="ResolveDiscoveredDataType"/>.
    ///     </para>
    /// </remarks>
    /// <param name="builder">The address space builder to stream discovered nodes into.</param>
    /// <param name="cancellationToken">Cancellation for the discovery operation.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        cancellationToken.ThrowIfCancellationRequested();

        var folder = builder.Folder(DiscoveryFolderName, DiscoveryFolderName);
        foreach (var rawPath in _authoredRawPaths)
        {
            if (!TryResolve(rawPath, out var def))
            {
                continue;
            }

            folder.Variable(def.Name, def.Name, new DriverAttributeInfo(
                FullName: def.Name,
                DriverDataType: ResolveDiscoveredDataType(def),
                IsArray: false,
                ArrayDim: null,
                // MQTT ingest is one-way in P1 — this driver implements no IWritable leg, so every
                // node is ViewOnly rather than an Operate node whose writes would silently vanish.
                SecurityClass: SecurityClassification.ViewOnly,
                IsHistorized: false,
                IsAlarm: false,
                WriteIdempotent: false));
        }

        return Task.CompletedTask;
    }

    /// <summary>
    ///     The datatype <see cref="DiscoverAsync"/> reports for one authored tag: the authored type when
    ///     the operator declared one, otherwise the type the metric's live birth certificate declared,
    ///     otherwise the record's default.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         <b>The precedence is the ingest path's, restated.</b> <c>SparkplugIngestor.PublishMetric</c>
    ///         coerces every value with <c>DataTypeAuthored ? authored : birth</c>; a discovery surface
    ///         that disagreed with it would advertise a type no value ever arrives as. The two must be
    ///         changed together.
    ///     </para>
    ///     <para>
    ///         An unsupported Sparkplug type (DataSet / Template / PropertySet / Unknown) maps to
    ///         <see langword="null"/> and falls back rather than blanking the tag — the ingest path
    ///         already refuses those metrics once, loudly, and a discovery pass is not the place to
    ///         relitigate it.
    ///     </para>
    /// </remarks>
    /// <param name="def">The authored tag definition.</param>
    /// <returns>The effective data type for this pass.</returns>
    private DriverDataType ResolveDiscoveredDataType(MqttTagDefinition def)
    {
        if (def.DataTypeAuthored || _sparkplug is not { } sparkplug)
        {
            return def.DataType;
        }

        if (def.GroupId is not { } groupId || def.EdgeNodeId is not { } edgeNodeId || def.MetricName is not { } metric)
        {
            return def.DataType;
        }

        var table = sparkplug.Births.Find(new SparkplugScope(groupId, edgeNodeId, def.DeviceId));
        return table?.ResolveByName(metric)?.DataType.ToDriverDataType() ?? def.DataType;
    }

    // ---- ISubscribable (straight through to the manager) ----

    /// <inheritdoc />
    public Task<ISubscriptionHandle> SubscribeAsync(
        IReadOnlyList<string> fullReferences,
        TimeSpan publishingInterval,
        CancellationToken cancellationToken)
        => _sparkplug is { } sparkplug
            ? sparkplug.SubscribeAsync(fullReferences, publishingInterval, cancellationToken)
            : _subscriptions.SubscribeAsync(fullReferences, publishingInterval, cancellationToken);

    /// <inheritdoc />
    public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
        => _sparkplug is { } sparkplug
            ? sparkplug.UnsubscribeAsync(handle, cancellationToken)
            : _subscriptions.UnsubscribeAsync(handle, cancellationToken);

    /// <summary>Resolves a RawPath through whichever ingest path this driver's mode selected.</summary>
    /// <param name="rawPath">The driver wire reference.</param>
    /// <param name="def">The definition when this returns <see langword="true"/>.</param>
    /// <returns><see langword="true"/> when the reference is an authored tag of the live ingest path.</returns>
    private bool TryResolve(string rawPath, out MqttTagDefinition def)
        => _sparkplug is { } sparkplug
            ? sparkplug.TryResolve(rawPath, out def)
            : _subscriptions.TryResolve(rawPath, out def);

    // ---- IReadable ----

    /// <summary>
    ///     Serves the batch from the last-value cache. MQTT is push-based: there is nothing to poll,
    ///     so a read is always "what was most recently published".
    /// </summary>
    /// <remarks>
    ///     <b>Never throws.</b> A reference that has not been observed — including one that is not an
    ///     authored tag at all — returns <c>BadWaitingForInitialData</c> in its own slot. Failing the
    ///     whole call would let one stale reference blank every other node in the same OPC UA read.
    /// </remarks>
    /// <param name="fullReferences">The RawPath references to read.</param>
    /// <param name="cancellationToken">Unused; the read touches no network.</param>
    /// <returns>One snapshot per requested reference, in request order.</returns>
    public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
        IReadOnlyList<string> fullReferences,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(fullReferences);

        var results = new DataValueSnapshot[fullReferences.Count];
        for (var i = 0; i < fullReferences.Count; i++)
        {
            // The driver-owned cache, not the live ingest path's: both are handed this same instance,
            // and reading it directly means a mid-flight ingest rebuild can never blank a read.
            results[i] = _values.Read(fullReferences[i]);
        }

        return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
    }

    // ---- IHostConnectivityProbe ----

    /// <summary>The single broker this driver instance talks to, as <c>host:port</c>.</summary>
    public string HostName => $"{_options.Host}:{_options.Port}";

    /// <inheritdoc />
    public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
    {
        lock (_hostLock)
        {
            return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
        }
    }

    // ---- IRediscoverable ----

    /// <summary>
    ///     Raises <see cref="OnRediscoveryNeeded"/>. Nothing in <see cref="MqttMode.Plain"/> calls it
    ///     — the authored set only changes by redeploy. It exists for the Sparkplug B path, where a
    ///     DBIRTH can introduce metrics the previous birth certificate did not carry.
    /// </summary>
    /// <param name="reason">Driver-supplied reason for the diagnostic log.</param>
    /// <param name="scopeHint">Optional subtree hint; <see langword="null"/> means the whole tree.</param>
    internal void RaiseRediscoveryNeeded(string reason, string? scopeHint = null)
        => OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, scopeHint));

    /// <summary>
    ///     The rediscovery decision: a birth changed what <see cref="DiscoverAsync"/> can produce only
    ///     when it is for an <b>authored</b> scope <b>and</b> its metric-name set differs from the last
    ///     one seen for that scope. Anything else is dropped.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         <b>Both conditions are load-bearing, and both are anti-storm.</b> Rediscovery triggers an
    ///         address-space rebuild — expensive and disruptive on a running server. An edge node
    ///         re-births freely: on its own schedule, on every reconnect (the ingestor's late-join
    ///         rebirth fan-out), and once per rebirth NCMD its gap policy sends. Firing on each of those
    ///         is a permanent rebuild loop against a plant that is behaving perfectly. Firing on
    ///         <i>changed metric set only</i> is the primary defence; the authored-scope filter is the
    ///         second, and removes the whole class of churn sourced from devices this deployment does
    ///         not read (see <see cref="_authoredScopes"/>).
    ///     </para>
    ///     <para>
    ///         <b>The signature is order-insensitive</b> — ordered-distinct, ordinal. Nothing obliges an
    ///         edge node to keep its birth's metric ordering stable, and a reordered birth describes the
    ///         same address space; comparing the raw sequence would leak the storm back in.
    ///     </para>
    ///     <para>
    ///         <b>No additional debounce or coalescing.</b> One was considered and rejected: the change
    ///         gate already collapses the unbounded sources (rebirth timers, reconnect floods, NCMD
    ///         answers) to zero, and what survives it is a genuine metric-set change — of which a given
    ///         scope has a handful in its lifetime, each one a real edit to the served tree. A timer on
    ///         top would only delay a rebuild that must happen, and would need its own trailing-edge
    ///         flush to avoid dropping the last change entirely. If a consumer ever needs coalescing
    ///         across many scopes birthing at once (a whole-plant restart), that belongs in the consumer,
    ///         which alone knows what a rebuild costs it — today there is no such consumer at all.
    ///     </para>
    ///     <para>
    ///         <b>Runs on MQTTnet's dispatcher thread</b> (via the ingestor's own contained raise), so it
    ///         does no I/O and never throws — <see cref="SparkplugIngestor"/> catches a throwing
    ///         subscriber, but relying on that would still have cost the rest of the birth's fan-out.
    ///     </para>
    /// </remarks>
    private void OnBirthObserved(object? sender, SparkplugBirthObservedEventArgs e)
    {
        if (!_authoredScopes.Contains(e.Scope))
        {
            // A birth for a scope no authored tag binds cannot change the authored-only discovery tree.
            return;
        }

        var signature = BirthSignature(e.MetricNames);
        if (_birthSignatures.TryGetValue(e.Scope, out var previous)
            && string.Equals(previous, signature, StringComparison.Ordinal))
        {
            return; // Same metric set as last time — nothing to rediscover.
        }

        _birthSignatures[e.Scope] = signature;

        var reason = previous is null
            ? $"Sparkplug birth observed for '{e.Scope}' ({e.MetricNames.Count} metric(s))"
            : $"Sparkplug rebirth changed the metric set for '{e.Scope}' ({e.MetricNames.Count} metric(s))";

        _logger?.LogInformation(
            "MQTT driver '{DriverId}': {Reason}; requesting rediscovery.",
            _driverInstanceId,
            reason);

        // ScopeHint names the folder DiscoverAsync actually streams under — deliberately NOT the
        // Sparkplug scope path. The discovered tree is flat (one 'Mqtt' folder of RawPath-named
        // variables); it has no edge-node/device folders, so a hint of 'Plant1/EdgeA/Filler1' would
        // name a subtree that does not exist and a consumer scoping a rebuild on it would rebuild
        // nothing. The scope belongs in the Reason, which is the documented diagnostic field.
        RaiseRediscoveryNeeded(reason, DiscoveryFolderName);
    }

    /// <summary>An order-insensitive, ordinal signature of a birth's metric-name set.</summary>
    /// <param name="metricNames">The names the birth declared.</param>
    /// <returns>The comparable signature.</returns>
    private static string BirthSignature(IReadOnlyList<string> metricNames)
        => string.Join(
            '\u0001', // A separator no Sparkplug metric name can contain, so two sets cannot alias.
            metricNames.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal));

    // ---- disposal ----

    /// <inheritdoc />
    public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();

    /// <summary>
    ///     Performs the same teardown as <see cref="ShutdownAsync"/>, <b>under the same lifecycle
    ///     gate</b>, so a caller that uses <c>await using</c> without an explicit shutdown does not
    ///     leak a live broker session.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         <b>The gate wait is the whole point, not ceremony.</b> An ungated dispose that ran
    ///         while <see cref="ReinitializeAsync"/>'s rebuild branch held the gate would observe
    ///         <c>_connection == null</c> — the rebuild has torn the old session down but not yet
    ///         assigned the new one — do nothing, and set <c>_disposed</c>. The rebuild would then
    ///         complete, assign a <i>live, connected</i> connection and start a host-probe loop that
    ///         no later dispose can ever reach, because the disposed guard short-circuits them all.
    ///         That is the orphaned-connection class <see cref="MqttConnection"/>'s own remarks
    ///         describe having already fixed once. Waiting for the gate makes this dispose tear down
    ///         whatever the in-flight operation ends up assigning.
    ///     </para>
    ///     <para>
    ///         <c>_disposed</c> is set <b>before</b> the wait, so an in-flight
    ///         <see cref="InitializeCoreAsync"/> sees it at its post-connect re-check and refuses to
    ///         publish the connection at all — which is what closes the residual window on the
    ///         bounded-timeout fallback path below.
    ///     </para>
    ///     <para>
    ///         Separately (and for an unrelated reason): the gate object itself is deliberately never
    ///         <c>Dispose()</c>d — same call as <see cref="MqttConnection"/>'s semaphores. A caller
    ///         that disposes before its last <see cref="ShutdownAsync"/> would otherwise get an
    ///         <see cref="ObjectDisposedException"/> naming <see cref="SemaphoreSlim"/>, which says
    ///         nothing useful; the gate holds no timer and no surviving registration, so leaving it
    ///         undisposed costs nothing.
    ///     </para>
    /// </remarks>
    /// <returns>A task that represents the asynchronous dispose.</returns>
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
        {
            return;
        }

        if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds))
                .ConfigureAwait(false))
        {
            try
            {
                await TeardownAsync().ConfigureAwait(false);
            }
            finally
            {
                _lifecycleGate.Release();
            }

            return;
        }

        // Bounded even here: a wedged initialize must not turn dispose into a hang. The post-connect
        // disposed re-check in InitializeCoreAsync is what stops that operation from publishing a
        // connection behind this teardown's back.
        _logger?.LogWarning(
            "MQTT driver '{DriverId}': lifecycle gate still held at dispose; tearing the session down anyway.",
            _driverInstanceId);
        await TeardownAsync().ConfigureAwait(false);
    }

    // ---- internals ----

    /// <summary>The gate-held body of <see cref="InitializeAsync"/>.</summary>
    private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        WriteHealth(new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
        try
        {
            // A blank / unusable blob keeps the constructor's options rather than degrading the
            // driver to defaults pointing at localhost.
            var parsed = ParseOptions(driverConfigJson);
            if (parsed is not null)
            {
                AdoptOptions(parsed);
            }

            RegisterAuthoredTags(_options);

            var connection = new MqttConnection(_options, _driverInstanceId, _logger);

            // MUST precede the connect: this wires message delivery AND the reconnect re-subscribe.
            // The handler is passed through unwrapped on purpose — its throw-on-total-failure is what
            // tears a deaf session down and retries it.
            if (_sparkplug is { } attaching)
            {
                attaching.AttachTo(connection);
            }
            else
            {
                _subscriptions.AttachTo(connection);
            }

            if (BeforeConnectHookForTests is { } hook)
            {
                await hook(cancellationToken).ConfigureAwait(false);
            }

            await connection.ConnectAsync(cancellationToken).ConfigureAwait(false);

            // Re-check disposal AFTER the connect, before publishing the connection. DisposeAsync
            // normally waits for this gate, but its wait is bounded — on the wedged-gate fallback
            // path it can run concurrently with this method, and assigning a live connection behind
            // a completed teardown is exactly the orphaned-session bug. Losing the race means
            // disposing what we just built, never leaking it.
            if (Volatile.Read(ref _disposed) != 0)
            {
                await connection.DisposeAsync().ConfigureAwait(false);
                throw new ObjectDisposedException(nameof(MqttDriver));
            }

            // Sparkplug subscribes ONCE, here, rather than on the OPC UA server's first subscribe: the
            // driver must see birth certificates whether or not anything is subscribed yet, because a
            // birth is where every alias and datatype comes from. This also issues the late-join
            // rebirth (§3.6 #5) — the initial connect does not fire Reconnected.
            //
            // It runs BEFORE `_connection` is published, and disposes the session it was given if it
            // fails. Publishing first would leave a live, supervised connection behind a throw: the
            // driver-host resilience layer re-runs this method, the retry overwrites `_connection`, and
            // the first session becomes an orphan no later dispose can reach — the exact leak
            // MqttConnection's own remarks describe having already fixed once.
            if (_sparkplug is { } sparkplug)
            {
                try
                {
                    await sparkplug.EstablishAsync(cancellationToken).ConfigureAwait(false);
                }
                catch
                {
                    await connection.DisposeAsync().ConfigureAwait(false);
                    throw;
                }
            }

            _connection = connection;

            WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null));
            TransitionHost(HostState.Running);
            StartHostProbe();
        }
        catch (Exception ex)
        {
            WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message));
            TransitionHost(HostState.Faulted);
            throw;
        }
    }

    /// <summary>
    ///     Deserializes a driver-config blob. Returns <see langword="null"/> — never throws — when the
    ///     blob is blank or unusable, so both the initialize fallback and the reinitialize
    ///     keep-running-config rule read off one answer.
    /// </summary>
    private MqttDriverOptions? ParseOptions(string driverConfigJson)
    {
        if (string.IsNullOrWhiteSpace(driverConfigJson))
        {
            return null;
        }

        try
        {
            // MqttJson.Options — the ONE shared instance (factory / probe / this / browser),
            // deliberately: enums must round-trip by NAME. A second, divergent
            // JsonSerializerOptions is this repo's documented systemic enum bug, where an
            // AdminUI-authored config with a string enum field faults the driver.
            return JsonSerializer.Deserialize<MqttDriverOptions>(driverConfigJson, MqttJson.Options);
        }
        catch (JsonException ex)
        {
            _logger?.LogWarning(
                ex,
                "MQTT driver '{DriverId}': driver config JSON is invalid.",
                _driverInstanceId);
            return null;
        }
        catch (NotSupportedException ex)
        {
            _logger?.LogWarning(
                ex,
                "MQTT driver '{DriverId}': driver config JSON could not be bound to the options shape.",
                _driverInstanceId);
            return null;
        }
    }

    /// <summary>
    ///     Adopts new options, rebuilding the subscription manager only when a setting it captures at
    ///     construction actually changed. The rebuilt manager inherits the driver-owned
    ///     <see cref="LastValueCache"/>, so adopting new options never blanks observed values.
    /// </summary>
    private void AdoptOptions(MqttDriverOptions next)
    {
        if (!SameIngest(next, _options))
        {
            _subscriptions = CreateSubscriptionManager(next);
            _sparkplug = CreateSparkplugIngestor(next);
        }

        _options = next;
    }

    /// <summary>Builds a manager over the driver-owned cache and bridges its notifications to <see cref="OnDataChange"/>.</summary>
    private MqttSubscriptionManager CreateSubscriptionManager(MqttDriverOptions options)
    {
        var manager = new MqttSubscriptionManager(
            options,
            _driverInstanceId,
            transport: null,
            logger: _logger,
            cache: _values,
            maxPayloadBytes: options.MaxPayloadBytes);

        // The published reference is the RawPath the manager already stamped on the args; the driver
        // only re-raises with itself as sender.
        manager.OnDataChange += (_, args) => OnDataChange?.Invoke(this, args);
        return manager;
    }

    /// <summary>
    ///     Builds the Sparkplug B ingest path for <paramref name="options"/>, or <see langword="null"/>
    ///     outside <see cref="MqttMode.SparkplugB"/>. Shares the driver-owned
    ///     <see cref="LastValueCache"/> for the same reason the plain manager does: a rebuild for
    ///     changed settings must not blank every observed value.
    /// </summary>
    private SparkplugIngestor? CreateSparkplugIngestor(MqttDriverOptions options)
    {
        if (options.Mode != MqttMode.SparkplugB)
        {
            return null;
        }

        if (options.Sparkplug is { ActAsPrimaryHost: true })
        {
            // Loud, because the flag reads as if it does something. Being a Sparkplug Primary Host is a
            // three-part contract — a retained ONLINE after CONNECT, a matching retained OFFLINE
            // registered as the MQTT Last Will AT CONNECT TIME, and an explicit OFFLINE on clean
            // shutdown — and edge nodes stop publishing while their host is offline. Shipping the
            // ONLINE half without the Will is strictly worse than shipping neither: a crashed node
            // would leave a retained ONLINE asserting forever that a dead host is alive, which is the
            // exact state the mechanism exists to prevent. The Will belongs in
            // MqttConnection.BuildClientOptions; until it is there this flag stays inert and said so.
            _logger?.LogWarning(
                "MQTT driver '{DriverId}': Sparkplug actAsPrimaryHost is set but primary-host STATE "
                + "publishing is not implemented — this driver observes STATE and never publishes one. "
                + "Edge nodes gated on this host id will not see it come online.",
                _driverInstanceId);
        }

        var ingestor = new SparkplugIngestor(
            options,
            _driverInstanceId,
            subscribeTransport: null,
            publishTransport: null,
            logger: _logger,
            cache: _values,
            maxPayloadBytes: options.MaxPayloadBytes);

        // The published reference is the RawPath the ingestor already stamped on the args; the driver
        // only re-raises with itself as sender.
        ingestor.OnDataChange += (_, args) => OnDataChange?.Invoke(this, args);

        // The ingestor detects a birth; the driver decides whether it changed the address space. Wired
        // here rather than in InitializeAsync so a driver rebuilt for a config delta re-arms it — an
        // ingest rebuild that silently dropped this subscription would leave a Sparkplug driver whose
        // datatypes fill in and whose rediscovery never fires again, with nothing to show for it.
        ingestor.BirthObserved += OnBirthObserved;
        return ingestor;
    }

    /// <summary>Registers the authored raw tags wholesale and refreshes the discovery enumeration set.</summary>
    private void RegisterAuthoredTags(MqttDriverOptions options)
    {
        // Wholesale into the LIVE path only. Feeding Sparkplug tags to the plain parser would log a
        // "did not map" warning for every one of them (they carry no topic) and vice versa.
        var mapped = _sparkplug is { } sparkplug
            ? sparkplug.Register(options.RawTags)
            : _subscriptions.Register(options.RawTags);

        // Enumerate in authoring order; the manager owns which of these actually mapped, and
        // DiscoverAsync skips the rest.
        _authoredRawPaths = [.. options.RawTags.Select(t => t.RawPath)];
        _authoredScopes = BuildAuthoredScopes();

        // Drop change-gate memory for scopes this deploy stopped authoring — the same housekeeping
        // SparkplugIngestor.Register does for its trackers, and for the same reason: without it a scope
        // nobody reads keeps a slot across every future redeploy. Re-authoring such a scope later means
        // its next birth reads as new and fires once, which is correct — a newly authored scope IS a
        // change to the discovered tree.
        foreach (var scope in _birthSignatures.Keys)
        {
            if (!_authoredScopes.Contains(scope))
            {
                _birthSignatures.TryRemove(scope, out _);
            }
        }

        if (mapped != options.RawTags.Count)
        {
            _logger?.LogWarning(
                "MQTT driver '{DriverId}': {Mapped} of {Authored} authored tag(s) mapped to a definition; "
                + "the rest will report BadNodeIdUnknown.",
                _driverInstanceId,
                mapped,
                options.RawTags.Count);
        }
    }

    /// <summary>
    ///     The Sparkplug scopes the currently registered authored tags bind, derived from the same
    ///     definitions <see cref="DiscoverAsync"/> enumerates — empty outside
    ///     <see cref="MqttMode.SparkplugB"/>, and empty for any tag whose TagConfig did not map.
    /// </summary>
    /// <returns>The authored scope set.</returns>
    private FrozenSet<SparkplugScope> BuildAuthoredScopes()
    {
        if (_sparkplug is null)
        {
            return FrozenSet<SparkplugScope>.Empty;
        }

        var scopes = new HashSet<SparkplugScope>();
        foreach (var rawPath in _authoredRawPaths)
        {
            if (TryResolve(rawPath, out var def)
                && def.GroupId is { } groupId
                && def.EdgeNodeId is { } edgeNodeId)
            {
                scopes.Add(new SparkplugScope(groupId, edgeNodeId, def.DeviceId));
            }
        }

        return scopes.ToFrozenSet();
    }

    /// <summary>
    ///     Whether two option sets describe the same broker session. Note the explicit
    ///     <see cref="object.Equals(object?)"/>: the identity projections are <c>object</c>-typed
    ///     anonymous instances, so <c>!=</c> would compare <b>references</b> and report "changed"
    ///     every single time — which would tear down and rebuild a perfectly good session on every
    ///     redeploy, and (via <see cref="SameIngest"/>) swap in a subscription manager that is not
    ///     attached to the live connection.
    /// </summary>
    private static bool SameSession(MqttDriverOptions a, MqttDriverOptions b)
        => SessionIdentity(a).Equals(SessionIdentity(b));

    /// <summary>
    ///     Whether two option sets produce an identical ingest path — <see cref="MqttSubscriptionManager"/>
    ///     in Plain mode, <see cref="SparkplugIngestor"/> in Sparkplug B.
    /// </summary>
    /// <remarks>
    ///     ⚠️ <b>Every setting an ingest path captures at construction MUST be named by
    ///     <see cref="IngestIdentity"/>.</b> A setting that is captured but unnamed makes a delta
    ///     changing it compare "same ingest", get applied in place, and silently never reach the
    ///     component that needed rebuilding — the driver would keep subscribing to the OLD Sparkplug
    ///     group id and report perfect health. Task 21 closed the Sparkplug half of that gap by naming
    ///     the whole <see cref="MqttSparkplugOptions"/> record (it has value equality, so a member
    ///     added there is covered automatically — which is the point of naming the record rather than
    ///     enumerating its fields).
    /// </remarks>
    private static bool SameIngest(MqttDriverOptions a, MqttDriverOptions b)
        => IngestIdentity(a).Equals(IngestIdentity(b));

    /// <summary>
    ///     The settings whose change invalidates the live broker session. Projected onto an anonymous
    ///     type (structural <c>Equals</c>) rather than comparing the whole options record, whose
    ///     <c>RawTags</c> list would make every comparison unequal by reference.
    /// </summary>
    private static object SessionIdentity(MqttDriverOptions o) => new
    {
        o.Host,
        o.Port,
        o.ClientId,
        o.UseTls,
        o.AllowUntrustedServerCertificate,
        o.CaCertificatePath,
        o.Username,
        o.Password,
        o.ProtocolVersion,
        o.CleanSession,
        o.KeepAliveSeconds,
        o.ConnectTimeoutSeconds,
        o.ReconnectMinBackoffSeconds,
        o.ReconnectMaxBackoffSeconds,
        Ingest = IngestIdentity(o),
    };

    /// <summary>The settings the live ingest path captures at construction.</summary>
    /// <remarks>
    ///     <see cref="MqttSparkplugOptions"/> is named as a whole record rather than field-by-field: it
    ///     has structural value equality, so a member added to it in a later task is covered here with
    ///     no edit — and the failure mode of forgetting one is silent (see <see cref="SameIngest"/>).
    /// </remarks>
    private static object IngestIdentity(MqttDriverOptions o) => new
    {
        o.Mode,
        DefaultQos = o.Plain?.DefaultQos,
        o.MaxPayloadBytes,
        o.Sparkplug,
    };

    /// <summary>Shared teardown for <see cref="ShutdownAsync"/>, <see cref="DisposeAsync"/> and the session rebuild.</summary>
    private async Task TeardownAsync()
    {
        var probe = Interlocked.Exchange(ref _hostProbeCts, null);
        if (probe is not null)
        {
            await probe.CancelAsync().ConfigureAwait(false);
            probe.Dispose();
        }

        var connection = Interlocked.Exchange(ref _connection, null);
        if (connection is not null)
        {
            await connection.DisposeAsync().ConfigureAwait(false);
        }
    }

    /// <summary>
    ///     Starts the host-connectivity poll. <see cref="MqttConnection"/> exposes state but no
    ///     state-changed event, and an <see cref="IHostConnectivityProbe"/> whose event never fires is
    ///     a capability that looks wired and is inert — so the transition signal is sampled here.
    /// </summary>
    private void StartHostProbe()
    {
        var cts = new CancellationTokenSource();
        var previous = Interlocked.Exchange(ref _hostProbeCts, cts);
        if (previous is not null)
        {
            // Cancel before disposing: disposing a live source leaves its loop running against a
            // token that will never be cancelled, and a second probe would fight the first.
            previous.Cancel();
            previous.Dispose();
        }

        _ = Task.Run(() => HostProbeLoopAsync(cts.Token), cts.Token);
    }

    private async Task HostProbeLoopAsync(CancellationToken ct)
    {
        try
        {
            while (!ct.IsCancellationRequested)
            {
                await Task.Delay(HostProbeInterval, ct).ConfigureAwait(false);

                var connection = _connection;
                if (connection is null)
                {
                    continue;
                }

                TransitionHost(connection.State switch
                {
                    MqttConnectionState.Connected => HostState.Running,
                    MqttConnectionState.Reconnecting or MqttConnectionState.Disconnected => HostState.Stopped,
                    MqttConnectionState.Faulted => HostState.Faulted,
                    _ => HostState.Unknown,
                });
            }
        }
        catch (OperationCanceledException)
        {
            // Shutdown.
        }
        catch (Exception ex)
        {
            _logger?.LogWarning(ex, "MQTT driver '{DriverId}': host connectivity probe stopped.", _driverInstanceId);
        }
    }

    /// <summary>Publishes a host state transition once; repeats of the current state are ignored.</summary>
    private void TransitionHost(HostState next)
    {
        HostState previous;
        lock (_hostLock)
        {
            if (_hostState == next)
            {
                return;
            }

            previous = _hostState;
            _hostState = next;
            _hostStateChangedUtc = DateTime.UtcNow;
        }

        OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, previous, next));
    }

    /// <summary>Barrier-protected read of the multi-threaded health field.</summary>
    private DriverHealth ReadHealth() => Volatile.Read(ref _health);

    /// <summary>Barrier-protected publish of a new health snapshot.</summary>
    private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
}
