using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
///     <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
///     driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
///     <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
/// </summary>
/// <remarks>
///     Scope limits: Historian + alarm capabilities are out of scope (the protocol doesn't
///     express them). Subscriptions overlay a polling loop via the shared
///     <see cref="PollGroupEngine"/> since Modbus has no native push model.
/// </remarks>
public sealed class ModbusDriver
    : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
    // ---- instance fields (grouped at top for auditability) ----

    /// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
    /// (#516). Only ever written on the init path, before any reader/transport uses it.</summary>
    private ModbusDriverOptions _options;
    private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
    private readonly string _driverInstanceId;
    private readonly ILogger<ModbusDriver> _logger;

    // Polled subscriptions delegate to the shared PollGroupEngine. The driver only supplies
    // the reader + on-change bridge; the engine owns the loop, interval floor, and lifecycle.
    private readonly PollGroupEngine _poll;

    // v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
    // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
    private readonly Dictionary<string, ModbusTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);

    // Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
    // authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
    private readonly EquipmentTagRefResolver<ModbusTagDefinition> _resolver;

    // Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
    // the deadband filter. Stored as object so all numeric types share one map; the comparison
    // does a typed cast inside.
    private readonly ConcurrentDictionary<string, object> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);

    // Last-written value per tag for the WriteOnChangeOnly suppression. Invalidated by reads
    // that return a different value (so an HMI-side change doesn't get masked).
    private readonly Dictionary<string, object?> _lastWrittenByRef = new(StringComparer.OrdinalIgnoreCase);
    private readonly object _lastWrittenLock = new();

    // BitInRegister writes need a read-modify-write against the full holding register. A
    // per-register lock keeps concurrent bit-write callers from stomping on each other.
    private readonly ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();

    private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
    private readonly object _autoProhibitedLock = new();

    // Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
    // collection has at most one entry. HostName is the Host:Port string so the Admin UI can
    // display the PLC endpoint uniformly with Galaxy platforms/engines.
    private readonly object _probeLock = new();
    private HostState _hostState = HostState.Unknown;
    private DateTime _hostStateChangedUtc = DateTime.UtcNow;

    private IModbusTransport? _transport;
    private CancellationTokenSource? _probeCts;
    private CancellationTokenSource? _reprobeCts;

    private DriverHealth _health = new(DriverState.Unknown, null, null);

    /// <summary>Occurs when a subscribed tag value changes.</summary>
    public event EventHandler<DataChangeEventArgs>? OnDataChange;
    /// <summary>Occurs when host connectivity status changes.</summary>
    public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;

    // ---- nested types ----

    /// <summary>
    ///     Per-prohibition state. <c>SplitPending</c> drives the re-probe loop's
    ///     bisection: when true and the range spans &gt; 1 register, the next re-probe
    ///     tries the two halves separately to narrow the actual offending register(s).
    ///     Single-register prohibitions can't be split further; they stay re-probed as-is.
    /// </summary>
    private sealed class ProhibitionState
    {
        public DateTime LastProbedUtc;
        public bool SplitPending;
    }

    // ---- ctor + identity ----

    /// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
    /// <param name="options">Driver configuration options.</param>
    /// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
    /// <param name="transportFactory">Factory to create the Modbus transport; defaults to
    /// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
    /// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
    public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
        Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
        ILogger<ModbusDriver>? logger = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        _options = options;
        _driverInstanceId = driverInstanceId;
        _logger = logger ?? NullLogger<ModbusDriver>.Instance;
        _resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
            r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
        _transportFactory = transportFactory
            ?? (o => ModbusTransportFactory.Create(o));
        _poll = new PollGroupEngine(
            reader: ReadAsync,
            onChange: (handle, tagRef, snapshot) =>
            {
                if (!ShouldPublish(tagRef, snapshot)) return;
                OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
            },
            onError: HandlePollError,
            backoffCap: PollBackoffCap);
    }

    /// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
    private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);

    /// <summary>
    ///     05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
    ///     degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
    ///     Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
    /// </summary>
    /// <param name="ex">The exception caught by the poll engine.</param>
    internal void HandlePollError(Exception ex)
    {
        _logger.LogWarning(ex, "Modbus poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
        var current = ReadHealth();
        if (current.State != DriverState.Faulted)
            WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
    }

    /// <summary>
    ///     Resolve a reference to the per-slave host key (<c>host:port/unitN</c>) that keys its
    ///     per-host resilience (bulkhead / circuit breaker). CONV-4: routes through
    ///     <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
    ///     JSON) keys its OWN authored unit — via the tag's optional <c>unitId</c> or the driver
    ///     default — rather than the single-slave fallback. The resolver caches parses, so this adds
    ///     no per-call cost after first use.
    /// </summary>
    /// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
    /// <returns>The per-slave host key.</returns>
    public string ResolveHost(string fullReference)
    {
        if (_resolver.TryResolve(fullReference, out var tag))
            return BuildSlaveHostName(ResolveUnitId(tag));
        // Unknown reference — fall back to driver-instance host (single-slave behaviour).
        return HostName;
    }

    /// <summary>Format a per-slave host string. Multi-slave deployments distinguish breakers by this string.</summary>
    private string BuildSlaveHostName(byte unitId) => $"{_options.Host}:{_options.Port}/unit{unitId}";

    private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
    {
        if (!_resolver.TryResolve(tagRef, out var tag) || tag.Deadband is null) return true;
        if (snapshot.Value is null) return true;
        // Deadband only applies to numeric scalar types — array / Bool / String publishes
        // unconditionally. Easier to special-case skip than to enumerate the supported types.
        if (tag.ArrayCount.HasValue || tag.DataType is ModbusDataType.Bool or ModbusDataType.BitInRegister or ModbusDataType.String)
            return true;

        if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev))
        {
            // First sample passes through unconditionally — the threshold can't be evaluated
            // without a baseline. The publish lands and seeds the comparison.
            _lastPublishedByRef[tagRef] = snapshot.Value;
            return true;
        }

        var newD = Convert.ToDouble(snapshot.Value);
        var oldD = Convert.ToDouble(prev);
        if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;

        _lastPublishedByRef[tagRef] = snapshot.Value;
        return true;
    }

    /// <inheritdoc />
    public string DriverInstanceId => _driverInstanceId;
    /// <inheritdoc />
    public string DriverType => "Modbus";

    /// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
    /// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
    /// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
    private static bool HasConfigBody(string? driverConfigJson)
    {
        if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
        var trimmed = driverConfigJson.Trim();
        return trimmed is not "{}" and not "[]";
    }

    /// <inheritdoc />
    public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
        try
        {
            // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
            // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
            // with, so an operator's edit is discarded while the deployment still seals green. An empty /
            // placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options.
            if (HasConfigBody(driverConfigJson))
                _options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);

            _transport = _transportFactory(_options);
            await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
            // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
            // blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
            // def (it lives on the RawTagEntry, not inside the blob). A mapper miss is skipped (logged),
            // never thrown — a bad tag must not fail the whole driver init.
            foreach (var entry in _options.RawTags)
            {
                if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
                    _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
                else
                    _logger.LogWarning(
                        "Modbus tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
                        _driverInstanceId, entry.RawPath);
            }
            WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));

            // PR 23: kick off the probe loop once the transport is up. Initial state stays
            // Unknown until the first probe tick succeeds — avoids broadcasting a premature
            // Running transition before any register round-trip has happened.
            if (_options.Probe.Enabled)
            {
                _probeCts = new CancellationTokenSource();
                _ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
            }

            if (_options.AutoProhibitReprobeInterval is not null)
            {
                _reprobeCts = new CancellationTokenSource();
                _ = Task.Run(() => ReprobeLoopAsync(_reprobeCts.Token), _reprobeCts.Token);
            }
        }
        catch (Exception ex)
        {
            WriteHealth(new DriverHealth(DriverState.Faulted, null, ex.Message));
            throw;
        }
    }

    /// <inheritdoc />
    public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        await ShutdownAsync(cancellationToken);
        await InitializeAsync(driverConfigJson, cancellationToken);
    }

    /// <inheritdoc />
    public async Task ShutdownAsync(CancellationToken cancellationToken)
    {
        var lastRead = ReadHealth().LastSuccessfulRead;
        await TeardownAsync().ConfigureAwait(false);
        WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
    }

    /// <inheritdoc />
    public DriverHealth GetHealth() => ReadHealth();

    /// <summary>
    ///     Barrier-protected read of the multi-thread <c>_health</c> field.
    ///     <c>Volatile.Read</c> guarantees <c>GetHealth()</c> and the in-driver self-reads (the
    ///     Degraded paths that retain <c>LastSuccessfulRead</c>) observe the most recently
    ///     published snapshot rather than a per-core cached stale copy.
    /// </summary>
    private DriverHealth ReadHealth() => Volatile.Read(ref _health);

    /// <summary>
    ///     Barrier-protected publish of a new <c>_health</c> snapshot.
    /// </summary>
    private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
    /// <inheritdoc />
    public long GetMemoryFootprint() => 0;
    /// <inheritdoc />
    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    // ---- ITagDiscovery ----

    /// <inheritdoc />
    public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;

    /// <inheritdoc />
    public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        var folder = builder.Folder("Modbus", "Modbus");
        // Surface the authored raw tags (mapped into the RawPath → def table at Initialize). The def's
        // Name is the RawPath, used for both the browse name and the driver FullName.
        foreach (var t in _tagsByRawPath.Values)
        {
            folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
                FullName: t.Name,
                DriverDataType: MapDataType(t.DataType),
                IsArray: t.ArrayCount.HasValue,
                ArrayDim: t.ArrayCount.HasValue ? (uint)t.ArrayCount.Value : null,
                SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
                IsHistorized: false,
                IsAlarm: false,
                WriteIdempotent: t.WriteIdempotent));
        }
        return Task.CompletedTask;
    }

    // ---- IReadable ----

    /// <inheritdoc />
    public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
        IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
    {
        var transport = RequireTransport();
        var now = DateTime.UtcNow;
        var results = new DataValueSnapshot[fullReferences.Count];

        var coalesced = _options.MaxReadGap > 0
            ? await ReadCoalescedAsync(transport, fullReferences, results, now, cancellationToken).ConfigureAwait(false)
            : new HashSet<int>();

        for (var i = 0; i < fullReferences.Count; i++)
        {
            if (coalesced.Contains(i)) continue;
            if (!_resolver.TryResolve(fullReferences[i], out var tag))
            {
                results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
                continue;
            }
            try
            {
                var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
                results[i] = new DataValueSnapshot(value, 0u, now, now);
                WriteHealth(new DriverHealth(DriverState.Healthy, now, null));

                // Invalidate the WriteOnChangeOnly cache when the read returns a different value
                // — typically an HMI-side or PLC-internal change. Without this, a setpoint
                // tweaked at the panel could be silently re-suppressed when our client tried
                // to restore it.
                if (_options.WriteOnChangeOnly)
                {
                    lock (_lastWrittenLock)
                    {
                        if (_lastWrittenByRef.TryGetValue(fullReferences[i], out var prev) && !Equals(prev, value))
                            _lastWrittenByRef.Remove(fullReferences[i]);
                    }
                }
            }
            catch (ModbusException mex)
            {
                results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
                WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
            }
            catch (Exception ex)
            {
                // Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
                // as communication error so callers can distinguish it from tag-level faults.
                results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
                WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
            }
        }
        return results;
    }

    private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
    {
        var arrayCount = tag.ArrayCount ?? 1;

        switch (tag.Region)
        {
            case ModbusRegion.Coils:
            case ModbusRegion.DiscreteInputs:
            {
                // FC01 (Coils) / FC02 (DiscreteInputs). Auto-chunk when array count exceeds the
                // coil cap — Modbus spec says ≤ 2000 bits per request; some devices cap lower
                // (we trust the caller-provided MaxCoilsPerRead).
                var fc = tag.Region == ModbusRegion.Coils ? (byte)0x01 : (byte)0x02;
                var cap = _options.MaxCoilsPerRead == 0 ? (ushort)2000 : _options.MaxCoilsPerRead;
                var unitId = ResolveUnitId(tag);
                var bitmap = arrayCount <= cap
                    ? await ReadBitBlockAsync(transport, unitId, fc, tag.Address, (ushort)arrayCount, ct).ConfigureAwait(false)
                    : await ReadBitBlockChunkedAsync(transport, unitId, fc, tag.Address, arrayCount, cap, ct).ConfigureAwait(false);
                return DecodeBitArray(bitmap, arrayCount, tag.ArrayCount.HasValue);
            }
            case ModbusRegion.HoldingRegisters:
            case ModbusRegion.InputRegisters:
            {
                var elementRegs = RegisterCount(tag);
                var totalRegs = (ushort)(elementRegs * arrayCount);
                var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
                // Auto-chunk when the tag's register span exceeds the caller-configured cap.
                // Affects long strings + arrays (FC03/04 > 125 regs is spec-forbidden; DL205 caps
                // at 128, Mitsubishi Q caps at 64). Scalar non-string tags max out at 4 regs so
                // the cap never triggers for them.
                var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
                var unitId = ResolveUnitId(tag);
                var data = totalRegs <= cap
                    ? await ReadRegisterBlockAsync(transport, unitId, fc, tag.Address, totalRegs, ct).ConfigureAwait(false)
                    : await ReadRegisterBlockChunkedAsync(transport, unitId, fc, tag.Address, totalRegs, cap, ct).ConfigureAwait(false);

                if (!tag.ArrayCount.HasValue)
                    return DecodeRegister(data, tag);

                return DecodeRegisterArray(data, tag, elementRegs, arrayCount);
            }
            default:
                throw new InvalidOperationException($"Unknown region {tag.Region}");
        }
    }

    /// <summary>
    ///     Decode an FC01/FC02 coil-bitmap response into either a single bool (scalar tag) or a
    ///     bool[] of <paramref name="count"/> elements (array tag). Modbus packs coils LSB-first
    ///     within each byte, ascending address across bytes.
    /// </summary>
    private static object DecodeBitArray(ReadOnlySpan<byte> bitmap, int count, bool isArray)
    {
        if (bitmap.IsEmpty)
            throw new InvalidDataException("Modbus bit response produced an empty bitmap — cannot decode coil value");
        if (!isArray) return (bitmap[0] & 0x01) == 1;
        var result = new bool[count];
        for (var i = 0; i < count; i++)
            result[i] = ((bitmap[i / 8] >> (i % 8)) & 0x01) == 1;
        return result;
    }

    /// <summary>
    ///     Decode an array of register-backed values from a contiguous block. Each element
    ///     occupies <paramref name="elementRegs"/> registers and is decoded with the same
    ///     codec the scalar path uses, sliced from its position in the block.
    /// </summary>
    private static object DecodeRegisterArray(byte[] data, ModbusTagDefinition tag, int elementRegs, int count)
    {
        var elementBytes = elementRegs * 2;
        // Element type drives the array CLR type. Boxed into Array so the Read pipeline can
        // surface it directly without a per-call type-switch on the caller side.
        switch (tag.DataType)
        {
            case ModbusDataType.Int16:
            {
                var arr = new short[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (short)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.UInt16:
            {
                var arr = new ushort[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (ushort)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.Int32:
            case ModbusDataType.Bcd16:
            case ModbusDataType.Bcd32:
            {
                var arr = new int[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (int)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.UInt32:
            {
                var arr = new uint[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (uint)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.Int64:
            {
                var arr = new long[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (long)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.UInt64:
            {
                var arr = new ulong[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (ulong)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.Float32:
            {
                var arr = new float[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (float)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.Float64:
            {
                var arr = new double[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (double)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.String:
            {
                // Each element occupies elementRegs registers = elementBytes bytes, encoding
                // StringLength chars packed high-byte-first or low-byte-first per tag.StringByteOrder.
                // DecodeRegister handles the per-element slice identically to the scalar path.
                var arr = new string[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (string)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            case ModbusDataType.BitInRegister:
            {
                // Each element is 1 holding register (2 bytes). The same BitIndex is applied to
                // every element. DecodeRegister returns bool for BitInRegister scalar slices.
                var arr = new bool[count];
                for (var i = 0; i < count; i++)
                    arr[i] = (bool)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
                return arr;
            }
            default:
                throw new InvalidOperationException(
                    $"Array decode not supported for {tag.DataType} (use scalar tags or split by element)");
        }
    }

    /// <summary>Resolve the UnitId for a tag — per-tag override or driver-level fallback.</summary>
    private byte ResolveUnitId(ModbusTagDefinition tag) => tag.UnitId ?? _options.UnitId;

    private bool RangeIsAutoProhibited(byte unit, ModbusRegion region, ushort start, ushort end)
    {
        lock (_autoProhibitedLock)
        {
            foreach (var p in _autoProhibited.Keys)
            {
                // A candidate (start..end) range is prohibited if it overlaps any recorded
                // failure. Overlap rule: max-start ≤ min-end. We don't try to be smart about
                // partial overlap — once a range fails, any superset of it is also untrusted.
                if (p.Unit != unit || p.Region != region) continue;
                if (Math.Max(start, p.Start) <= Math.Min(end, p.End)) return true;
            }
            return false;
        }
    }

    private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end)
    {
        bool isNew;
        lock (_autoProhibitedLock)
        {
            // Multi-register prohibitions enter the bisection workflow on the next re-probe;
            // single-register prohibitions are already minimal and skip bisection.
            isNew = !_autoProhibited.ContainsKey((unit, region, start, end));
            _autoProhibited[(unit, region, start, end)] = new ProhibitionState
            {
                LastProbedUtc = DateTime.UtcNow,
                SplitPending = end > start,
            };
        }

        if (isNew)
            _logger.LogWarning(
                "Modbus coalesced read failed; auto-prohibited range recorded. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End} Span={Span}",
                _driverInstanceId, unit, region, start, end, end - start + 1);
    }

    /// <summary>
    ///     Info log when a re-probe clears a prohibition. Operators see recovery
    ///     events without having to poll <see cref="GetAutoProhibitedRanges"/>.
    /// </summary>
    private void LogProhibitionCleared(byte unit, ModbusRegion region, ushort start, ushort end) =>
        _logger.LogInformation(
            "Modbus auto-prohibition cleared by re-probe. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End}",
            _driverInstanceId, unit, region, start, end);

    /// <summary>
    ///     Operator-visible snapshot of every range the planner has learned to read
    ///     individually. Exposed through the driver-diagnostics surface; consumers (Admin UI,
    ///     log-aggregation, dashboards) call this to show what's been auto-isolated. Populated
    ///     on coalesced-read failure, narrowed by bisection, cleared by the
    ///     re-probe loop when ranges become healthy again.
    /// </summary>
    /// <returns>The current set of auto-prohibited register ranges.</returns>
    public IReadOnlyList<ModbusAutoProhibition> GetAutoProhibitedRanges()
    {
        lock (_autoProhibitedLock)
            return _autoProhibited
                .Select(kv => new ModbusAutoProhibition(
                    kv.Key.Unit, kv.Key.Region, kv.Key.Start, kv.Key.End,
                    kv.Value.LastProbedUtc, kv.Value.SplitPending))
                .ToArray();
    }

    /// <summary>Test/diagnostic accessor — returns the current auto-prohibited range count.</summary>
    internal int AutoProhibitedRangeCount
    {
        get { lock (_autoProhibitedLock) return _autoProhibited.Count; }
    }

    /// <summary>
    ///     Periodic re-probe loop, augmented with bisection-style narrowing.
    ///     Each tick processes every prohibition: split-pending multi-register ranges get
    ///     bisected (try left + right halves; replace with whichever halves still fail),
    ///     single-register or non-split-pending ranges get a straight re-probe. Lives for
    ///     the driver lifetime; cancelled by <c>ShutdownAsync</c>.
    /// </summary>
    private async Task ReprobeLoopAsync(CancellationToken ct)
    {
        var interval = _options.AutoProhibitReprobeInterval!.Value;
        while (!ct.IsCancellationRequested)
        {
            try { await Task.Delay(interval, ct).ConfigureAwait(false); }
            catch (OperationCanceledException) { return; }
            try { await RunReprobeOnceForTestAsync(ct).ConfigureAwait(false); }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
            catch (ObjectDisposedException) when (ct.IsCancellationRequested)
            {
                return;
            }
        }
    }

    /// <summary>
    ///     One re-probe pass. Public-but-internal so tests can drive it synchronously rather
    ///     than wait on the background timer. Iterates a snapshot of the prohibition set; for
    ///     each entry decides between bisection (multi-register + SplitPending) or straight
    ///     retry (single-register or already-narrowed).
    /// </summary>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    internal async Task RunReprobeOnceForTestAsync(CancellationToken ct)
    {
        var transport = _transport ?? throw new InvalidOperationException("Transport not connected");

        ((byte Unit, ModbusRegion Region, ushort Start, ushort End) Key, bool SplitPending)[] candidates;
        lock (_autoProhibitedLock)
            candidates = _autoProhibited
                .Select(kv => (Key: kv.Key, SplitPending: kv.Value.SplitPending))
                .ToArray();

        foreach (var (key, splitPending) in candidates)
        {
            if (ct.IsCancellationRequested) return;
            if (splitPending && key.End > key.Start)
                await BisectAndReprobeAsync(transport, key, ct).ConfigureAwait(false);
            else
                await StraightReprobeAsync(transport, key, ct).ConfigureAwait(false);
        }
    }

    private async Task StraightReprobeAsync(IModbusTransport transport,
        (byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
    {
        var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
        var qty = (ushort)(key.End - key.Start + 1);
        try
        {
            _ = await ReadRegisterBlockAsync(transport, key.Unit, fc, key.Start, qty, ct).ConfigureAwait(false);
            lock (_autoProhibitedLock) _autoProhibited.Remove(key);
            LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
        catch
        {
            lock (_autoProhibitedLock)
                if (_autoProhibited.TryGetValue(key, out var st)) st.LastProbedUtc = DateTime.UtcNow;
        }
    }

    /// <summary>
    ///     Bisect a multi-register prohibition. Removes the parent entry and re-adds
    ///     whichever halves still fail. Over multiple re-probe ticks the prohibition narrows
    ///     log2(span) times until it pinpoints the actual protected register(s).
    /// </summary>
    private async Task BisectAndReprobeAsync(IModbusTransport transport,
        (byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
    {
        var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
        var mid = (ushort)((key.Start + key.End) / 2);
        var leftEnd = mid;
        var rightStart = (ushort)(mid + 1);

        var leftFailed = await ProbeFailsAsync(transport, fc, key.Unit, key.Start, leftEnd, ct).ConfigureAwait(false);
        var rightFailed = await ProbeFailsAsync(transport, fc, key.Unit, rightStart, key.End, ct).ConfigureAwait(false);

        lock (_autoProhibitedLock)
        {
            _autoProhibited.Remove(key);
            if (leftFailed)
            {
                _autoProhibited[(key.Unit, key.Region, key.Start, leftEnd)] = new ProhibitionState
                {
                    LastProbedUtc = DateTime.UtcNow,
                    SplitPending = leftEnd > key.Start,
                };
            }
            if (rightFailed)
            {
                _autoProhibited[(key.Unit, key.Region, rightStart, key.End)] = new ProhibitionState
                {
                    LastProbedUtc = DateTime.UtcNow,
                    SplitPending = key.End > rightStart,
                };
            }
            // Both halves succeeded → entry is just removed. The parent prohibition is gone
            // and the next normal scan can re-coalesce across the whole original range.
        }

        if (!leftFailed && !rightFailed)
            LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
        else
        {
            if (!leftFailed)
                LogProhibitionCleared(key.Unit, key.Region, key.Start, leftEnd);
            if (!rightFailed)
                LogProhibitionCleared(key.Unit, key.Region, rightStart, key.End);
        }
    }

    private async Task<bool> ProbeFailsAsync(IModbusTransport transport, byte fc, byte unit,
        ushort start, ushort end, CancellationToken ct)
    {
        var qty = (ushort)(end - start + 1);
        try
        {
            _ = await ReadRegisterBlockAsync(transport, unit, fc, start, qty, ct).ConfigureAwait(false);
            return false;
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
        catch { return true; }
    }

    /// <summary>
    ///     Block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts
    ///     by start address, and merges adjacent / near-adjacent (gap ≤ MaxReadGap) into single
    ///     FC03/FC04 reads. Per-block: emit one Modbus PDU, slice the response back into per-tag
    ///     values, populate <paramref name="results"/> and the WriteOnChangeOnly cache. Returns
    ///     the set of <paramref name="fullReferences"/> indices the planner handled — the
    ///     caller falls back to the per-tag path for the rest (arrays, coils, prohibited, unknown).
    /// </summary>
    private async Task<HashSet<int>> ReadCoalescedAsync(
        IModbusTransport transport,
        IReadOnlyList<string> fullReferences,
        DataValueSnapshot[] results,
        DateTime timestamp,
        CancellationToken ct)
    {
        // Eligible: known tag, register region (HoldingRegisters or InputRegisters), scalar
        // (no array, no string), not CoalesceProhibited, not BitInRegister.
        var eligible = new List<(int Index, string Ref, ModbusTagDefinition Tag)>();
        for (var i = 0; i < fullReferences.Count; i++)
        {
            if (!_resolver.TryResolve(fullReferences[i], out var tag)) continue;
            if (tag.CoalesceProhibited) continue;
            if (tag.ArrayCount.HasValue) continue;
            if (tag.Region is not (ModbusRegion.HoldingRegisters or ModbusRegion.InputRegisters)) continue;
            if (tag.DataType is ModbusDataType.String or ModbusDataType.BitInRegister) continue;
            eligible.Add((i, fullReferences[i], tag));
        }
        if (eligible.Count == 0) return new HashSet<int>();

        var handled = new HashSet<int>();

        // Group by (UnitId, Region) — coalescing across slaves or regions is unsafe.
        foreach (var group in eligible.GroupBy(e => (Unit: ResolveUnitId(e.Tag), e.Tag.Region)))
        {
            var fc = group.Key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
            var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
            var sorted = group.OrderBy(e => e.Tag.Address).ToList();

            // Build merged blocks. A "block" is (start, lastEnd, members[]) where lastEnd is
            // the inclusive end address of the last tag's register span. A new tag joins the
            // block if its start ≤ lastEnd + 1 + MaxReadGap AND the resulting span ≤ cap.
            var blocks = new List<(ushort Start, ushort End, List<(int Index, ModbusTagDefinition Tag)> Members)>();
            foreach (var (idx, _, tag) in sorted)
            {
                var tagStart = tag.Address;
                var tagEnd = (ushort)(tag.Address + RegisterCount(tag) - 1);
                if (blocks.Count > 0)
                {
                    var last = blocks[^1];
                    var gap = tagStart - last.End - 1;
                    var newEnd = Math.Max(tagEnd, last.End);
                    var newSpan = newEnd - last.Start + 1;
                    var crossesProhibition = RangeIsAutoProhibited(group.Key.Unit, group.Key.Region, last.Start, (ushort)newEnd);
                    if (gap <= _options.MaxReadGap && newSpan <= cap && !crossesProhibition)
                    {
                        last.Members.Add((idx, tag));
                        blocks[^1] = (last.Start, (ushort)newEnd, last.Members);
                        continue;
                    }
                }
                blocks.Add((tagStart, tagEnd, new List<(int, ModbusTagDefinition)> { (idx, tag) }));
            }

            // Issue one PDU per block. On a Modbus-level exception (illegal data address /
            // protected register), record the range as auto-prohibited, leave the
            // member indices UNhandled, and let the per-tag fallback in ReadAsync read each
            // surviving address individually. On transport-level failure (timeout / socket
            // drop) mark members Bad and short-circuit the per-tag fallback (hitting the
            // dead socket again won't help). Bisection narrows the prohibition over
            // subsequent re-probe ticks.
            foreach (var block in blocks)
            {
                if (block.Members.Count == 1)
                {
                    // Lone tag — let the per-tag path handle it for symmetry with WriteOnChange
                    // cache invalidation. Costs nothing because one tag = one PDU either way.
                    continue;
                }

                var qty = (ushort)(block.End - block.Start + 1);
                try
                {
                    var data = await ReadRegisterBlockAsync(transport, group.Key.Unit, fc, block.Start, qty, ct).ConfigureAwait(false);
                    foreach (var (idx, tag) in block.Members)
                    {
                        var sliceOffsetBytes = (tag.Address - block.Start) * 2;
                        var sliceLenBytes = RegisterCount(tag) * 2;
                        var value = DecodeRegister(data.AsSpan(sliceOffsetBytes, sliceLenBytes), tag);
                        results[idx] = new DataValueSnapshot(value, 0u, timestamp, timestamp);
                        handled.Add(idx);
                        InvalidateWriteCacheIfDiverged(fullReferences[idx], value);
                    }
                    WriteHealth(new DriverHealth(DriverState.Healthy, timestamp, null));
                }
                catch (ModbusException mex)
                {
                    RecordAutoProhibition(group.Key.Unit, group.Key.Region, block.Start, block.End);
                    WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
                }
                catch (Exception ex)
                {
                    // Communication failures (timeout, socket drop) aren't a structural reason
                    // to prohibit the range — the same coalesced read might succeed once the
                    // transport recovers. Mark members Bad for this scan but don't auto-prohibit
                    // and don't deflect to per-tag fallback (which would just hit the same dead
                    // socket).
                    foreach (var (idx, _) in block.Members)
                    {
                        results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, timestamp);
                        handled.Add(idx);
                    }
                    WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
                }
            }
        }
        return handled;
    }

    private void InvalidateWriteCacheIfDiverged(string fullRef, object value)
    {
        if (!_options.WriteOnChangeOnly) return;
        lock (_lastWrittenLock)
        {
            if (_lastWrittenByRef.TryGetValue(fullRef, out var prev) && !Equals(prev, value))
                _lastWrittenByRef.Remove(fullRef);
        }
    }


    private async Task<byte[]> ReadRegisterBlockAsync(
        IModbusTransport transport, byte unitId, byte fc, ushort address, ushort quantity, CancellationToken ct)
    {
        var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
                               (byte)(quantity >> 8), (byte)(quantity & 0xFF) };
        var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
        // resp = [fc][byte-count][data...] — validate before indexing to surface a clean error
        // rather than an IndexOutOfRangeException when a device returns a truncated PDU.
        if (resp.Length < 2)
            throw new InvalidDataException(
                $"Modbus register response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
        if (resp.Length < 2 + resp[1])
            throw new InvalidDataException(
                $"Modbus register response truncated: byte-count field declares {resp[1]} bytes but only {resp.Length - 2} available");
        var expectedByteCount = quantity * 2;
        if (resp[1] != expectedByteCount)
            throw new InvalidDataException(
                $"Modbus register response byte-count mismatch: requested {quantity} registers ({expectedByteCount} bytes), got {resp[1]} bytes");
        var data = new byte[resp[1]];
        Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
        return data;
    }

    private async Task<byte[]> ReadBitBlockAsync(
        IModbusTransport transport, byte unitId, byte fc, ushort address, ushort qty, CancellationToken ct)
    {
        var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
                               (byte)(qty >> 8), (byte)(qty & 0xFF) };
        var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
        if (resp.Length < 2)
            throw new InvalidDataException(
                $"Modbus bit response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
        if (resp.Length < 2 + resp[1])
            throw new InvalidDataException(
                $"Modbus bit response truncated: byte-count field declares {resp[1]} bytes but only {resp.Length - 2} available");
        var expectedByteCount = (qty + 7) / 8;
        if (resp[1] < expectedByteCount)
            throw new InvalidDataException(
                $"Modbus bit response byte-count mismatch: requested {qty} bits ({expectedByteCount} bytes), got {resp[1]} bytes");
        var bitmap = new byte[resp[1]];
        Buffer.BlockCopy(resp, 2, bitmap, 0, resp[1]);
        return bitmap;
    }

    /// <summary>
    ///     Auto-chunk coil-array reads above MaxCoilsPerRead. Reassembles per-chunk bitmaps into
    ///     one logical bitmap byte array sized for the full <paramref name="totalBits"/>; the
    ///     downstream <see cref="DecodeBitArray"/> walks bits LSB-first the same way it would
    ///     for a single-chunk response.
    /// </summary>
    private async Task<byte[]> ReadBitBlockChunkedAsync(
        IModbusTransport transport, byte unitId, byte fc, ushort address, int totalBits, ushort cap, CancellationToken ct)
    {
        var assembled = new byte[(totalBits + 7) / 8];
        var done = 0;
        while (done < totalBits)
        {
            var chunk = (ushort)Math.Min(cap, totalBits - done);
            var chunkBitmap = await ReadBitBlockAsync(transport, unitId, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
            // Re-pack per-chunk LSB-first bits into the assembled bitmap at the right offset.
            for (var i = 0; i < chunk; i++)
            {
                if (((chunkBitmap[i / 8] >> (i % 8)) & 0x01) == 0) continue;
                var dest = done + i;
                assembled[dest / 8] |= (byte)(1 << (dest % 8));
            }
            done += chunk;
        }
        return assembled;
    }

    private async Task<byte[]> ReadRegisterBlockChunkedAsync(
        IModbusTransport transport, byte unitId, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
    {
        var assembled = new byte[totalRegs * 2];
        ushort done = 0;
        while (done < totalRegs)
        {
            var chunk = (ushort)Math.Min(cap, totalRegs - done);
            var chunkBytes = await ReadRegisterBlockAsync(transport, unitId, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
            Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
            done += chunk;
        }
        return assembled;
    }

    // ---- IWritable ----

    /// <inheritdoc />
    public async Task<IReadOnlyList<WriteResult>> WriteAsync(
        IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
    {
        var transport = RequireTransport();
        var results = new WriteResult[writes.Count];
        for (var i = 0; i < writes.Count; i++)
        {
            var w = writes[i];
            if (!_resolver.TryResolve(w.FullReference, out var tag))
            {
                results[i] = new WriteResult(StatusBadNodeIdUnknown);
                continue;
            }
            if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
            {
                results[i] = new WriteResult(StatusBadNotWritable);
                continue;
            }
            if (_options.WriteOnChangeOnly && IsRedundantWrite(w.FullReference, w.Value))
            {
                results[i] = new WriteResult(0u);
                continue;
            }

            try
            {
                await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
                results[i] = new WriteResult(0u);
                if (_options.WriteOnChangeOnly)
                    lock (_lastWrittenLock) _lastWrittenByRef[w.FullReference] = w.Value;
            }
            catch (ModbusException mex)
            {
                results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
            }
            catch (InvalidDataException)
            {
                results[i] = new WriteResult(StatusBadCommunicationError);
            }
            catch (Exception)
            {
                results[i] = new WriteResult(StatusBadInternalError);
            }
        }
        return results;
    }

    private bool IsRedundantWrite(string tagRef, object? value)
    {
        lock (_lastWrittenLock)
        {
            if (!_lastWrittenByRef.TryGetValue(tagRef, out var prev)) return false;
            // Object.Equals handles boxed-numeric equality (5 == 5 even if one was short and
            // one int through boxing). For arrays we deliberately don't suppress — equality
            // semantics on arrays are reference-only so the cache miss is the safer answer.
            if (prev is null || value is null) return Equals(prev, value);
            if (prev is Array || value is Array) return false;
            return prev.Equals(value);
        }
    }

    // BitInRegister writes need a read-modify-write against the full holding register. The
    // per-register lock (declared at the top of the class) keeps concurrent bit-write callers
    // from stomping on each other — Write bit 0 and Write bit 5 targeting the same register
    // can arrive on separate subscriber threads, and without serialising the RMW the
    // second-to-commit value wins + the first bit update is lost.
    private SemaphoreSlim GetRmwLock(ushort address) =>
        _rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));

    private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
    {
        // BitInRegister → RMW dispatch ahead of the normal encode path so the lock + read-modify-
        // write sequence doesn't hit EncodeRegister's defensive throw.
        if (tag.DataType == ModbusDataType.BitInRegister &&
            tag.Region is ModbusRegion.HoldingRegisters)
        {
            await WriteBitInRegisterAsync(transport, tag, value, ct).ConfigureAwait(false);
            return;
        }

        switch (tag.Region)
        {
            case ModbusRegion.Coils:
            {
                if (!tag.ArrayCount.HasValue && !_options.UseFC15ForSingleCoilWrites)
                {
                    var on = Convert.ToBoolean(value);
                    var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
                                           on ? (byte)0xFF : (byte)0x00, 0x00 };
                    await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
                    return;
                }
                // FC15 path: either an explicit array, or UseFC15ForSingleCoilWrites=true forced
                // it for a scalar (synthesise a 1-element bool[] from the scalar value).
                var arrayLen = tag.ArrayCount ?? 1;
                if (!tag.ArrayCount.HasValue)
                    value = new[] { Convert.ToBoolean(value) };
                // FC15 — Write Multiple Coils. Pack the bool[] into LSB-first bitmap.
                var values = ToBoolArray(value, arrayLen, tag.Name);
                var byteCount = (values.Length + 7) / 8;
                var bitmap = new byte[byteCount];
                for (var i = 0; i < values.Length; i++)
                    if (values[i]) bitmap[i / 8] |= (byte)(1 << (i % 8));
                var qty = (ushort)values.Length;
                var pdu15 = new byte[6 + 1 + byteCount];
                pdu15[0] = 0x0F;
                pdu15[1] = (byte)(tag.Address >> 8); pdu15[2] = (byte)(tag.Address & 0xFF);
                pdu15[3] = (byte)(qty >> 8); pdu15[4] = (byte)(qty & 0xFF);
                pdu15[5] = (byte)byteCount;
                Buffer.BlockCopy(bitmap, 0, pdu15, 6, byteCount);
                await transport.SendAsync(ResolveUnitId(tag), pdu15, ct).ConfigureAwait(false);
                return;
            }
            case ModbusRegion.HoldingRegisters:
            {
                var bytes = tag.ArrayCount.HasValue
                    ? EncodeRegisterArray(value, tag)
                    : EncodeRegister(value, tag);

                if (bytes.Length == 2 && !tag.ArrayCount.HasValue && !_options.UseFC16ForSingleRegisterWrites)
                {
                    // FC06 fast-path for single-register scalar writes only. Arrays always use
                    // FC16 even when the array is one element wide, because the encoder shape
                    // may need it. UseFC16ForSingleRegisterWrites=true forces FC16 even here for
                    // PLCs that only accept the multi-write codes.
                    var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
                                           bytes[0], bytes[1] };
                    await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
                }
                else
                {
                    // FC 16 (Write Multiple Registers) for 32-bit / 64-bit / array / string types.
                    var qty = (ushort)(bytes.Length / 2);
                    var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
                    if (qty > writeCap)
                        throw new InvalidOperationException(
                            $"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
                            $"Split the tag (e.g. shorter StringLength or smaller ArrayCount) — partial FC16 chunks would lose atomicity.");
                    var pdu = new byte[6 + 1 + bytes.Length];
                    pdu[0] = 0x10;
                    pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
                    pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
                    pdu[5] = (byte)bytes.Length;
                    Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
                    await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
                }
                return;
            }
            default:
                throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
        }
    }

    /// <summary>
    ///     Encode an array-typed write value into a contiguous byte block by encoding each
    ///     element with the scalar codec. Caller submits IList / Array of the element CLR type.
    /// </summary>
    private static byte[] EncodeRegisterArray(object? value, ModbusTagDefinition tag)
    {
        var count = tag.ArrayCount!.Value;
        if (value is not System.Collections.IList list || list.Count != count)
            throw new InvalidOperationException(
                $"Array write to {tag.Name} expects an IList of length {count}; got {value?.GetType().Name ?? "null"}");

        var elementBytes = ElementByteCount(tag);
        var result = new byte[count * elementBytes];
        for (var i = 0; i < count; i++)
        {
            var element = list[i];
            var encoded = EncodeRegister(element, tag);
            if (encoded.Length != elementBytes)
                throw new InvalidOperationException(
                    $"Encoder returned {encoded.Length} bytes for element {i} of {tag.Name}, expected {elementBytes}");
            Buffer.BlockCopy(encoded, 0, result, i * elementBytes, elementBytes);
        }
        return result;
    }

    private static bool[] ToBoolArray(object? value, int expectedCount, string tagName)
    {
        if (value is bool[] direct && direct.Length == expectedCount) return direct;
        if (value is System.Collections.IList list && list.Count == expectedCount)
        {
            var arr = new bool[expectedCount];
            for (var i = 0; i < expectedCount; i++) arr[i] = Convert.ToBoolean(list[i]);
            return arr;
        }
        throw new InvalidOperationException(
            $"Coil-array write to {tagName} expects a bool[] (or convertible IList) of length {expectedCount}; got {value?.GetType().Name ?? "null"}");
    }

    private static int ElementByteCount(ModbusTagDefinition tag) => tag.DataType switch
    {
        ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.Bcd16 => 2,
        ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 4,
        ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 8,
        _ => throw new InvalidOperationException($"Element byte count not defined for {tag.DataType} in array context"),
    };

    /// <summary>
    ///     Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised
    ///     against other bit writes targeting the same register via <see cref="GetRmwLock"/>.
    /// </summary>
    private async Task WriteBitInRegisterAsync(
        IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
    {
        var bit = tag.BitIndex;
        if (bit > 15)
            throw new InvalidOperationException(
                $"BitInRegister bit index {bit} out of range (0-15) for tag {tag.Name}.");
        var on = Convert.ToBoolean(value);

        var rmwLock = GetRmwLock(tag.Address);
        await rmwLock.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            // FC03 read 1 holding register at tag.Address.
            var readRaw = await ReadRegisterBlockAsync(transport, ResolveUnitId(tag), 0x03, tag.Address, 1, ct).ConfigureAwait(false);
            // readRaw = [hi][lo] — ReadRegisterBlockAsync strips the fc+byte-count header and
            // validates the payload length, so [0] and [1] are always safe to index here.
            var current = (ushort)((readRaw[0] << 8) | readRaw[1]);

            var updated = on
                ? (ushort)(current | (1 << bit))
                : (ushort)(current & ~(1 << bit));

            // FC06 write single holding register.
            var writePdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
                                        (byte)(updated >> 8), (byte)(updated & 0xFF) };
            await transport.SendAsync(ResolveUnitId(tag), writePdu, ct).ConfigureAwait(false);
        }
        finally
        {
            rmwLock.Release();
        }
    }

    // ---- ISubscribable (polling overlay via shared engine) ----

    /// <inheritdoc />
    public Task<ISubscriptionHandle> SubscribeAsync(
        IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
        Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));

    /// <inheritdoc />
    public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
    {
        _poll.Unsubscribe(handle);
        return Task.CompletedTask;
    }

    // ---- IHostConnectivityProbe ----

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

    /// <summary>
    ///     Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
    ///     Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
    ///     by endpoint without needing the driver-instance-id in the Admin dashboard.
    /// </summary>
    public string HostName => $"{_options.Host}:{_options.Port}";

    private async Task ProbeLoopAsync(CancellationToken ct)
    {
        var transport = _transport; // captured reference; disposal tears the loop down via ct
        while (!ct.IsCancellationRequested)
        {
            var success = false;
            try
            {
                using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                probeCts.CancelAfter(_options.Probe.Timeout);
                var pdu = new byte[] { 0x03,
                    (byte)(_options.Probe.ProbeAddress >> 8),
                    (byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
                _ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
                success = true;
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                return;
            }
            catch
            {
                // transport / timeout / exception PDU — treated as Stopped below
            }

            TransitionTo(success ? HostState.Running : HostState.Stopped);

            try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
            catch (OperationCanceledException) { return; }
        }
    }

    private void TransitionTo(HostState newState)
    {
        HostState old;
        lock (_probeLock)
        {
            old = _hostState;
            if (old == newState) return;
            _hostState = newState;
            _hostStateChangedUtc = DateTime.UtcNow;
        }
        OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
    }

    // ---- codec ----

    /// <summary>
    ///     How many 16-bit registers a given tag occupies. Accounts for multi-register logical
    ///     types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
    ///     from 2 chars per register).
    /// </summary>
    /// <param name="tag">Tag definition to measure.</param>
    /// <returns>The number of 16-bit registers the tag occupies.</returns>
    internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
    {
        ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister or ModbusDataType.Bcd16 => 1,
        ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 2,
        ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
        ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
        _ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
    };

    /// <summary>
    ///     Re-order the input bytes into the big-endian (ABCD) layout the decoders expect.
    ///     The four orders refer to how bytes A, B, C, D appear on the wire when reading a
    ///     32-bit value from two consecutive registers (extends pairwise for 64-bit / 4 regs):
    ///     <list type="bullet">
    ///         <item><b>BigEndian (ABCD)</b>: bytes as-is — Modbus spec default.</item>
    ///         <item><b>WordSwap (CDAB)</b>: swap word pairs (full register reversal across the value).</item>
    ///         <item><b>ByteSwap (BADC)</b>: swap bytes within each register.</item>
    ///         <item><b>FullReverse (DCBA)</b>: full byte reversal — equivalent to little-endian.</item>
    ///     </list>
    /// </summary>
    private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
    {
        if (order == ModbusByteOrder.BigEndian) return data.ToArray();

        var result = new byte[data.Length];
        var registers = data.Length / 2;

        switch (order)
        {
            case ModbusByteOrder.WordSwap:
                // Reverse register order; bytes within each register stay big-endian.
                for (var word = 0; word < registers; word++)
                {
                    var srcWord = registers - 1 - word;
                    result[word * 2] = data[srcWord * 2];
                    result[word * 2 + 1] = data[srcWord * 2 + 1];
                }
                break;
            case ModbusByteOrder.ByteSwap:
                // Keep register order, swap two bytes within each register.
                for (var word = 0; word < registers; word++)
                {
                    result[word * 2] = data[word * 2 + 1];
                    result[word * 2 + 1] = data[word * 2];
                }
                break;
            case ModbusByteOrder.FullReverse:
                // Full byte-by-byte reversal — equivalent to interpreting the value little-endian.
                for (var i = 0; i < data.Length; i++)
                    result[i] = data[data.Length - 1 - i];
                break;
            default:
                throw new InvalidOperationException($"Unhandled byte order {order}");
        }
        return result;
    }

    /// <summary>Decodes a register value according to the tag's data type.</summary>
    /// <param name="data">Raw register bytes.</param>
    /// <param name="tag">Tag definition specifying the data type.</param>
    /// <returns>The decoded value, boxed as the CLR type matching the tag's data type.</returns>
    internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
    {
        switch (tag.DataType)
        {
            case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
            case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
            case ModbusDataType.Bcd16:
            {
                var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
                return (int)DecodeBcd(raw, nibbles: 4);
            }
            case ModbusDataType.Bcd32:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                var raw = BinaryPrimitives.ReadUInt32BigEndian(b);
                return (int)DecodeBcd(raw, nibbles: 8);
            }
            case ModbusDataType.BitInRegister:
            {
                var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
                return (raw & (1 << tag.BitIndex)) != 0;
            }
            case ModbusDataType.Int32:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadInt32BigEndian(b);
            }
            case ModbusDataType.UInt32:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadUInt32BigEndian(b);
            }
            case ModbusDataType.Float32:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadSingleBigEndian(b);
            }
            case ModbusDataType.Int64:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadInt64BigEndian(b);
            }
            case ModbusDataType.UInt64:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadUInt64BigEndian(b);
            }
            case ModbusDataType.Float64:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadDoubleBigEndian(b);
            }
            case ModbusDataType.String:
            {
                // ASCII, 2 chars per register. HighByteFirst (standard) packs the first char in
                // the high byte of each register; LowByteFirst (DL205/DL260) packs the first char
                // in the low byte. Respect StringLength (truncate nul-padded regions).
                var chars = new char[tag.StringLength];
                for (var i = 0; i < tag.StringLength; i++)
                {
                    var regIdx = i / 2;
                    var highByte = data[regIdx * 2];
                    var lowByte  = data[regIdx * 2 + 1];
                    byte b;
                    if (tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst)
                        b = (i % 2 == 0) ? highByte : lowByte;
                    else
                        b = (i % 2 == 0) ? lowByte : highByte;
                    if (b == 0) return new string(chars, 0, i);
                    chars[i] = (char)b;
                }
                return new string(chars);
            }
            default:
                throw new InvalidOperationException($"Non-register data type {tag.DataType}");
        }
    }

    /// <summary>Encodes a value into register bytes according to the tag's data type.</summary>
    /// <param name="value">Value to encode.</param>
    /// <param name="tag">Tag definition specifying the data type.</param>
    /// <returns>The encoded register bytes.</returns>
    internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
    {
        switch (tag.DataType)
        {
            case ModbusDataType.Int16:
            {
                var v = Convert.ToInt16(value);
                var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
            }
            case ModbusDataType.UInt16:
            {
                var v = Convert.ToUInt16(value);
                var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
            }
            case ModbusDataType.Bcd16:
            {
                var v = Convert.ToUInt32(value);
                if (v > 9999) throw new OverflowException($"BCD16 value {v} exceeds 4 decimal digits");
                var raw = (ushort)EncodeBcd(v, nibbles: 4);
                var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, raw); return b;
            }
            case ModbusDataType.Bcd32:
            {
                var v = Convert.ToUInt32(value);
                if (v > 99_999_999u) throw new OverflowException($"BCD32 value {v} exceeds 8 decimal digits");
                var raw = EncodeBcd(v, nibbles: 8);
                var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, raw);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.Int32:
            {
                var v = Convert.ToInt32(value);
                var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.UInt32:
            {
                var v = Convert.ToUInt32(value);
                var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.Float32:
            {
                var v = Convert.ToSingle(value);
                var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.Int64:
            {
                var v = Convert.ToInt64(value);
                var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.UInt64:
            {
                var v = Convert.ToUInt64(value);
                var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.Float64:
            {
                var v = Convert.ToDouble(value);
                var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.String:
            {
                var s = Convert.ToString(value) ?? string.Empty;
                var regs = (tag.StringLength + 1) / 2;
                var b = new byte[regs * 2];
                for (var i = 0; i < tag.StringLength && i < s.Length; i++)
                {
                    var regIdx = i / 2;
                    var destIdx = tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst
                        ? (i % 2 == 0 ? regIdx * 2 : regIdx * 2 + 1)
                        : (i % 2 == 0 ? regIdx * 2 + 1 : regIdx * 2);
                    b[destIdx] = (byte)s[i];
                }
                // remaining bytes stay 0 — nul-padded per PLC convention
                return b;
            }
            case ModbusDataType.BitInRegister:
                // Reached only if BitInRegister is somehow passed outside the HoldingRegisters
                // path. Normal BitInRegister writes dispatch through WriteBitInRegisterAsync via
                // the RMW shortcut in WriteOneAsync.
                throw new InvalidOperationException(
                    "BitInRegister writes must go through WriteBitInRegisterAsync (HoldingRegisters region only).");
            default:
                throw new InvalidOperationException($"Non-register data type {tag.DataType}");
        }
    }

    /// <summary>
    ///     Map a Modbus logical type to the driver-agnostic <see cref="DriverDataType"/> used
    ///     by the address-space builder.
    /// </summary>
    /// <param name="t">Modbus logical data type to map.</param>
    /// <returns>The corresponding driver-agnostic data type.</returns>
    internal static DriverDataType MapDataType(ModbusDataType t) => t switch
    {
        ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
        ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
        ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
        ModbusDataType.Int64 => DriverDataType.Int64,
        ModbusDataType.UInt64 => DriverDataType.UInt64,
        ModbusDataType.Float32 => DriverDataType.Float32,
        ModbusDataType.Float64 => DriverDataType.Float64,
        ModbusDataType.String => DriverDataType.String,
        ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32,
        _ => DriverDataType.Int32,
    };

    /// <summary>
    ///     Decode an N-nibble binary-coded-decimal value. Each nibble of <paramref name="raw"/>
    ///     encodes one decimal digit (most-significant nibble first). Rejects nibbles &gt; 9 —
    ///     the hardware sometimes produces garbage during transitions and silent non-BCD reads
    ///     would quietly corrupt the caller's data.
    /// </summary>
    /// <param name="raw">Raw BCD value.</param>
    /// <param name="nibbles">Number of nibbles to decode.</param>
    /// <returns>The decoded decimal value.</returns>
    internal static uint DecodeBcd(uint raw, int nibbles)
    {
        uint result = 0;
        for (var i = nibbles - 1; i >= 0; i--)
        {
            var digit = (raw >> (i * 4)) & 0xF;
            if (digit > 9)
                throw new InvalidDataException(
                    $"Non-BCD nibble 0x{digit:X} at position {i} of raw=0x{raw:X}");
            result = result * 10 + digit;
        }
        return result;
    }

    /// <summary>
    ///     Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
    ///     against the nibble capacity (10^nibbles - 1).
    /// </summary>
    /// <param name="value">Decimal value to encode.</param>
    /// <param name="nibbles">Number of nibbles to encode.</param>
    /// <returns>The encoded N-nibble BCD value.</returns>
    internal static uint EncodeBcd(uint value, int nibbles)
    {
        uint result = 0;
        for (var i = 0; i < nibbles; i++)
        {
            var digit = value % 10;
            result |= digit << (i * 4);
            value /= 10;
        }
        return result;
    }

    private IModbusTransport RequireTransport() =>
        _transport ?? throw new InvalidOperationException("ModbusDriver not initialized");

    private const uint StatusBadInternalError = 0x80020000u;
    private const uint StatusBadNodeIdUnknown = 0x80340000u;
    private const uint StatusBadNotWritable = 0x803B0000u;
    private const uint StatusBadOutOfRange = 0x803C0000u;
    private const uint StatusBadNotSupported = 0x803D0000u;
    private const uint StatusBadDeviceFailure = 0x808B0000u;
    private const uint StatusBadCommunicationError = 0x80050000u;

    /// <summary>
    ///     Map a server-returned Modbus exception code to the most informative OPC UA
    ///     StatusCode. Keeps the driver's outward-facing status surface aligned with what a
    ///     Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
    ///     Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
    ///     generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
    ///     operators see a CPU-mode problem rather than a driver bug, etc. Per
    ///     <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
    ///     extensions.
    /// </summary>
    /// <param name="exceptionCode">Modbus exception code.</param>
    /// <returns>The mapped OPC UA StatusCode value.</returns>
    internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
    {
        0x01 => StatusBadNotSupported,     // Illegal Function — FC not in supported list
        0x02 => StatusBadOutOfRange,       // Illegal Data Address — register outside mapped range
        0x03 => StatusBadOutOfRange,       // Illegal Data Value — quantity over per-FC cap
        0x04 => StatusBadDeviceFailure,    // Server Failure — CPU in PROGRAM mode during protected write
        0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
        0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
        _ => StatusBadInternalError,
    };

    /// <summary>Releases resources used by the driver.</summary>
    public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();

    /// <summary>
    ///     DisposeAsync must perform the same teardown as ShutdownAsync so
    ///     callers that use <c>await using</c> (without an explicit <c>ShutdownAsync</c>) do not
    ///     leak the probe loop, re-probe loop, and poll-engine background tasks. Shares
    ///     <see cref="TeardownAsync"/> with <see cref="ShutdownAsync"/> to keep them in sync.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async ValueTask DisposeAsync()
    {
        await TeardownAsync().ConfigureAwait(false);
    }

    /// <summary>
    ///     Shared teardown helper used by both <see cref="ShutdownAsync"/> and
    ///     <see cref="DisposeAsync"/>. Cancels both background loops, disposes the poll engine,
    ///     and disposes the transport. Idempotent — safe to call more than once.
    /// </summary>
    private async Task TeardownAsync()
    {
        try { _probeCts?.Cancel(); } catch { }
        _probeCts?.Dispose();
        _probeCts = null;

        try { _reprobeCts?.Cancel(); } catch { }
        _reprobeCts?.Dispose();
        _reprobeCts = null;

        _tagsByRawPath.Clear();
        _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
        _lastPublishedByRef.Clear();
        lock (_lastWrittenLock) _lastWrittenByRef.Clear();
        lock (_autoProhibitedLock) _autoProhibited.Clear();

        await _poll.DisposeAsync().ConfigureAwait(false);

        if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
        _transport = null;
    }
}
