using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using S7.Net;
using S7NetDataType = global::S7.Net.DataType;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
///     library. First implementation of <see cref="IDriver"/> for an in-process .NET Standard
///     PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
///     generalize beyond Modbus + Galaxy.
/// </summary>
/// <remarks>
///     <para>
///         PR 62 ships the scaffold: <see cref="IDriver"/> only (Initialize / Reinitialize /
///         Shutdown / GetHealth). <see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
///         <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>
///         land in PRs 63-65 once the address parser (PR 63) is in place.
///     </para>
///     <para>
///         <b>Single-connection policy</b>: S7netplus documented pattern is one
///         <c>Plc</c> instance per PLC, serialized with a <see cref="SemaphoreSlim"/>.
///         Parallelising reads against a single S7 CPU doesn't help — the CPU scans the
///         communication mailbox at most once per cycle (2-10 ms) and queues concurrent
///         requests wire-side anyway. Multiple client-side connections just waste the CPU's
///         8-64 connection-resource budget.
///     </para>
/// </remarks>
public sealed class S7Driver
    : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
    private readonly string _driverInstanceId;
    private readonly ILogger<S7Driver> _logger;
    private readonly IS7PlcFactory _plcFactory;

    /// <summary>Initializes a new instance of the <see cref="S7Driver"/> class.</summary>
    /// <param name="options">Driver configuration (the constructor-supplied fallback used when
    /// Initialize/Reinitialize receive an empty config body).</param>
    /// <param name="driverInstanceId">Unique driver instance identifier.</param>
    /// <param name="plcFactory">Optional S7 connection factory; defaults to <see cref="S7PlcFactory"/>
    /// (real S7.Net). Tests inject a fake to exercise the reconnect path without a live PLC.</param>
    /// <param name="logger">Optional logger; a null logger is used when not supplied.</param>
    public S7Driver(S7DriverOptions options, string driverInstanceId,
        IS7PlcFactory? plcFactory = null, ILogger<S7Driver>? logger = null)
    {
        _options = options;
        _driverInstanceId = driverInstanceId;
        _plcFactory = plcFactory ?? new S7PlcFactory();
        _logger = logger ?? NullLogger<S7Driver>.Instance;
        _resolver = new EquipmentTagRefResolver<S7TagDefinition>(
            r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
        // CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff +
        // drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE),
        // retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor;
        // backoffCap 30 s matches the retired fork's PollBackoffCap.
        _poll = new PollGroupEngine(
            reader: ReadAsync,
            onChange: (handle, tagRef, snapshot) =>
                OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
            minInterval: TimeSpan.FromMilliseconds(100),
            onError: HandlePollError,
            backoffCap: TimeSpan.FromSeconds(30));
    }
    // ---- ISubscribable + IHostConnectivityProbe state ----

    private readonly PollGroupEngine _poll;
    private readonly object _probeLock = new();
    private HostState _hostState = HostState.Unknown;
    private DateTime _hostStateChangedUtc = DateTime.UtcNow;
    private CancellationTokenSource? _probeCts;

    /// <summary>
    ///     Handle to the in-flight probe loop. Tracked (rather than fire-and-forget) so
    ///     <see cref="ShutdownAsync"/> can await it after cancelling — otherwise a probe
    ///     iteration still inside the <see cref="_gate"/> would race a disposed semaphore.
    /// </summary>
    private Task? _probeTask;

    /// <summary>
    ///     Bounded grace window <see cref="ShutdownAsync"/> waits for the probe + poll loops to
    ///     observe cancellation and exit before it disposes the shared semaphore / CTS objects.
    /// </summary>
    private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(5);

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

    /// <summary>OPC UA StatusCode used when the tag name isn't in the driver's tag map.</summary>
    private const uint StatusBadNodeIdUnknown = 0x80340000u;
    /// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
    private const uint StatusBadNotSupported = 0x803D0000u;
    /// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
    private const uint StatusBadNotWritable = 0x803B0000u;
    /// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
    private const uint StatusBadInternalError = 0x80020000u;
    /// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
    private const uint StatusBadCommunicationError = 0x80050000u;
    /// <summary>OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).</summary>
    private const uint StatusBadDeviceFailure = 0x808B0000u;

    // v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name),
    // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory.
    private readonly Dictionary<string, S7TagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
    // Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside
    // _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault.
    private readonly Dictionary<string, S7ParsedAddress> _parsedByRawPath = new(StringComparer.Ordinal);

    // Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single
    // authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt).
    private readonly EquipmentTagRefResolver<S7TagDefinition> _resolver;

    /// <summary>
    ///     Active driver configuration. Seeded from the constructor argument, then replaced by
    ///     whatever <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> parse out of
    ///     the supplied <c>driverConfigJson</c>. The
    ///     constructor value is the fallback used when the caller passes an empty / placeholder
    ///     JSON document (e.g. the <c>"{}"</c> some unit tests pass).
    /// </summary>
    private S7DriverOptions _options;
    private readonly SemaphoreSlim _gate = new(1, 1);

    /// <summary>
    ///     Per-connection gate. Internal so PRs 63-65 (read/write/subscribe) can serialize on
    ///     the same semaphore without exposing it publicly. Single-connection-per-PLC is a
    ///     hard requirement of S7netplus — see class remarks.
    /// </summary>
    internal SemaphoreSlim Gate => _gate;

    /// <summary>
    ///     Active S7 PLC connection. Null until <see cref="InitializeAsync"/> returns; null
    ///     after <see cref="ShutdownAsync"/>. Also (re)opened lazily by
    ///     <see cref="EnsureConnectedAsync"/> after a transient drop. Read-only outside this class;
    ///     Read/Write/probe take the <see cref="_gate"/> before touching it.
    /// </summary>
    internal IS7Plc? Plc { get; private set; }

    /// <summary>
    ///     True once <see cref="InitializeAsync"/> has parsed the tag table + opened the first
    ///     connection, false after <see cref="ShutdownAsync"/>. Distinct from <c>Plc != null</c>
    ///     because the reconnect path can leave <see cref="Plc"/> transiently null / dead while the
    ///     driver is still initialized — a data call then repairs the connection rather than
    ///     throwing "not initialized". Guarded by <see cref="_gate"/>.
    /// </summary>
    private bool _initialized;

    /// <summary>
    ///     Set when a connection-fatal fault (socket drop / <see cref="ErrorCode.ConnectionError"/>)
    ///     is observed on a read/write, so the next <see cref="EnsureConnectedAsync"/> disposes the
    ///     dead handle and re-opens. Kept as a flag (rather than nulling <see cref="Plc"/> inline) so
    ///     the dead handle is disposed exactly once, in <see cref="EnsureConnectedAsync"/>. Guarded
    ///     by <see cref="_gate"/>.
    /// </summary>
    private bool _plcDead;

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

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

    /// <inheritdoc />
    public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        _health = new DriverHealth(DriverState.Initializing, null, null);
        try
        {
            // Re-parse the supplied DriverConfig JSON so a config change delivered through the
            // IDriver contract is honoured. An empty / placeholder document
            // (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
            if (HasConfigBody(driverConfigJson))
                _options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);

            // v3: build the RawPath → definition table from the authored raw tags BEFORE the config
            // guards run (they now iterate the mapped table). 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 —
            // one bad tag must not fail the whole driver init.
            _tagsByRawPath.Clear();
            _parsedByRawPath.Clear();
            _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
            foreach (var entry in _options.RawTags)
            {
                if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
                    _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
                else
                    _logger.LogWarning(
                        "S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
                        _driverInstanceId, entry.RawPath);
            }

            // Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
            // types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
            // specific shapes — this rejects the genuinely-unsupported configurations
            // (wide-type arrays, non-byte-addressed wide scalars, wrong Timer/Counter DataType)
            // so a config typo fails fast at init instead of as a misleading per-read fault.
            RejectUnsupportedTagConfigs();

            // Legacy data-type seam. The UnimplementedDataTypes set is now
            // empty (Phase 4d unblocked all five wide types), so this rejects nothing — kept as
            // the single grep target / future seam should a type ever need re-gating at init.
            RejectUnsupportedTagDataTypes();

            // Build the connection through the factory (the same seam EnsureConnectedAsync's reopen
            // uses) so the read/write timeouts are baked in identically on connect and reconnect.
            var plc = _plcFactory.Create(
                _options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
            try
            {
                using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                cts.CancelAfter(_options.Timeout);
                await plc.OpenAsync(cts.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
            {
                // The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
                // Surface it as a TimeoutException so init-time and reconnect-time timeouts report
                // the same exception type (STAB-14 consistency); the outer catch still faults health.
                try { plc.Dispose(); } catch { }
                throw new TimeoutException(
                    $"S7 connect to {_options.Host}:{_options.Port} timed out after " +
                    $"{(int)_options.Timeout.TotalMilliseconds} ms.");
            }
            catch
            {
                // Dispose the partially-constructed connection so a failed init doesn't leak it
                // before the outer catch nulls Plc (Plc isn't assigned yet, so it can't).
                try { plc.Dispose(); } catch { }
                throw;
            }

            Plc = plc;
            _plcDead = false;

            // Parse every tag's address once at init so config typos fail fast here instead
            // of surfacing as BadInternalError on every Read against the bad tag. The parser
            // also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath
            // (== def.Name) so Read/Write can pick up the pre-parsed address.
            foreach (var t in _tagsByRawPath.Values)
            {
                _parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
            }

            _initialized = true;
            _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
            _logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}",
                _driverInstanceId, _options.Host, _options.CpuType, _tagsByRawPath.Count);

            // Kick off the probe loop once the connection is up. Initial HostState stays
            // Unknown until the first probe tick succeeds — avoids broadcasting a premature
            // Running transition before any PDU round-trip has happened.
            if (_options.Probe.Enabled)
            {
                _probeCts = new CancellationTokenSource();
                // Track the probe Task (not fire-and-forget) so ShutdownAsync can await it
                // before disposing _gate / _probeCts. Pass None to Task.Run so
                // the delegate always runs and the handle is always awaitable; the loop's own
                // token check handles cancellation.
                _probeTask = Task.Run(() => ProbeLoopAsync(_probeCts.Token), CancellationToken.None);
            }
        }
        catch (Exception ex)
        {
            // Clean up a partially-constructed Plc so a retry from the caller doesn't leak
            // the TcpClient. Dispose is best-effort and idempotent.
            try { Plc?.Dispose(); } catch { }
            Plc = null;
            _initialized = false;
            _plcDead = false;
            _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
            _logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, _options.Host);
            throw;
        }
    }

    /// <inheritdoc />
    public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        // InitializeAsync re-parses driverConfigJson, so a config change delivered here is
        // applied in place rather than silently discarded.
        await ShutdownAsync(cancellationToken).ConfigureAwait(false);
        await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
    }

    /// <inheritdoc />
    public async Task ShutdownAsync(CancellationToken cancellationToken)
    {
        // Signal cancellation to the probe + poll loops first, collect their Task handles,
        // then await all of them with a bounded timeout BEFORE disposing the shared semaphore
        // and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
        // on (or WaitAsync against) a disposed semaphore.
        // Drain the polling overlay first — the engine cancels each loop and awaits it before
        // disposing its CTS (drain-before-dispose, STAB-6-S7), so no poll iteration touches _gate
        // after this returns.
        await _poll.DisposeAsync().ConfigureAwait(false);

        var probeCts = _probeCts;
        var probeTask = _probeTask;
        try { probeCts?.Cancel(); } catch { }
        if (probeTask is not null)
        {
            try
            {
                await probeTask.WaitAsync(DrainTimeout, CancellationToken.None).ConfigureAwait(false);
            }
            catch (TimeoutException) { /* a wedged probe loop — proceed; better than leaking the teardown */ }
            catch { /* loop faults are already surfaced via health; teardown continues */ }
        }

        // The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
        probeCts?.Dispose();
        _probeCts = null;
        _probeTask = null;

        _initialized = false;
        _plcDead = false;
        try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
        Plc = null;
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }

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

    /// <inheritdoc />
    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    /// <summary>
    ///     True when <paramref name="driverConfigJson"/> carries a real config 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 <see cref="_options"/>.
    /// </summary>
    private static bool HasConfigBody(string? driverConfigJson)
    {
        if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
        var trimmed = driverConfigJson.Trim();
        return trimmed is not "{}" and not "[]";
    }

    /// <summary>
    ///     S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an
    ///     access-width suffix. Phase 4d decodes these from a contiguous byte buffer
    ///     (<c>DB{n}.DBB{offset}</c> / <c>MB</c> / <c>IB</c> / <c>QB{offset}</c>), so a wide tag
    ///     authored against a non-byte suffix (DBW/DBD/…) is a config error — see
    ///     <see cref="RejectUnsupportedTagConfigs"/>.
    /// </summary>
    private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
    {
        S7DataType.Int64,
        S7DataType.UInt64,
        S7DataType.Float64,
        S7DataType.String,
        S7DataType.DateTime,
    };

    /// <summary>
    ///     Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only
    ///     <c>RejectUnsupportedTagAddresses</c>). Timer/Counter tags and the wide/structured
    ///     types (Int64/UInt64/Float64/String/DateTime) are now <i>supported</i> — but only in
    ///     specific shapes. This guard fails fast on the genuinely-unsupported configurations so
    ///     a config typo throws here at init rather than as a misleading per-read fault:
    ///     <list type="bullet">
    ///         <item>(a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase.</item>
    ///         <item>(b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>).</item>
    ///         <item>(c) a Timer tag not typed <c>Float64</c>, or a Counter tag not typed <c>Int32</c>.</item>
    ///     </list>
    ///     Addresses that don't parse are left to the existing <see cref="S7AddressParser.Parse"/>
    ///     pass in <see cref="InitializeAsync"/> so the parse error isn't double-handled.
    /// </summary>
    private void RejectUnsupportedTagConfigs()
    {
        foreach (var t in _tagsByRawPath.Values)
        {
            var isWide = ByteAnchoredDataTypes.Contains(t.DataType);

            // (a) wide-type arrays — out of scope this phase.
            if (isWide && t.ArrayCount is >= 1)
            {
                throw new NotSupportedException(
                    $"S7 tag '{t.Name}' is a {t.DataType} array, which the S7 driver does not yet " +
                    "support. Use a scalar wide tag, or an Int16/UInt16/Int32/UInt32/Float32/Byte/Bool " +
                    "element type for arrays.");
            }

            // Parse the address; if it doesn't parse, leave it for the InitializeAsync
            // S7AddressParser.Parse pass to throw the FormatException (no double-handling).
            if (!S7AddressParser.TryParse(t.Address, out var parsed))
                continue;

            // (b) wide/structured scalar on DB/M/I/Q must be byte-anchored (Size == Byte). The
            // codec decodes from a byte buffer at the start byte; a non-byte suffix (DBW/DBD/…)
            // would mis-frame the value.
            if (isWide
                && parsed.Area is S7Area.DataBlock or S7Area.Memory or S7Area.Input or S7Area.Output
                && parsed.Size != S7Size.Byte)
            {
                throw new NotSupportedException(
                    $"S7 tag '{t.Name}' is a {t.DataType} at '{t.Address}', which is not byte-addressed. " +
                    "Wide/structured types must be byte-addressed (DBB/MB/IB/QB) — e.g. 'DB1.DBB0', 'MB4'.");
            }

            // (c) Timer/Counter type constraints — Timer decodes to a Float64 (seconds), Counter
            // to an Int32 (count); both are read-only.
            if (parsed.Area is S7Area.Timer && t.DataType != S7DataType.Float64)
            {
                throw new NotSupportedException(
                    $"S7 tag '{t.Name}' is a Timer address ('{t.Address}') but is typed {t.DataType}. " +
                    "Timer tags must be DataType=Float64 (decoded to seconds, read-only).");
            }
            if (parsed.Area is S7Area.Counter && t.DataType != S7DataType.Int32)
            {
                throw new NotSupportedException(
                    $"S7 tag '{t.Name}' is a Counter address ('{t.Address}') but is typed {t.DataType}. " +
                    "Counter tags must be DataType=Int32 (decoded to count, read-only).");
            }

            // (d) Timer/Counter declared Writable — writes are read-only this phase; without this
            // a node is discovered as Operate-writable but every write returns BadNotSupported.
            if (parsed.Area is S7Area.Timer or S7Area.Counter && t.Writable)
            {
                throw new NotSupportedException(
                    $"S7 tag '{t.Name}' is a Timer/Counter ('{t.Address}') declared Writable — " +
                    "Timer/Counter are read-only this phase; set Writable=false.");
            }

            // (e) Array tag declared Writable — WriteOneAsync has no WriteArrayAsync path yet.
            // Without this guard a writable array node is discovered and
            // accepted, then every write returns BadCommunicationError (InvalidCastException from
            // BoxValueForWrite receiving a typed array) instead of the informative BadNotSupported
            // the caller could act on. Wide-type arrays are already rejected above (guard-a); this
            // targets non-wide types (Int16[], Byte[], Float32[], etc.) that ARE supported for
            // reading but not yet for writing.
            if (t.ArrayCount is >= 1 && t.Writable)
            {
                throw new NotSupportedException(
                    $"S7 tag '{t.Name}' is a {t.DataType} array (ArrayCount={t.ArrayCount}) declared Writable — " +
                    "array writes are not yet supported by the S7 driver. Set Writable=false for read-only array tags, " +
                    "or split into individual scalar tags until array-write support lands.");
            }
        }
    }

    /// <summary>
    ///     Rejects tags configured with an <see cref="S7DataType"/> still on the
    ///     <see cref="UnimplementedDataTypes"/> list — types <see cref="ReinterpretRawValue"/> /
    ///     <see cref="BoxValueForWrite"/> throw <see cref="NotSupportedException"/> for, which
    ///     would otherwise create live OPC UA nodes via <see cref="DiscoverAsync"/> that return
    ///     <c>BadNotSupported</c> on every access.
    ///     <b>The set is now empty</b> (Phase 4d unblocked all five wide types), so this rejects
    ///     nothing — kept as the seam / grep target should a type ever need re-gating at init.
    /// </summary>
    private void RejectUnsupportedTagDataTypes()
    {
        foreach (var t in _tagsByRawPath.Values)
        {
            if (UnimplementedDataTypes.Contains(t.DataType))
            {
                throw new NotSupportedException(
                    $"S7 tag '{t.Name}' uses data type '{t.DataType}' which is not yet " +
                    "supported by the S7 driver — Read/Write would return BadNotSupported. " +
                    "Remove the tag or use Bool/Byte/Int16/UInt16/Int32/UInt32/Float32 until " +
                    $"{t.DataType} is wired through.");
            }
        }
    }

    /// <summary>
    ///     S7DataType members that the read/write helpers throw NotSupportedException for.
    ///     <b>Now empty</b> — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64,
    ///     Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read
    ///     through it on AREA. Kept here (rather than reflecting over
    ///     <see cref="ReinterpretRawValue"/>) as the single grep target / future seam should a
    ///     data type ever need re-gating at init.
    /// </summary>
    private static readonly HashSet<S7DataType> UnimplementedDataTypes = new();

    /// <inheritdoc />
    // The Plc instance + one 240-960 byte PDU buffer is under 4 KB; returns 0 because the
    // IDriver contract asks for a driver-attributable growth number and S7.Net doesn't expose one.
    public long GetMemoryFootprint() => 0;

    // ---- IReadable ----

    /// <inheritdoc />
    public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
        IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
    {
        // Validate the list before the init check so a null argument produces an
        // ArgumentNullException (consistent with DiscoverAsync) rather than an
        // InvalidOperationException from the not-initialized check.
        ArgumentNullException.ThrowIfNull(fullReferences);
        RequireInitialized();
        var now = DateTime.UtcNow;
        var results = new DataValueSnapshot[fullReferences.Count];

        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            // (Re)open the connection under the gate. A failed (re)connect degrades the whole
            // batch to BadCommunicationError rather than throwing — the poll loop / next read
            // retries, and EnsureConnectedAsync repairs on the following call.
            IS7Plc plc;
            try
            {
                plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
            }
            // Rethrow ONLY caller cancellation. A timeout-born OCE (none expected after the
            // EnsureConnectedAsync conversion, but e.g. an S7.Net-internal CTS) must degrade the
            // batch below, not escape as teardown (STAB-14).
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
            catch (Exception ex)
            {
                for (var i = 0; i < fullReferences.Count; i++)
                    results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
                return results;
            }

            for (var i = 0; i < fullReferences.Count; i++)
            {
                var name = fullReferences[i];
                if (!_resolver.TryResolve(name, out var tag))
                {
                    results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
                    continue;
                }
                try
                {
                    var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
                    results[i] = new DataValueSnapshot(value, 0u, now, now);
                    _health = new DriverHealth(DriverState.Healthy, now, null);
                }
                catch (OperationCanceledException)
                {
                    // Cancellation observed mid-PDU abandons a half-read ISO-on-TCP response on the
                    // single gated stream — the handle must be reopened, not reused (STAB-15a). Mark
                    // dead (we hold _gate) and propagate: the caller cancelled, nobody consumes the
                    // batch. Marking is unconditional on OCE (not routed through the type classifier):
                    // whether bytes moved is unknowable, and a false positive costs one cheap reopen
                    // versus permanent desync for a false negative.
                    _plcDead = true;
                    throw;
                }
                catch (NotSupportedException)
                {
                    results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
                }
                catch (PlcException pex) when (IsAccessDenied(pex))
                {
                    // PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent
                    // configuration fault, NOT a transient one. Blind retry is wasted effort,
                    // so map it to BadNotSupported and flag the driver as a config alert
                    // (Faulted) rather than Degraded — per driver-specs.md §5.
                    results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
                    _health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead,
                        "S7 access denied — enable PUT/GET communication in TIA Portal " +
                        $"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
                }
                catch (PlcException pex)
                {
                    // A genuine device-layer fault (CPU error, hardware fault) — transient
                    // enough to keep retrying; report BadDeviceFailure and degrade health. A
                    // connection-fatal PlcException (socket drop / ConnectionError) additionally
                    // marks the handle dead so the next read reopens it.
                    MarkConnectionDeadIfFatal(pex);
                    results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
                    _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
                }
                catch (Exception ex)
                {
                    // Socket / timeout / transport fault — mark the handle dead when it's a
                    // connection loss so EnsureConnectedAsync reopens on the next call.
                    MarkConnectionDeadIfFatal(ex);
                    results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
                    _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
                }
            }
        }
        finally { _gate.Release(); }
        return results;
    }

    private async Task<object> ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct)
    {
        // Every tag pre-parses its address at init (_parsedByRawPath); the on-demand
        // S7AddressParser.Parse is a defensive fallback for a tag not in that table. Parse throws
        // FormatException on a bad address, which the caller's catch maps to BadCommunicationError.
        var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed)
            ? parsed
            : S7AddressParser.Parse(tag.Address);

        // Array path: a tag with a declared count >= 1 reads a CONTIGUOUS block of
        // count × element-bytes in a SINGLE round-trip (Plc.ReadBytesAsync), then decodes each
        // element from its big-endian slice into an element-typed CLR array. The scalar path
        // (count null) is left byte-for-byte unchanged below. A count of 1 IS a valid 1-element
        // array (the foundation materialises a [1] OPC UA array node when isArray:true).
        if (tag.ArrayCount is >= 1)
            return await ReadArrayAsync(plc, tag, addr, ct).ConfigureAwait(false);

        // Wide/structured scalar path (Int64/UInt64/Float64/String/DateTime) AND the Timer/Counter
        // areas: S7.Net's string ReadAsync only decodes 1/2/4-byte size suffixes, so neither family
        // can go through the narrow path below. Read a contiguous byte block and decode it —
        // mirrors the array path's buffer read. Timer/Counter route here on AREA (IsBufferType),
        // so a Timer/Float64 tag reads its 2-byte S5TIME word here rather than falling through to
        // the dead Float64 stub (which would surface BadNotSupported on every read).
        if (IsBufferType(tag, addr))
            return await ReadScalarBlockAsync(plc, tag, addr, ct).ConfigureAwait(false);

        // S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
        // the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
        // specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
        // converts the raw unsigned boxed value into the requested type without issuing an
        // extra PLC round-trip.
        var raw = await plc.ReadAsync(tag.Address, ct).ConfigureAwait(false)
            ?? throw new System.IO.InvalidDataException($"S7.Net returned null for '{tag.Address}'");

        return ReinterpretRawValue(tag, addr, raw);
    }

    /// <summary>
    ///     Reads a 1-D array tag as ONE contiguous block (<c>count × element-bytes</c>) via
    ///     S7.Net's buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c>
    ///     — a single PLC round-trip, NOT <c>N</c> string reads — then hands the raw byte block
    ///     to the pure <see cref="DecodeArrayBlock"/> decode loop. Timer/Counter areas are
    ///     already rejected at init, so only DB/M/I/Q reach here.
    /// </summary>
    private async Task<object> ReadArrayAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
    {
        var count = tag.ArrayCount!.Value;
        var elementBytes = ElementByteSize(addr.Size);
        var totalBytes = count * elementBytes;

        // ReadBytesAsync addresses by (area, db, startByteOffset, byteCount). The parser already
        // normalised the start to a BYTE offset (ByteOffset) for DB/M/I/Q; a Bit array starts at
        // its byte and consumes one byte per element (byte-granular contiguous bit access). S7.Net
        // transparently splits a > PDU-sized block into multiple wire requests, so the driver
        // doesn't have to chunk.
        var area = ToS7NetArea(addr.Area);
        var block = await plc.ReadBytesAsync(area, addr.DbNumber, addr.ByteOffset, totalBytes, ct)
            .ConfigureAwait(false)
            ?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");

        return DecodeArrayBlock(tag, addr, block);
    }

    /// <summary>
    ///     True when this tag must be read/written through the byte-buffer codec rather than
    ///     S7.Net's string path. Two families route here:
    ///     <list type="bullet">
    ///         <item>the wide/structured types (Int64/UInt64/Float64/String/DateTime), which are
    ///         byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>) and decoded from a
    ///         contiguous block — S7.Net's string ReadAsync only understands 1/2/4-byte size
    ///         suffixes, so they can't use the narrow path;</item>
    ///         <item>the Timer/Counter AREAS — a Timer (<c>T{n}</c>) decodes a 2-byte S5TIME word
    ///         to a duration in seconds (<c>double</c>) and a Counter (<c>C{n}</c>) decodes a
    ///         2-byte word to a count (<c>int</c>); both are read-only this phase. Routing them
    ///         here (rather than through the narrow path, which would hit the dead Float64 stub and
    ///         throw <c>BadNotSupported</c> on every read) closes the interim correctness hole.</item>
    ///     </list>
    ///     The init guard already enforces byte-addressing + the Timer→Float64 / Counter→Int32
    ///     type constraints, so by the time a tag reaches here it is well-formed.
    /// </summary>
    /// <param name="tag">Tag definition carrying the <see cref="S7DataType"/>.</param>
    /// <param name="addr">Parsed address — its <see cref="S7Area"/> routes Timer/Counter into the seam regardless of <see cref="S7DataType"/>.</param>
    /// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
    private static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
        addr.Area is S7Area.Timer or S7Area.Counter
        || tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
            or S7DataType.String or S7DataType.DateTime;

    /// <summary>
    ///     Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
    ///     buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c> (a single
    ///     PLC round-trip), then hands the raw block to the pure <see cref="DecodeScalarBlock"/>
    ///     decode. Mirrors <see cref="ReadArrayAsync"/>'s shape.
    /// </summary>
    private async Task<object> ReadScalarBlockAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
    {
        var width = ScalarByteWidth(tag, addr);
        // For DB/M/I/Q, addr.ByteOffset is the start BYTE and `width` the byte count. For
        // Timer/Counter, addr.ByteOffset is the timer/counter NUMBER (db stays 0) and width is 2:
        // S7.Net's Timer/Counter transport size makes the request "count" word a per-2-byte unit,
        // so passing 2 reads exactly one timer/counter's 2-byte block (matching S7.Net's own
        // VarType.Timer/Counter path, which sets byteLength = varCount × 2). See ScalarByteWidth.
        var block = await plc.ReadBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, width, ct)
            .ConfigureAwait(false)
            ?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");

        return DecodeScalarBlock(tag, addr, block);
    }

    /// <summary>
    ///     Byte width of one wide/structured scalar (or one Timer/Counter word).
    ///     <b>The parsed AREA takes precedence over the tag's <see cref="S7DataType"/></b>: a
    ///     Timer/Counter address is always 2 bytes (one S5TIME / counter word), even though a
    ///     Timer tag is typed <see cref="S7DataType.Float64"/> (which would otherwise yield 8) and
    ///     a Counter tag <see cref="S7DataType.Int32"/>. For DB/M/I/Q areas the width follows the
    ///     DataType: Int64/UInt64/Float64/DateTime are 8 bytes; an S7 STRING occupies
    ///     <c>StringLength + 2</c> bytes (the two-byte header carries the declared max length and
    ///     the current actual length). Throws for any non-buffer type — defensive;
    ///     <see cref="IsBufferType"/> gates this so a non-buffer type never reaches here.
    /// </summary>
    /// <param name="tag">Tag definition carrying the <see cref="S7DataType"/> and (for strings) <c>StringLength</c>.</param>
    /// <param name="addr">Parsed address — Timer/Counter areas force a 2-byte width regardless of <see cref="S7DataType"/>.</param>
    /// <returns>The byte width to read for this scalar.</returns>
    internal static int ScalarByteWidth(S7TagDefinition tag, S7ParsedAddress addr)
    {
        // Area precedence: Timer (S5TIME) and Counter are a single 16-bit word on the wire. The
        // Timer tag's Float64 DataType describes the DECODED value (seconds), not the read width,
        // so it must NOT drive an 8-byte read — that would mis-frame the timer word.
        if (addr.Area is S7Area.Timer or S7Area.Counter)
            return 2;

        return tag.DataType switch
        {
            S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
            S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL).
            S7DataType.String => tag.StringLength + 2,
            _ => throw new InvalidOperationException(
                $"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"),
        };
    }

    /// <summary>
    ///     Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR
    ///     value (<c>long</c> / <c>ulong</c> / <c>double</c>), boxed as <see cref="object"/>. No
    ///     network I/O — factored out of <see cref="ReadScalarBlockAsync"/> so the codec is
    ///     unit-testable against a known block without a live PLC (S7.Net ships no in-process
    ///     fake). Mirrors <see cref="DecodeArrayBlock"/>.
    /// </summary>
    /// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
    /// <param name="addr">Parsed address (carried for the error surface + the Timer/Counter seam).</param>
    /// <param name="block">Raw contiguous byte block read from the PLC (length == <see cref="ScalarByteWidth"/>).</param>
    /// <returns>The decoded scalar value boxed as <see cref="object"/>.</returns>
    internal static object DecodeScalarBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
    {
        // AREA precedence first (before the DataType switch): a Timer (T{n}) decodes its 2-byte
        // S5TIME word to a duration in SECONDS via S7.Net's Timer.FromByteArray (returns double);
        // a Counter (C{n}) decodes its 2-byte word to a COUNT via Counter.FromByteArray (returns
        // ushort, surfaced as a non-negative int to match the tag's Int32 type). Routing on Area
        // here is essential: a Timer tag is typed Float64, so letting it fall to the Float64 arm
        // below would ReadDoubleBigEndian over a 2-byte block = garbage/throw. Boxed explicitly so
        // the box type is exactly double / int (not the switch-unified common type).
        if (addr.Area is S7Area.Timer)
            return (object)global::S7.Net.Types.Timer.FromByteArray(block);
        if (addr.Area is S7Area.Counter)
        {
            // NOTE: S7.Net Counter.FromByteArray returns the RAW big-endian word ((bytes[0]<<8)|bytes[1]),
            // NOT a BCD decode. On classic S7-300/400 the C-area word is BCD (0-999), so on that hardware
            // this raw value can differ from the displayed count; S7-1200/1500 use IEC/DB counters (plain
            // ints), where it is correct. Surfacing S7.Net's value verbatim is the faithful choice; BCD
            // reinterpretation for legacy C-area counters is a live-hardware-gated follow-up.
            return (object)(int)global::S7.Net.Types.Counter.FromByteArray(block);
        }

        // Each numeric arm is boxed to object explicitly: a bare switch expression would unify
        // long/ulong/double to their common type (double) and box THAT, mis-typing Int64/UInt64.
        return tag.DataType switch
        {
            S7DataType.Int64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(block),
            S7DataType.UInt64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt64BigEndian(block),
            S7DataType.Float64 => System.Buffers.Binary.BinaryPrimitives.ReadDoubleBigEndian(block),

            // S7 classic STRING: [maxLen byte][curLen byte][chars…]. S7.Net's S7String.FromByteArray
            // reads the two-byte header and returns exactly curLen ASCII chars, ignoring the reserved
            // padding past curLen — it validates curLen against the block, so no extra guard is needed.
            S7DataType.String => global::S7.Net.Types.S7String.FromByteArray(block),

            // S7 classic DATE_AND_TIME (DT): 8-byte BCD value (year/month/day/hour/min/sec + ms +
            // day-of-week nibble). S7.Net's DateTime.FromByteArray reads the BCD fields and returns a
            // System.DateTime; the DT range is 1990–2089 and round-trips to the millisecond. Boxed as
            // System.DateTime explicitly (the cast pins the box type, consistent with the numeric arms).
            S7DataType.DateTime => (object)global::S7.Net.Types.DateTime.FromByteArray(block),

            _ => throw new System.IO.InvalidDataException(
                $"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
                $"'{tag.Address}' parsed as Size={addr.Size}"),
        };
    }

    /// <summary>
    ///     Pure encode step — turns the caller's value into the raw S7 (big-endian) byte block
    ///     for a wide scalar write. No network I/O — factored out of
    ///     <see cref="WriteScalarBlockAsync"/> so the codec is unit-testable (mirrors
    ///     <see cref="BoxValueForWrite"/>).
    /// </summary>
    /// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
    /// <param name="value">Value to encode (coerced via <c>Convert.To*</c>).</param>
    /// <returns>The big-endian byte block to write.</returns>
    internal static byte[] EncodeScalarBlock(S7TagDefinition tag, object? value)
    {
        // Timer/Counter are READ-ONLY this phase. A Timer tag is typed Float64 and a Counter tag
        // Int32, so the DataType switch below would otherwise hand them a (wrong, 8/wide-byte)
        // encode arm — guard on the parsed AREA first and refuse the write. The address parses to
        // an S7Area without I/O; TryParse keeps a malformed address from masking the read-only
        // contract (a bad address falls through to the DataType arms and is caught upstream).
        if (S7AddressParser.TryParse(tag.Address, out var addr)
            && addr.Area is S7Area.Timer or S7Area.Counter)
        {
            throw new NotSupportedException(
                $"S7 Timer/Counter writes are read-only this phase (tag '{tag.Name}' at '{tag.Address}').");
        }

        switch (tag.DataType)
        {
            case S7DataType.Int64:
            {
                var b = new byte[8];
                System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(b, Convert.ToInt64(value));
                return b;
            }
            case S7DataType.UInt64:
            {
                var b = new byte[8];
                System.Buffers.Binary.BinaryPrimitives.WriteUInt64BigEndian(b, Convert.ToUInt64(value));
                return b;
            }
            case S7DataType.Float64:
            {
                var b = new byte[8];
                System.Buffers.Binary.BinaryPrimitives.WriteDoubleBigEndian(b, Convert.ToDouble(value));
                return b;
            }

            case S7DataType.String:
                // S7.Net's S7String.ToByteArray builds [maxLen=StringLength][curLen][chars…] and pads
                // the result to the full reserved field (StringLength + 2 bytes) — exactly the width
                // ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value
                // longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT
                // silently truncate). A null value encodes as the empty string.
                return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength);

            case S7DataType.DateTime:
                // S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the
                // 1990–2089 DT range and throws ArgumentOutOfRangeException for an out-of-range year —
                // we surface that (no silent clamp). Value coerced via Convert.ToDateTime (accepts a
                // System.DateTime or a parseable timestamp string).
                return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value));

            default:
                throw new InvalidOperationException(
                    $"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')");
        }
    }

    /// <summary>Width in bytes of one array element for the given access size. Bit elements are
    /// byte-granular over the wire (one byte per bool), so they cost 1 byte each.</summary>
    /// <param name="size">The parsed access width.</param>
    /// <returns>Element byte size: Bit/Byte = 1, Word = 2, DWord = 4.</returns>
    internal static int ElementByteSize(S7Size size) => size switch
    {
        S7Size.Bit => 1,
        S7Size.Byte => 1,
        S7Size.Word => 2,
        S7Size.DWord => 4,
        _ => throw new InvalidOperationException($"Unknown S7Size {size}"),
    };

    /// <summary>
    ///     Maps the driver's <see cref="S7Area"/> to S7.Net's <c>DataType</c> for the
    ///     buffer-based block read. Timer/Counter map to S7.Net's <c>Timer</c>/<c>Counter</c>
    ///     transport types — the <c>ScalarByteWidth</c> 2-byte read uses these to read one
    ///     timer/counter word (the array path never reaches Timer/Counter: wide-type arrays are
    ///     rejected at init and Timer/Counter are always scalar).
    /// </summary>
    private static S7NetDataType ToS7NetArea(S7Area area) => area switch
    {
        S7Area.DataBlock => S7NetDataType.DataBlock,
        S7Area.Memory => S7NetDataType.Memory,
        S7Area.Input => S7NetDataType.Input,
        S7Area.Output => S7NetDataType.Output,
        S7Area.Timer => S7NetDataType.Timer,
        S7Area.Counter => S7NetDataType.Counter,
        _ => throw new NotSupportedException($"S7 area {area} has no S7.Net DataType mapping"),
    };

    /// <summary>
    ///     Pure decode loop — turns a raw S7 (big-endian) byte block into an element-typed CLR
    ///     array (<c>short[]</c> / <c>ushort[]</c> / <c>int[]</c> / <c>uint[]</c> / <c>float[]</c>
    ///     / <c>byte[]</c> / <c>bool[]</c>), boxed as <see cref="object"/>. No network I/O —
    ///     factored out of <see cref="ReadArrayAsync"/> so the block-decode is unit-testable
    ///     against a known byte block without a live PLC (S7.Net ships no in-process fake).
    ///     Each element is read from its <c>i × element-bytes</c> slice using S7 big-endian byte
    ///     order, identical to the per-element semantics of <see cref="ReinterpretRawValue"/>.
    /// </summary>
    /// <param name="tag">Tag definition carrying the element <see cref="S7DataType"/> and array count.</param>
    /// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
    /// <param name="block">Raw contiguous byte block read from the PLC (length == count × element-bytes).</param>
    /// <returns>An element-typed CLR array boxed as <see cref="object"/>.</returns>
    internal static object DecodeArrayBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
    {
        var count = tag.ArrayCount!.Value;
        var elementBytes = ElementByteSize(addr.Size);

        switch (tag.DataType, addr.Size)
        {
            case (S7DataType.Bool, S7Size.Bit):
            {
                var a = new bool[count];
                for (var i = 0; i < count; i++)
                    a[i] = (block[i] & 0x01) != 0;
                return a;
            }
            case (S7DataType.Byte, S7Size.Byte):
            {
                var a = new byte[count];
                for (var i = 0; i < count; i++)
                    a[i] = block[i];
                return a;
            }
            case (S7DataType.UInt16, S7Size.Word):
            {
                var a = new ushort[count];
                for (var i = 0; i < count; i++)
                    a[i] = ReadBeUInt16(block, i * elementBytes);
                return a;
            }
            case (S7DataType.Int16, S7Size.Word):
            {
                var a = new short[count];
                for (var i = 0; i < count; i++)
                    a[i] = unchecked((short)ReadBeUInt16(block, i * elementBytes));
                return a;
            }
            case (S7DataType.UInt32, S7Size.DWord):
            {
                var a = new uint[count];
                for (var i = 0; i < count; i++)
                    a[i] = ReadBeUInt32(block, i * elementBytes);
                return a;
            }
            case (S7DataType.Int32, S7Size.DWord):
            {
                var a = new int[count];
                for (var i = 0; i < count; i++)
                    a[i] = unchecked((int)ReadBeUInt32(block, i * elementBytes));
                return a;
            }
            case (S7DataType.Float32, S7Size.DWord):
            {
                var a = new float[count];
                for (var i = 0; i < count; i++)
                    a[i] = BitConverter.UInt32BitsToSingle(ReadBeUInt32(block, i * elementBytes));
                return a;
            }

            case (S7DataType.Int64, _):
            case (S7DataType.UInt64, _):
            case (S7DataType.Float64, _):
            case (S7DataType.String, _):
            case (S7DataType.DateTime, _):
                throw new NotSupportedException(
                    $"S7 array reads of {tag.DataType} land in a follow-up PR");

            default:
                throw new System.IO.InvalidDataException(
                    $"S7 array Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
                    $"'{tag.Address}' parsed as Size={addr.Size}");
        }
    }

    /// <summary>Reads a big-endian 16-bit word from <paramref name="block"/> at <paramref name="offset"/>.</summary>
    private static ushort ReadBeUInt16(byte[] block, int offset) =>
        (ushort)((block[offset] << 8) | block[offset + 1]);

    /// <summary>Reads a big-endian 32-bit dword from <paramref name="block"/> at <paramref name="offset"/>.</summary>
    private static uint ReadBeUInt32(byte[] block, int offset) =>
        ((uint)block[offset] << 24) | ((uint)block[offset + 1] << 16)
        | ((uint)block[offset + 2] << 8) | block[offset + 3];

    /// <summary>
    ///     Pure reinterpret step — converts the boxed value that S7.Net returns (always an
    ///     unsigned type: <c>bool</c>, <c>byte</c>, <c>ushort</c>, <c>uint</c>) into the
    ///     SEMANTIC type declared by the tag's <see cref="S7DataType"/>. No network I/O.
    ///     Factored out of <see cref="ReadOneAsync"/> so it can be exercised in unit tests
    ///     without a live PLC.
    /// </summary>
    /// <param name="tag">Tag definition containing type information.</param>
    /// <param name="addr">Parsed tag address.</param>
    /// <param name="raw">Raw value from S7.Net.</param>
    /// <returns>The reinterpreted value in the target semantic type.</returns>
    internal static object ReinterpretRawValue(S7TagDefinition tag, S7ParsedAddress addr, object raw) =>
        (tag.DataType, addr.Size, raw) switch
        {
            (S7DataType.Bool, S7Size.Bit, bool b) => b,
            (S7DataType.Byte, S7Size.Byte, byte by) => by,
            (S7DataType.UInt16, S7Size.Word, ushort u16) => u16,
            (S7DataType.Int16, S7Size.Word, ushort u16) => unchecked((short)u16),
            (S7DataType.UInt32, S7Size.DWord, uint u32) => u32,
            (S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
            (S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),

            // Unreachable defensive backstop — every wide type (Int64/UInt64/Float64/String/
            // DateTime) AND every Timer/Counter tag routes through the byte-buffer path
            // (IsBufferType → ReadScalarBlockAsync → DecodeScalarBlock) before ReinterpretRawValue
            // is ever consulted. Kept (not deleted) so a future regression that mis-routes a wide
            // type to the narrow path fails loudly here instead of silently mis-decoding.
            (S7DataType.Int64, _, _)   => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
            (S7DataType.UInt64, _, _)  => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
            (S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
            (S7DataType.String, _, _)  => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),
            (S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"),

            _ => throw new System.IO.InvalidDataException(
                $"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
                $"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
        };

    // ---- IWritable ----

    /// <inheritdoc />
    public async Task<IReadOnlyList<WriteResult>> WriteAsync(
        IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
    {
        // Same as ReadAsync — validate before the init check so a null argument is a
        // typed argument error, not the "not initialized" surface.
        ArgumentNullException.ThrowIfNull(writes);
        RequireInitialized();
        var results = new WriteResult[writes.Count];

        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            // (Re)open under the gate; a failed (re)connect degrades every write to
            // BadCommunicationError rather than throwing.
            IS7Plc plc;
            try
            {
                plc = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
            }
            // Rethrow ONLY caller cancellation — a timeout-born OCE degrades the batch below rather
            // than escaping as teardown (STAB-14 defense-in-depth, mirrors ReadAsync).
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
            catch (Exception ex)
            {
                for (var i = 0; i < writes.Count; i++)
                    results[i] = new WriteResult(StatusBadCommunicationError);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
                return results;
            }

            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)
                {
                    results[i] = new WriteResult(StatusBadNotWritable);
                    continue;
                }
                // Timer/Counter write guard: an equipment tag authored writable=true (S7 mapper
                // honours the "writable" key, and node-level auth governs writes) whose address is a
                // Timer/Counter would bypass the !Writable gate above and reach EncodeScalarBlock,
                // which throws NotSupportedException → BadNotSupported. The docs say Timer/Counter
                // writes return BadNotWritable. Detect the area here before the call so it
                // consistently returns BadNotWritable (init guard-d also rejects a writable
                // Timer/Counter, but the mapper doesn't run that guard).
                // TryParse (not Parse) so a malformed address falls through to the existing
                // error path rather than throwing here.
                if (_parsedByRawPath.TryGetValue(tag.Name, out var tcParsed)
                        ? tcParsed.Area is S7Area.Timer or S7Area.Counter
                        : (S7AddressParser.TryParse(tag.Address, out tcParsed) && tcParsed.Area is S7Area.Timer or S7Area.Counter))
                {
                    results[i] = new WriteResult(StatusBadNotWritable);
                    continue;
                }
                try
                {
                    await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
                    results[i] = new WriteResult(0u);
                }
                catch (OperationCanceledException)
                {
                    // Cancellation observed mid-PDU abandons a half-written ISO-on-TCP request on
                    // the single gated stream — the handle must be reopened, not reused (STAB-15a).
                    // Mark dead (we hold _gate) and propagate: the caller cancelled, nobody consumes
                    // the batch, and Release() still runs in finally. Marking is unconditional on
                    // OCE — a false positive costs one cheap reopen versus permanent desync.
                    _plcDead = true;
                    throw;
                }
                catch (NotSupportedException)
                {
                    results[i] = new WriteResult(StatusBadNotSupported);
                }
                catch (PlcException pex) when (IsAccessDenied(pex))
                {
                    // PUT/GET-disabled / access-protection on write — same permanent
                    // configuration fault as on read. BadNotSupported +
                    // a config-alert health state, not a transient device failure.
                    results[i] = new WriteResult(StatusBadNotSupported);
                    _health = new DriverHealth(DriverState.Faulted, _health.LastSuccessfulRead,
                        "S7 access denied — enable PUT/GET communication in TIA Portal " +
                        $"(Protection & Security) for this CPU. PLC reported: {pex.Message}");
                }
                catch (PlcException pex)
                {
                    // Genuine device-layer fault — degrade health so a PLC-down-during-writes
                    // scenario is visible to the operator (previously health was never updated
                    // on write failure). A connection-fatal PlcException also marks the handle
                    // dead so the next call reopens.
                    MarkConnectionDeadIfFatal(pex);
                    results[i] = new WriteResult(StatusBadDeviceFailure);
                    _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
                }
                catch (Exception ex)
                {
                    // Socket/timeout/conversion failure. Map to BadCommunicationError (not
                    // BadInternalError) for transport faults; degrade health, and mark the handle
                    // dead when it's a connection loss.
                    MarkConnectionDeadIfFatal(ex);
                    results[i] = new WriteResult(StatusBadCommunicationError);
                    _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
                }
            }
        }
        finally { _gate.Release(); }
        return results;
    }

    private async Task WriteOneAsync(IS7Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
    {
        // Defence-in-depth guard: authored array tags are rejected at init
        // (RejectUnsupportedTagConfigs guard-e), but a transient equipment-tag ref resolved by
        // _resolver bypasses that path. Both should fail with NotSupportedException → BadNotSupported,
        // not InvalidCastException → BadCommunicationError from BoxValueForWrite receiving an array.
        if (tag.ArrayCount is >= 1)
            throw new NotSupportedException(
                $"S7 array writes are not yet supported (tag '{tag.Name}'). " +
                "Use individual scalar tags or set Writable=false for read-only arrays.");

        // Parse the address the same way ReadOneAsync does: every tag pre-parses at init
        // (_parsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the
        // wide-type write can byte-address the block (the narrow path below addresses by the raw
        // address string instead).
        var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed)
            ? parsed
            : S7AddressParser.Parse(tag.Address);

        // Wide/structured scalar path: encode the value to a big-endian byte block and write it
        // at the start byte via S7.Net's buffer-based WriteBytesAsync. Mirrors the read seam;
        // the narrow string-based write below stays unchanged for 1/2/4-byte types.
        if (IsBufferType(tag, addr))
        {
            await WriteScalarBlockAsync(plc, tag, addr, value, ct).ConfigureAwait(false);
            return;
        }

        // S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
        // match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
        // Our S7DataType lets the caller pass short/int/float; convert to the unsigned
        // wire representation before handing off.
        var boxed = BoxValueForWrite(tag.DataType, value);
        await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
    }

    /// <summary>
    ///     Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
    ///     buffer-based <c>Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct)</c>.
    ///     The pure <see cref="EncodeScalarBlock"/> produces the big-endian bytes; this method
    ///     owns only the network I/O (mirrors <see cref="ReadScalarBlockAsync"/>).
    /// </summary>
    private async Task WriteScalarBlockAsync(IS7Plc plc, S7TagDefinition tag, S7ParsedAddress addr, object? value, CancellationToken ct)
    {
        var bytes = EncodeScalarBlock(tag, value);
        await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, bytes, ct)
            .ConfigureAwait(false);
    }

    /// <summary>
    ///     Pure boxing step — converts the caller's value into the unsigned wire type that
    ///     S7.Net's <c>Plc.WriteAsync</c> expects for each address size (bool → bool, byte
    ///     → byte, short → ushort, int → uint, float → uint-bits). No network I/O.
    ///     Factored out of <see cref="WriteOneAsync"/> so it can be exercised in unit tests
    ///     without a live PLC.
    /// </summary>
    /// <param name="dataType">Target S7 data type.</param>
    /// <param name="value">Value to box.</param>
    /// <returns>The boxed value in the wire type expected by S7.Net.</returns>
    internal static object BoxValueForWrite(S7DataType dataType, object? value) => dataType switch
    {
        S7DataType.Bool    => (object)Convert.ToBoolean(value),
        S7DataType.Byte    => (object)Convert.ToByte(value),
        S7DataType.UInt16  => (object)Convert.ToUInt16(value),
        S7DataType.Int16   => (object)unchecked((ushort)Convert.ToInt16(value)),
        S7DataType.UInt32  => (object)Convert.ToUInt32(value),
        S7DataType.Int32   => (object)unchecked((uint)Convert.ToInt32(value)),
        S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),

        // Unreachable defensive backstop — every wide type routes through the byte-buffer write
        // path (IsBufferType → WriteScalarBlockAsync → EncodeScalarBlock) before BoxValueForWrite
        // is consulted. Kept (not deleted) so a mis-route fails loudly rather than mis-encoding.
        S7DataType.Int64   => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
        S7DataType.UInt64  => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
        S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
        S7DataType.String  => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
        S7DataType.DateTime => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"),
        _ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"),
    };

    private void RequireInitialized()
    {
        if (!_initialized) throw new InvalidOperationException("S7Driver not initialized");
    }

    /// <summary>
    ///     Returns a live S7 connection, lazily disposing a dead handle and re-opening a fresh one
    ///     when the previous connection dropped (the reconnect path). The fast path returns the
    ///     current <see cref="Plc"/> when it is still connected and not marked dead; otherwise it
    ///     builds + opens a new connection via <see cref="_plcFactory"/> with the same
    ///     timeout-linked CTS the initial connect uses.
    /// </summary>
    /// <remarks>
    ///     MUST be called while holding <see cref="_gate"/> — it mutates <see cref="Plc"/> /
    ///     <see cref="_plcDead"/> and reuses the single-connection-per-PLC invariant the gate
    ///     enforces. The read / write / probe paths all take the gate before calling it.
    ///     <para>
    ///         While the PLC is down every data call and probe tick pays a full connect attempt
    ///         bounded only by <c>_options.Timeout</c> (STAB-8). This method is the single
    ///         choke-point all S7 reconnects flow through (already gate-serialized), so the
    ///         fleet-wide connect-attempt throttle plugs in here. R2-09 (sub-batch B3/B4) shipped the
    ///         shared <see cref="ConnectionBackoff"/> primitive
    ///         (<c>src/Core/…/Core.Abstractions/ConnectionBackoff.cs</c>) — the S7 wiring is a
    ///         per-driver <c>ConnectionBackoff</c> field consulted with <c>ShouldAttempt(now)</c> at
    ///         the top of the slow path (fail fast + degrade inside the window) plus
    ///         <c>RecordFailure</c>/<c>RecordSuccess</c> around the connect, all under <c>_gate</c>
    ///         (which satisfies the primitive's caller-synchronized contract; TwinCAT/FOCAS/AbCip
    ///         already wire it this way). Deliberately NOT implemented driver-locally here — owned by
    ///         <c>archreview/plans/R2-01-s7-fault-hardening-plan.md</c> Finding 3.
    ///     </para>
    /// </remarks>
    /// <param name="ct">Cancellation token for the (re)connect attempt.</param>
    /// <returns>A live <see cref="IS7Plc"/>.</returns>
    private async Task<IS7Plc> EnsureConnectedAsync(CancellationToken ct)
    {
        // Fast path — the current connection is live and not flagged dead.
        if (!_plcDead && Plc is { IsConnected: true } live) return live;

        // Dispose the stale / dead handle before making a fresh one (exactly once, here).
        if (Plc is not null)
        {
            try { Plc.Dispose(); } catch { /* best-effort */ }
            Plc = null;
        }
        _plcDead = false;

        var plc = _plcFactory.Create(
            _options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot, _options.Timeout);
        try
        {
            using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            cts.CancelAfter(_options.Timeout);
            await plc.OpenAsync(cts.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            // The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
            // Surface it as a connection FAILURE (TimeoutException) so callers route it through
            // their degrade paths: an escaping OCE reads as teardown and permanently killed the
            // subscription poll loops (STAB-14, the unreachable-host regression in the Crit-3 fix).
            try { plc.Dispose(); } catch { /* best-effort */ }
            throw new TimeoutException(
                $"S7 connect to {_options.Host}:{_options.Port} timed out after " +
                $"{(int)_options.Timeout.TotalMilliseconds} ms.");
        }
        catch
        {
            try { plc.Dispose(); } catch { /* best-effort */ }
            throw;
        }

        Plc = plc;
        _logger.LogInformation("S7Driver reconnected. Driver={DriverInstanceId} Host={Host} CPU={CpuType}",
            _driverInstanceId, _options.Host, _options.CpuType);
        return plc;
    }

    /// <summary>
    ///     Flags the current connection dead when <paramref name="ex"/> is a connection-level loss,
    ///     so the next <see cref="EnsureConnectedAsync"/> reopens. A no-op for data-address / type
    ///     faults — those don't warrant tearing down a healthy socket.
    /// </summary>
    /// <param name="ex">The exception observed on a read / write.</param>
    private void MarkConnectionDeadIfFatal(Exception ex)
    {
        if (IsS7ConnectionFatal(ex)) _plcDead = true;
    }

    /// <summary>
    ///     True when <paramref name="ex"/> (or any inner exception) is a socket-level / connection
    ///     loss OR an ISO-on-TCP framing/desync fault that a reopen can repair — a
    ///     <see cref="System.Net.Sockets.SocketException"/> / <see cref="System.IO.IOException"/> /
    ///     <see cref="ObjectDisposedException"/>; a <see cref="TimeoutException"/> from a wire op that
    ///     blew its <see cref="S7OperationDeadline"/> wall-clock ceiling (an unresponsive but still
    ///     TCP-established peer — the READ-leg sibling of the connect-timeout fix, STAB-14); an S7.Net framing violation
    ///     (<see cref="TPKTInvalidException"/> / <see cref="TPDUInvalidException"/> /
    ///     <see cref="WrongNumberOfBytesException"/>); or a <see cref="PlcException"/> carrying
    ///     <see cref="ErrorCode.ConnectionError"/> or <see cref="ErrorCode.WrongNumberReceivedBytes"/>.
    ///     A framing violation means the single serialized stream's position is untrustworthy — a
    ///     half-read PDU left by a timeout/cancellation makes every later response mis-frame, so only
    ///     a reopen recovers (STAB-15a: the Modbus STAB-3 desync mode rebuilt in S7).
    ///     <para>
    ///         Deliberately NOT <see cref="System.IO.InvalidDataException"/>: the driver's own decode
    ///         backstop (<see cref="ReinterpretRawValue"/> / <see cref="DecodeScalarBlock"/>) throws
    ///         it for a declared-type/address-size CONFIG mismatch on a HEALTHY socket — classifying
    ///         it would churn a full reopen on every read of a mis-authored tag. A genuinely desynced
    ///         stream's very next PDU still lands in TPKT/TPDU/WrongNumber territory, which now tears
    ///         down, so a real desync self-heals within one extra failed call.
    ///     </para>
    ///     A data-address / type error (S7.Net's <see cref="ErrorCode.ReadData"/> /
    ///     <see cref="ErrorCode.WriteData"/> for a bad address, PUT/GET-denied, etc.) is deliberately
    ///     NOT treated as fatal — reopening wouldn't help and would churn a healthy connection.
    /// </summary>
    /// <param name="ex">The exception to classify.</param>
    /// <returns><c>true</c> when a reconnect is warranted.</returns>
    private static bool IsS7ConnectionFatal(Exception ex)
    {
        for (Exception? e = ex; e is not null; e = e.InnerException)
        {
            if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException)
                return true;
            // A wire op that blew its wall-clock deadline (S7OperationDeadline — the async read/write
            // ceiling S7.Net's ignored socket ReadTimeout/WriteTimeout can't provide) means the
            // connection is unresponsive: discard + reopen. This is the READ-leg sibling of the
            // connect-timeout fix (STAB-14) — without it a frozen-but-established peer wedges the
            // poll loop forever instead of surfacing Bad + reconnecting.
            if (e is TimeoutException)
                return true;
            // ISO-on-TCP framing/desync surface (STAB-15a): the stream position is untrustworthy —
            // a half-read PDU left by a timeout/cancellation makes every later response mis-frame.
            // NOTE: deliberately NOT System.IO.InvalidDataException — ReinterpretRawValue throws it
            // for a declared-type/address-size CONFIG mismatch on a healthy socket.
            if (e is TPKTInvalidException or TPDUInvalidException or WrongNumberOfBytesException)
                return true;
            if (e is PlcException { ErrorCode: ErrorCode.ConnectionError or ErrorCode.WrongNumberReceivedBytes })
                return true;
        }
        return false;
    }

    /// <summary>
    ///     Detects an S7 PUT/GET-disabled / access-protection fault inside an S7.Net
    ///     <see cref="PlcException"/>. S7.Net's read/write paths wrap every PLC-side error in a
    ///     <c>PlcException</c> with <see cref="ErrorCode.ReadData"/> / <see cref="ErrorCode.WriteData"/>;
    ///     the response-code validator throws a plain <see cref="Exception"/> for the S7
    ///     <c>AccessingObjectNotAllowed</c> status, which lands as the inner exception. There is
    ///     no typed error code for it, so the inner message is the only discriminator
    ///     S7.Net exposes.
    /// </summary>
    private static bool IsAccessDenied(PlcException pex)
    {
        for (Exception? e = pex; e is not null; e = e.InnerException)
        {
            if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase)
                || e.Message.Contains("not allowed", StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
        }
        return false;
    }

    // ---- ITagDiscovery ----

    /// <inheritdoc />
    // Run-once: DiscoverAsync emits the complete node set synchronously from the configured tag
    // table in a single pass — nothing fills in asynchronously after connect, so a single
    // discovery pass is sufficient.
    public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;

    /// <inheritdoc />
    public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        var folder = builder.Folder("S7", "S7");
        foreach (var t in _tagsByRawPath.Values)
        {
            // A tag carrying a non-null array count (>= 1) surfaces as a 1-D OPC UA array node.
            // A null count stays scalar. A count of 1 IS a valid 1-element array: the foundation
            // materialises a [1] OPC UA array node when isArray:true, so the driver must agree.
            var isArray = t.ArrayCount is >= 1;
            folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
                FullName: t.Name,
                DriverDataType: MapDataType(t.DataType),
                IsArray: isArray,
                ArrayDim: isArray ? (uint)t.ArrayCount!.Value : null,
                SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
                IsHistorized: false,
                IsAlarm: false,
                WriteIdempotent: t.WriteIdempotent));
        }
        return Task.CompletedTask;
    }

    private static DriverDataType MapDataType(S7DataType t) => t switch
    {
        S7DataType.Bool => DriverDataType.Boolean,
        S7DataType.Byte => DriverDataType.Int32,      // no 8-bit in DriverDataType yet
        // UInt32 values > int.MaxValue (2^31-1) wrap negative when surfaced as
        // Int32. That residual lossy note now applies ONLY to UInt32 — Int64/UInt64 map to
        // their own DriverDataType members below (Phase 4d), so they are no longer lossy.
        S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
        S7DataType.Int64 => DriverDataType.Int64,
        S7DataType.UInt64 => DriverDataType.UInt64,
        S7DataType.Float32 => DriverDataType.Float32,
        S7DataType.Float64 => DriverDataType.Float64,
        S7DataType.String => DriverDataType.String,
        S7DataType.DateTime => DriverDataType.DateTime,
        _ => DriverDataType.Int32,
    };

    // ---- ISubscribable (polling overlay) ----

    /// <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;
    }

    /// <summary>
    ///     05/STAB-9 — routes a poll-loop reader failure (surfaced by the shared
    ///     <see cref="PollGroupEngine"/>'s onError sink) 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 (e.g. PUT/GET-denied set by ReadAsync)
    ///     — Faulted is the stronger, permanent-config signal. Replaces the retired fork's
    ///     <c>HandlePollFailure</c>; the engine now owns the consecutive-failure count + backoff.
    /// </summary>
    /// <param name="ex">The exception caught by the poll engine.</param>
    private void HandlePollError(Exception ex)
    {
        _logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
        if (_health.State != DriverState.Faulted)
            _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
    }

    // ---- IHostConnectivityProbe ----

    /// <summary>
    ///     Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
    ///     matches the Modbus driver's convention so the Admin UI dashboard renders both
    ///     family's rows uniformly.
    /// </summary>
    public string HostName => $"{_options.Host}:{_options.Port}";

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

    private async Task ProbeLoopAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var success = false;
            try
            {
                // Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
                // and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
                // would also work but GetCpuStatus doubles as a "PLC actually up" check.
                using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                probeCts.CancelAfter(_options.Probe.Timeout);

                await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
                try
                {
                    try
                    {
                        // EnsureConnectedAsync doubles as the reconnect backstop: if a read/write
                        // marked the handle dead (or the socket dropped between ticks), the probe
                        // reopens it here so recovery doesn't wait for the next data call.
                        var plc = await EnsureConnectedAsync(probeCts.Token).ConfigureAwait(false);
                        await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
                        success = true;
                    }
                    catch (OperationCanceledException) when (ct.IsCancellationRequested)
                    {
                        // Loop teardown — rethrow to the outer filter (after finally releases the gate).
                        throw;
                    }
                    catch (OperationCanceledException)
                    {
                        // Probe deadline fired mid-PDU: the CPU-status response may still be in
                        // flight, so the handle can't be reused (half-read ⇒ desync). We hold _gate
                        // here, keeping _plcDead gate-guarded (STAB-15b).
                        _plcDead = true;
                    }
                    catch (Exception ex)
                    {
                        // STAB-15b: classify + mark while holding _gate so a wire-dead/desynced
                        // handle whose TcpClient still claims Connected is reopened by the NEXT
                        // tick's EnsureConnectedAsync instead of being re-probed forever.
                        MarkConnectionDeadIfFatal(ex);
                    }
                }
                finally { _gate.Release(); }
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
            catch { /* gate-wait timeout / other — treated as Stopped below (gate contention is not a connection fault) */ }

            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;
        }
        _logger.LogInformation("S7 probe transition. Driver={DriverInstanceId} Host={Host} {OldState} → {NewState}",
            _driverInstanceId, HostName, old, newState);
        OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
    }

    /// <summary>Disposes the driver and releases resources.</summary>
    public void Dispose()
    {
        // Avoid the sync-over-async DisposeAsync().AsTask().GetAwaiter().GetResult()
        // pattern (a known deadlock surface even when currently safe here). ShutdownAsync's
        // body is effectively synchronous apart from waiting on probe/poll Tasks; do the same
        // teardown directly, blocking only on the drain — and only with a bounded timeout so
        // a wedged loop can't hang Dispose() indefinitely.
        if (_disposed) return;
        _disposed = true;
        SynchronousTeardown();
        _gate.Dispose();
    }

    /// <summary>Asynchronously disposes the driver and releases resources.</summary>
    /// <returns>A task representing the asynchronous operation.</returns>
    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;
        try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
        catch { /* disposal is best-effort */ }
        _gate.Dispose();
    }

    /// <summary>
    ///     Synchronous teardown — mirrors <see cref="ShutdownAsync"/> but blocks (with a bounded
    ///     timeout) on the probe + poll Tasks instead of awaiting them. Used by the sync
    ///     <see cref="Dispose"/> path so we don't sync-over-async <see cref="DisposeAsync"/>.
    /// </summary>
    private void SynchronousTeardown()
    {
        // Drain the polling overlay (drain-before-dispose, STAB-6-S7).
        try { _poll.DisposeAsync().AsTask().Wait(DrainTimeout); }
        catch { /* timeouts/loop faults are tolerated — teardown continues */ }

        var probeCts = _probeCts;
        var probeTask = _probeTask;
        try { probeCts?.Cancel(); } catch { }
        if (probeTask is not null)
        {
            try { probeTask.Wait(DrainTimeout); }
            catch { /* timeouts/loop faults are tolerated — teardown continues */ }
        }

        probeCts?.Dispose();
        _probeCts = null;
        _probeTask = null;

        _initialized = false;
        _plcDead = false;
        try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
        Plc = null;
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }
}
