using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;

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

/// <summary>
///     Allen-Bradley CIP / EtherNet-IP driver for ControlLogix / CompactLogix / Micro800 /
///     GuardLogix families. Implements all read/write/subscribe/discover/probe/alarm
///     capabilities via the libplctag.NET wrapper.
/// </summary>
/// <remarks>
///     <para>Wire layer is libplctag 1.6.x. Per-device host addresses use
///     the <c>ab://gateway[:port]/cip-path</c> canonical form parsed via
///     <see cref="AbCipHostAddress.TryParse"/>; those strings become the <c>hostName</c> key
///     for Polly per-host circuit-breaker isolation.</para>
///
///     <para>Tier A — in-process, shares server lifetime, no
///     sidecar. <see cref="ReinitializeAsync"/> is the Tier-B escape hatch for recovering
///     from native-heap growth that the CLR allocator can't see; it tears down the
///     libplctag.NET <c>Tag</c> instances held in <c>DeviceState.Runtimes</c> and reconnects
///     each device. Native tag lifetime is owned by the libplctag.NET <c>Tag.Dispose()</c>
///     (called in <see cref="DeviceState.DisposeHandles"/>); the library's own finalizer
///     handles GC-collected tags.</para>
/// </remarks>
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
    IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
    private AbCipDriverOptions _options;
    private readonly string _driverInstanceId;
    private readonly IAbCipTagFactory _tagFactory;
    private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
    private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
    private readonly AbCipTemplateCache _templateCache = new();

    /// <summary>
    ///     Maximum nesting depth the controller-discovered UDT fan-out recurses through. A member
    ///     whose path would sit deeper than this (a struct-of-struct chain longer than the cap) is
    ///     dropped rather than emitted, bounding both browse-tree breadth and the recursion. Depth
    ///     is counted in dotted segments below the discovered parent (the parent's direct atomic
    ///     members sit at depth 1).
    /// </summary>
    public const int MaxUdtDepth = 8;

    // Name-keyed UDT shapes consulted by the controller-discovered fan-out, keyed on
    // (deviceHostAddress, structTypeOrMemberName). PRODUCTION REALITY of the two population paths:
    //   * TOP-LEVEL discovered UDT — FUNCTIONAL in production. The Symbol Object decoder surfaces the
    //     discovered tag's CIP template instance id (AbCipDiscoveredTag.TemplateInstanceId), which
    //     DiscoverAsync threads into ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the
    //     Template Object off the controller and cache it in the id-keyed _templateCache. No seed
    //     needed.
    //   * NESTED struct members — FUNCTIONAL in production. A decoded Template Object member block carries
    //     the nested UDT's template instance id (low 12 bits of the member info, captured by
    //     CipTemplateObjectDecoder onto AbCipUdtMember.NestedTemplateId), which the fan-out threads into
    //     ResolveDiscoveredUdtShapeAsync -> FetchUdtShapeAsync to read the nested Template Object off the
    //     controller. The name-keyed seed (SeedDiscoveredUdtShapeForTest) still wins first for tests; a
    //     nested member whose id can't be resolved is dropped (the top-level UDT still expands).
    private readonly Dictionary<string, AbCipUdtShape> _discoveredUdtShapes =
        new(StringComparer.OrdinalIgnoreCase);

    // Composite key for _discoveredUdtShapes: device + struct/member name joined by a NUL ('\0').
    // A NUL appears in neither a canonical ab:// host address nor a Logix symbol name, so the
    // separator is unambiguous regardless of what either part contains.
    private static string UdtShapeKey(string deviceHostAddress, string structName) =>
        $"{deviceHostAddress}\0{structName}";

    /// <summary>
    ///     Test seam — seed a discovered-UDT shape under <paramref name="structName"/> for
    ///     <paramref name="deviceHostAddress"/>, consulted by name before any id-keyed fetch. Lets a
    ///     fan-out test link a nested sub-shape by member name without a live Template Object read.
    ///     Neither the TOP-LEVEL discovered UDT nor a NESTED-struct member needs a seed in production:
    ///     both carry a template instance id (the top level on
    ///     <see cref="AbCipDiscoveredTag.TemplateInstanceId"/>, a nested member on
    ///     <see cref="AbCipUdtMember.NestedTemplateId"/>) that the fan-out fetches via
    ///     <see cref="FetchUdtShapeAsync"/>.
    /// </summary>
    /// <param name="deviceHostAddress">The device the struct lives on.</param>
    /// <param name="structName">The discovered struct type or nested-member name.</param>
    /// <param name="shape">The shape to associate with that name.</param>
    internal void SeedDiscoveredUdtShapeForTest(string deviceHostAddress, string structName, AbCipUdtShape shape) =>
        _discoveredUdtShapes[UdtShapeKey(deviceHostAddress, structName)] = shape;

    private readonly PollGroupEngine _poll;
    // Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics).
    private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
    // Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias)
    // → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on
    // the RawPath's device segment. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection
    // host moves into the Device row's DeviceConfig.
    private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
    // v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
    // ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
    private readonly Dictionary<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
    // Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order —
    // the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths.
    private readonly List<AbCipTagDefinition> _declaredTags = new();

    // Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a
    // single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback
    // is retired — see EquipmentTagRefResolver).
    private readonly EquipmentTagRefResolver<AbCipTagDefinition> _resolver;

    private readonly ILogger<AbCipDriver> _logger;
    private AbCipAlarmProjection _alarmProjection;
    private DriverHealth _health = new(DriverState.Unknown, null, null);

    /// <summary>Occurs when a subscribed tag's value changes.</summary>
    public event EventHandler<DataChangeEventArgs>? OnDataChange;

    /// <summary>Occurs when a device's host connectivity status changes.</summary>
    public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;

    /// <summary>Occurs when an alarm event is raised.</summary>
    public event EventHandler<AlarmEventArgs>? OnAlarmEvent;

    /// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
    /// <param name="args">The alarm event arguments.</param>
    internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);

    /// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
    /// <param name="options">The driver configuration options.</param>
    /// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
    /// <param name="tagFactory">Optional factory for creating tag runtimes; uses libplctag default if null.</param>
    /// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
    /// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
    /// <param name="logger">Optional logger; uses null logger if not provided.</param>
    public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
        IAbCipTagFactory? tagFactory = null,
        IAbCipTagEnumeratorFactory? enumeratorFactory = null,
        IAbCipTemplateReaderFactory? templateReaderFactory = null,
        ILogger<AbCipDriver>? logger = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        _options = options;
        _driverInstanceId = driverInstanceId;
        _tagFactory = tagFactory ?? new LibplctagTagFactory();
        _enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
        _templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
        _logger = logger ?? NullLogger<AbCipDriver>.Instance;
        _resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
            r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
        _poll = new PollGroupEngine(
            reader: ReadAsync,
            onChange: (handle, tagRef, snapshot) =>
                OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
            onError: HandlePollError,
            backoffCap: PollBackoffCap);
        _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
    }

    /// <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, "AbCip poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
        var current = _health;
        if (current.State != DriverState.Faulted)
            _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
    }

    /// <summary>
    ///     Fetch + cache the shape of a Logix UDT by template instance id. First call reads
    ///     the Template Object off the controller; subsequent calls for the same
    ///     <c>(deviceHostAddress, templateInstanceId)</c> return the cached shape without
    ///     additional network traffic. <c>null</c> on template-not-found / decode failure so
    ///     callers can fall back to declaration-driven UDT fan-out.
    /// </summary>
    /// <param name="deviceHostAddress">The host address of the device to read the template from.</param>
    /// <param name="templateInstanceId">The instance ID of the UDT template to fetch.</param>
    /// <param name="cancellationToken">Cancellation token for the operation.</param>
    /// <returns>The UDT shape if found and decoded successfully; null otherwise.</returns>
    internal async Task<AbCipUdtShape?> FetchUdtShapeAsync(
        string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken)
    {
        var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId);
        if (cached is not null) return cached;

        if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;

        var deviceParams = device.BuildCreateParams($"@udt/{templateInstanceId}", _options.Timeout);

        try
        {
            using var reader = _templateReaderFactory.Create();
            var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
            var shape = CipTemplateObjectDecoder.Decode(buffer);
            if (shape is not null)
                _templateCache.Put(deviceHostAddress, templateInstanceId, shape);
            return shape;
        }
        catch (OperationCanceledException) { throw; }
        catch (Exception ex)
        {
            // Template read failure — surface via the driver's health surface AND a warning
            // log so operators see it; don't propagate since callers should fall back to
            // declaration-driven UDT semantics rather than failing the whole discovery run.
            _logger.LogWarning(ex,
                "AbCip driver {DriverInstanceId} failed to read UDT template {TemplateInstanceId} from device {Device}; " +
                "falling back to declaration-driven UDT semantics",
                _driverInstanceId, templateInstanceId, deviceHostAddress);
            return null;
        }
    }

    /// <summary>
    ///     Resolve the shape of a controller-discovered struct/UDT (or a nested-struct member) by
    ///     name. Consults the name-keyed <see cref="_discoveredUdtShapes"/> first (the source for
    ///     nested-struct sub-shapes + the test seam), then — for the top level only — the id-keyed
    ///     template cache via <see cref="FetchUdtShapeAsync"/> when a template instance id is
    ///     available. Returns <c>null</c> when no shape can be resolved so the caller degrades to a
    ///     single Variable rather than emitting a broken fan-out.
    /// </summary>
    /// <param name="deviceHostAddress">The device the struct lives on.</param>
    /// <param name="structName">The discovered struct type name or a nested-member name.</param>
    /// <param name="templateInstanceId">Template instance id when known — the top-level UDT's id, or a
    ///     nested-struct member's <see cref="AbCipUdtMember.NestedTemplateId"/> decoded from the parent
    ///     shape; <c>null</c> only when neither a seeded name-keyed shape nor an id can resolve the struct
    ///     (the caller then degrades to a single Variable rather than a broken fan-out).</param>
    /// <param name="cancellationToken">Cancellation token for any live template read.</param>
    private async Task<AbCipUdtShape?> ResolveDiscoveredUdtShapeAsync(
        string deviceHostAddress, string structName, uint? templateInstanceId, CancellationToken cancellationToken)
    {
        if (_discoveredUdtShapes.TryGetValue(UdtShapeKey(deviceHostAddress, structName), out var seeded))
            return seeded;

        if (templateInstanceId is { } id)
            return await FetchUdtShapeAsync(deviceHostAddress, id, cancellationToken).ConfigureAwait(false);

        return null;
    }

    /// <summary>
    ///     Shared UDT template cache populated by <see cref="FetchUdtShapeAsync"/>. Exposed
    ///     internally so tests + diagnostics can inspect cached shapes.
    /// </summary>
    internal AbCipTemplateCache TemplateCache => _templateCache;

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

    /// <inheritdoc />
    public string DriverType => "AbCip";

    /// <inheritdoc />
    public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        _health = new DriverHealth(DriverState.Initializing, null, null);
        try
        {
            if (!string.IsNullOrWhiteSpace(driverConfigJson))
            {
                var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
                if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
                {
                    _options = parsed;
                    _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
                }
            }

            foreach (var device in _options.Devices)
            {
                var addr = AbCipHostAddress.TryParse(device.HostAddress)
                    ?? throw new InvalidOperationException(
                        $"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
                var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
                var state = new DeviceState(addr, device, profile);
                _devices[device.HostAddress] = state;
                // Route index: a tag reaches this device by matching its DeviceName routing key OR its
                // host address. Devices with no explicit DeviceName are identified purely by host today.
                _devicesByRoutingKey[device.HostAddress] = state;
                if (!string.IsNullOrEmpty(device.DeviceName))
                    _devicesByRoutingKey[device.DeviceName] = state;
            }

            // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob
            // is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are
            // threaded onto the def (they live 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 (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
                {
                    _logger.LogWarning(
                        "AbCip tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
                        _driverInstanceId, entry.RawPath);
                    continue;
                }
                var tag = mapped with { DeviceHostAddress = entry.DeviceName, WriteIdempotent = entry.WriteIdempotent };

                // Duplicate-key check: a collision means two authored tags share the same RawPath.
                // Fail fast at init time with a diagnostic rather than silently clobbering.
                if (_tagsByRawPath.TryGetValue(tag.Name, out var existingTag))
                    throw new InvalidOperationException(
                        $"AbCip tag name collision: '{tag.Name}' is declared more than once. " +
                        $"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " +
                        $"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate.");
                _tagsByRawPath[tag.Name] = tag;
                _declaredTags.Add(tag);

                if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
                {
                    foreach (var member in tag.Members)
                    {
                        var memberTag = new AbCipTagDefinition(
                            Name: $"{tag.Name}.{member.Name}",
                            DeviceHostAddress: tag.DeviceHostAddress,
                            TagPath: $"{tag.TagPath}.{member.Name}",
                            DataType: member.DataType,
                            Writable: member.Writable,
                            WriteIdempotent: member.WriteIdempotent,
                            // Carry the member's array shape into the fanned-out
                            // runtime definition. Discovery already emits an array node for an array
                            // member (member.IsArray || member.ElementCount > 1); without these the
                            // runtime def defaulted to scalar and the read returned a single element
                            // instead of the typed array, a declared-type-vs-runtime-value mismatch.
                            ElementCount: member.ElementCount,
                            IsArray: member.IsArray);
                        // Member fan-out duplicate check: a member-path collision means two
                        // configured structure tags produce the same member path, or a member
                        // name collides with an independently-declared tag.
                        if (_tagsByRawPath.TryGetValue(memberTag.Name, out var existingMember))
                            throw new InvalidOperationException(
                                $"AbCip tag name collision: '{memberTag.Name}' is produced by both " +
                                $"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " +
                                $"'{existingMember.Name}'. Rename one of the configured tags to resolve.");
                        _tagsByRawPath[memberTag.Name] = memberTag;
                    }
                }
            }

            // Probe loops — one per device when enabled + a ProbeTagPath is configured.
            if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
            {
                foreach (var state in _devices.Values)
                {
                    state.ProbeCts = new CancellationTokenSource();
                    var ct = state.ProbeCts.Token;
                    // Keep the loop Task so ShutdownAsync can await its clean exit before
                    // disposing the CTS / handles the loop is still using.
                    state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
                }
            }
            else if (_options.Probe.Enabled && _devices.Count > 0)
            {
                // Probe is Enabled but no ProbeTagPath is configured. Without a
                // tag path the loop has nothing to read, so HostState would stay Unknown forever
                // and GetHostStatuses() would report every device as Unknown with no warning.
                // Log a warning so the misconfiguration is visible in the rolling Serilog file.
                _logger.LogWarning(
                    "AbCip probe is enabled but no ProbeTagPath is configured for driver {DriverInstanceId} — " +
                    "host connectivity probe loops were NOT started; GetHostStatuses() will report every device " +
                    "as Unknown until a ProbeTagPath is set or Probe.Enabled is set to false.",
                    _driverInstanceId);
            }
            _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
        }
        catch (Exception ex)
        {
            _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
            _logger.LogError(ex, "AbCip driver {DriverInstanceId} failed to initialize", _driverInstanceId);
            throw;
        }
        return Task.CompletedTask;
    }

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

    /// <inheritdoc />
    public async Task ShutdownAsync(CancellationToken cancellationToken)
    {
        await _alarmProjection.DisposeAsync().ConfigureAwait(false);
        await _poll.DisposeAsync().ConfigureAwait(false);

        // Phase 1: signal every probe loop to stop.
        foreach (var state in _devices.Values)
        {
            try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
        }

        // Phase 2: wait for each probe loop to observe cancellation and exit. The loop never
        // throws on cancellation (it catches OperationCanceledException internally), but guard
        // anyway so one slow device can't wedge the whole shutdown.
        foreach (var state in _devices.Values)
        {
            var probeTask = state.ProbeTask;
            if (probeTask is null) continue;
            try
            {
                await probeTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
            }
            catch (TimeoutException) { }
            catch (OperationCanceledException) { }
        }

        // Phase 3: now the loops are gone, dispose the CTS + native handles with no live reader.
        foreach (var state in _devices.Values)
        {
            state.ProbeCts?.Dispose();
            state.ProbeCts = null;
            state.ProbeTask = null;
            state.DisposeHandles();
        }
        _devices.Clear();
        _devicesByRoutingKey.Clear();
        _tagsByRawPath.Clear();
        _declaredTags.Clear();
        _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }

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

    // ---- IAlarmSource (ALMD projection) ----

    /// <inheritdoc />
    public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
        IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
    {
        if (!_options.EnableAlarmProjection)
        {
            var disabled = new AbCipAlarmSubscriptionHandle(0);
            return Task.FromResult<IAlarmSubscriptionHandle>(disabled);
        }
        return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
    }

    /// <inheritdoc />
    public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
        _options.EnableAlarmProjection
            ? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
            : Task.CompletedTask;

    /// <inheritdoc />
    public Task AcknowledgeAsync(
        IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
        _options.EnableAlarmProjection
            ? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken)
            : Task.CompletedTask;

    // ---- IHostConnectivityProbe ----

    /// <inheritdoc />
    public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
        [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];

    private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
    {
        var probeParams = state.BuildCreateParams(_options.Probe.ProbeTagPath!, _options.Probe.Timeout);

        IAbCipTagRuntime? probeRuntime = null;
        while (!ct.IsCancellationRequested)
        {
            var success = false;
            try
            {
                probeRuntime ??= _tagFactory.Create(probeParams);
                // Lazy-init on first attempt; re-init after a transport failure has caused the
                // native handle to be destroyed.
                if (!state.ProbeInitialized)
                {
                    await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
                    state.ProbeInitialized = true;
                }
                await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
                success = probeRuntime.GetStatus() == 0;
                if (success)
                    state.Backoff.RecordSuccess(); // 05/STAB-8: device reachable — reset the data-path window
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                // Wire / init error — tear down the probe runtime so the next tick re-creates it.
                // Log at debug because a wedged device produces one per tick; the
                // OnHostStatusChanged event is the persistent record once the state transitions.
                _logger.LogDebug(ex,
                    "AbCip probe tick failed for driver {DriverInstanceId} device {Device}",
                    _driverInstanceId, state.Options.HostAddress);
                try { probeRuntime?.Dispose(); } catch { }
                probeRuntime = null;
                state.ProbeInitialized = false;
                state.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: keep the data-path window open
            }

            TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);

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

        try { probeRuntime?.Dispose(); } catch { }
    }

    private void TransitionDeviceState(DeviceState state, HostState newState)
    {
        HostState old;
        lock (state.ProbeLock)
        {
            old = state.HostState;
            if (old == newState) return;
            state.HostState = newState;
            state.HostStateChangedUtc = DateTime.UtcNow;
        }
        OnHostStatusChanged?.Invoke(this,
            new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
    }

    // ---- IPerCallHostResolver ----

    /// <summary>
    ///     Resolve a read/write/subscribe reference (a RawPath in v3) to the device host that keys its
    ///     per-host resilience (bulkhead / circuit breaker). CONV-4: routes through the authored table so a
    ///     tag resolves to its OWN device's host rather than the first-device fallback — else a broken
    ///     device B would trip device A's breaker. The tag's device routing key is resolved to the device's
    ///     actual host address (the resilience key), matching by DeviceName-or-host.
    /// </summary>
    /// <param name="fullReference">The tag RawPath reference.</param>
    /// <returns>The device host key.</returns>
    public string ResolveHost(string fullReference)
    {
        if (_resolver.TryResolve(fullReference, out var def)
            && _devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var state))
            return state.Options.HostAddress;
        return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
    }

    // ---- IReadable ----

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

        // Plan the batch: members of the same parent UDT get collapsed into one
        // whole-UDT read + in-memory member decode; every other reference falls back to the
        // per-tag read path. Planner is a pure function over the
        // current tag map; BOOL/String/Structure members stay on the fallback path because
        // declaration-only offsets can't place them under Logix alignment rules. Whole-UDT
        // grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
        // reorder UDT members vs declaration order, so the fast path is opt-in only.
        var plan = AbCipUdtReadPlanner.Build(
            fullReferences, _tagsByRawPath, _options.EnableDeclarationOnlyUdtGrouping);

        foreach (var group in plan.Groups)
            await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);

        foreach (var fb in plan.Fallbacks)
            await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);

        return results;
    }

    private async Task ReadSingleAsync(
        AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
    {
        if (!_resolver.TryResolve(reference, out var def))
        {
            results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
            return;
        }
        // A Structure tag whose Members are declared is a container —
        // its bare name is readable via the whole-UDT grouping path (ReadGroupAsync), not the
        // per-tag path. Reading it here returns BadNotSupported rather than Good/null so the
        // caller knows to address individual member paths (e.g. "Motor.Speed").
        if (def.DataType == AbCipDataType.Structure && def.Members is { Count: > 0 })
        {
            results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
            return;
        }
        if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
        {
            results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
            return;
        }

        try
        {
            var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);

            // Serialise Read → GetStatus → Decode against the shared runtime — the cached libplctag
            // Tag handle is not concurrency-safe and GetStatus returns the LAST op's status (05/STAB-4).
            var opLock = device.GetRuntimeLock(def.Name);
            await opLock.WaitAsync(ct).ConfigureAwait(false);
            try
            {
                await runtime.ReadAsync(ct).ConfigureAwait(false);

                var status = runtime.GetStatus();
                if (status != 0)
                {
                    // Evict the stale handle so the next call re-creates it.
                    // A non-zero status can mean the controller dropped the connection or the tag
                    // handle became permanently invalid (e.g. after a PLC download). Evicting
                    // mirrors the probe loop's recreate-on-failure behaviour.
                    EvictRuntime(device, def.Name);
                    results[fb.OriginalIndex] = new DataValueSnapshot(null,
                        AbCipStatusMapper.MapLibplctagStatus(status), null, now);
                    _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
                        $"libplctag status {status} reading {reference}");
                    _logger.LogWarning(
                        "AbCip read returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
                        "evicting cached runtime so next call re-creates it",
                        status, reference, def.DeviceHostAddress);
                    return;
                }

                var tagPath = AbCipTagPath.TryParse(def.TagPath);
                var bitIndex = tagPath?.BitIndex;
                // Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
                // element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
                // (ElementCount 1). Scalar tags keep the single-value path.
                var value = IsArrayTag(def)
                    ? runtime.DecodeArray(def.DataType, Math.Max(1, def.ElementCount))
                    : runtime.DecodeValue(def.DataType, bitIndex);
                results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
                _health = new DriverHealth(DriverState.Healthy, now, null);
            }
            finally
            {
                opLock.Release();
            }
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            // Transport exception — evict so the next read creates a fresh handle.
            EvictRuntime(device, def.Name);
            results[fb.OriginalIndex] = new DataValueSnapshot(null,
                AbCipStatusMapper.BadCommunicationError, null, now);
            _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
            _logger.LogWarning(ex,
                "AbCip read transport exception for tag {Tag} on device {Device}",
                reference, def.DeviceHostAddress);
        }
    }

    /// <summary>
    ///     Perform one whole-UDT read on the parent tag, then decode each
    ///     grouped member from the runtime's buffer at its computed byte offset. A per-group
    ///     failure (parent read raised, non-zero libplctag status, or missing device) stamps
    ///     the mapped fault across every grouped member only — sibling groups + the
    ///     per-tag fallback list are unaffected.
    /// </summary>
    private async Task ReadGroupAsync(
        AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
    {
        var parent = group.ParentDefinition;

        if (!_devicesByRoutingKey.TryGetValue(parent.DeviceHostAddress, out var device))
        {
            StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
            return;
        }

        try
        {
            var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);

            // Serialise the whole-UDT Read → GetStatus → per-member DecodeValueAt against the shared
            // parent runtime — the members decode from that one handle's buffer (05/STAB-4).
            var opLock = device.GetRuntimeLock(parent.Name);
            await opLock.WaitAsync(ct).ConfigureAwait(false);
            try
            {
                await runtime.ReadAsync(ct).ConfigureAwait(false);

                var status = runtime.GetStatus();
                if (status != 0)
                {
                    EvictRuntime(device, parent.Name);
                    var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
                    StampGroupStatus(group, results, now, mapped);
                    _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
                        $"libplctag status {status} reading UDT {group.ParentName}");
                    _logger.LogWarning(
                        "AbCip whole-UDT read returned non-zero libplctag status {LibplctagStatus} for parent {Parent} " +
                        "on device {Device}; {MemberCount} member values stamped with mapped status",
                        status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
                    return;
                }

                foreach (var member in group.Members)
                {
                    var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
                    results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
                }
                _health = new DriverHealth(DriverState.Healthy, now, null);
            }
            finally
            {
                opLock.Release();
            }
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            EvictRuntime(device, parent.Name);
            StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
            _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
            _logger.LogWarning(ex,
                "AbCip whole-UDT read transport exception for parent {Parent} on device {Device}",
                group.ParentName, parent.DeviceHostAddress);
        }
    }

    private static void StampGroupStatus(
        AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode)
    {
        foreach (var member in group.Members)
            results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now);
    }

    // ---- IWritable ----

    /// <inheritdoc />
    public async Task<IReadOnlyList<WriteResult>> WriteAsync(
        IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(writes);
        var results = new WriteResult[writes.Count];
        var now = DateTime.UtcNow;

        for (var i = 0; i < writes.Count; i++)
        {
            var w = writes[i];
            if (!_resolver.TryResolve(w.FullReference, out var def))
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
                continue;
            }
            if (!def.Writable || def.SafetyTag)
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
                continue;
            }
            if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
                continue;
            }

            try
            {
                var parsedPath = AbCipTagPath.TryParse(def.TagPath);

                // BOOL-within-DINT writes — RMW against a parallel parent-DINT
                // runtime. Dispatching here keeps the normal EncodeValue path clean; the
                // per-parent lock prevents two concurrent bit writes to the same DINT from
                // losing one another's update.
                if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
                {
                    results[i] = new WriteResult(
                        await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
                            .ConfigureAwait(false));
                    if (results[i].StatusCode == AbCipStatusMapper.Good)
                        _health = new DriverHealth(DriverState.Healthy, now, null);
                    continue;
                }

                var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);

                // Serialise Encode → Write → GetStatus against the shared runtime — the same cached
                // Tag handle may be in use by a concurrent ReadAsync or poll loop (05/STAB-4).
                var opLock = device.GetRuntimeLock(def.Name);
                await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
                int status;
                try
                {
                    runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
                    await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
                    status = runtime.GetStatus();
                }
                finally
                {
                    opLock.Release();
                }

                if (status != 0)
                {
                    EvictRuntime(device, def.Name);
                    results[i] = new WriteResult(AbCipStatusMapper.MapLibplctagStatus(status));
                    _logger.LogWarning(
                        "AbCip write returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
                        "evicting cached runtime so next call re-creates it",
                        status, w.FullReference, def.DeviceHostAddress);
                }
                else
                {
                    results[i] = new WriteResult(AbCipStatusMapper.Good);
                    _health = new DriverHealth(DriverState.Healthy, now, null);
                }
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (NotSupportedException nse)
            {
                // Type/protocol error — not a transport fault; don't evict the handle.
                results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
                _logger.LogWarning(nse,
                    "AbCip write not supported for tag {Tag} on device {Device}",
                    w.FullReference, def.DeviceHostAddress);
            }
            catch (FormatException fe)
            {
                // Value conversion error — not a transport fault; don't evict.
                results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
                _logger.LogWarning(fe,
                    "AbCip write value-conversion error for tag {Tag} on device {Device}",
                    w.FullReference, def.DeviceHostAddress);
            }
            catch (InvalidCastException ice)
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
                _logger.LogWarning(ice,
                    "AbCip write type-cast error for tag {Tag} on device {Device}",
                    w.FullReference, def.DeviceHostAddress);
            }
            catch (OverflowException oe)
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
                _logger.LogWarning(oe,
                    "AbCip write value out of range for tag {Tag} on device {Device}",
                    w.FullReference, def.DeviceHostAddress);
            }
            catch (Exception ex)
            {
                // Transport / wire error — evict so the next write creates a fresh handle.
                EvictRuntime(device, def.Name);
                results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
                _logger.LogWarning(ex,
                    "AbCip write transport exception for tag {Tag} on device {Device}",
                    w.FullReference, def.DeviceHostAddress);
            }
        }

        return results;
    }

    /// <summary>
    ///     Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
    ///     parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
    ///     writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>.
    ///     Matches the Modbus BitInRegister + FOCAS PMC Bit pattern.
    /// </summary>
    private async Task<uint> WriteBitInDIntAsync(
        DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
    {
        var parentPath = bitPath with { BitIndex = null };
        var parentName = parentPath.ToLibplctagName();

        var rmwLock = device.GetRmwLock(parentName);
        await rmwLock.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
            await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
            var readStatus = parentRuntime.GetStatus();
            if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);

            var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
            var updated = Convert.ToBoolean(value)
                ? current | (1 << bit)
                : current & ~(1 << bit);

            parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
            await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
            var writeStatus = parentRuntime.GetStatus();
            return writeStatus == 0
                ? AbCipStatusMapper.Good
                : AbCipStatusMapper.MapLibplctagStatus(writeStatus);
        }
        finally
        {
            rmwLock.Release();
        }
    }

    /// <summary>
    ///     Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
    ///     so repeated bit writes against the same DINT share one handle.
    /// </summary>
    private async Task<IAbCipTagRuntime> EnsureParentRuntimeAsync(
        DeviceState device, string parentTagName, CancellationToken ct)
    {
        if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;

        var runtime = _tagFactory.Create(device.BuildCreateParams(parentTagName, _options.Timeout));
        try
        {
            await runtime.InitializeAsync(ct).ConfigureAwait(false);
        }
        catch
        {
            runtime.Dispose();
            throw;
        }
        // Two concurrent callers can both miss the cache + both initialize a runtime; only the
        // first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
        if (device.ParentRuntimes.TryAdd(parentTagName, runtime))
            return runtime;
        runtime.Dispose();
        return device.ParentRuntimes[parentTagName];
    }

    /// <summary>
    ///     Thrown when a per-device <see cref="ConnectionBackoff"/> window is still open (05/STAB-8)
    ///     — a fail-fast that costs no wire traffic. Read/write map it to <c>BadCommunicationError</c>
    ///     via their generic catch, exactly as a real Forward-Open failure.
    /// </summary>
    private sealed class ConnectionThrottledException(string message) : Exception(message);

    /// <summary>
    ///     Idempotently materialise the runtime handle for a tag definition. First call creates
    ///     + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
    ///     lifetime of the device.
    /// </summary>
    private async Task<IAbCipTagRuntime> EnsureTagRuntimeAsync(
        DeviceState device, AbCipTagDefinition def, CancellationToken ct)
    {
        if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;

        var parsed = AbCipTagPath.TryParse(def.TagPath)
            ?? throw new InvalidOperationException(
                $"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");

        // 05/STAB-8: inside an open backoff window a data-path caller fails fast — no runtime
        // create, no Forward-Open. The malformed-path config error above still surfaces (a permanent
        // fault must never be masked by the window); only the expensive create+init is throttled. The
        // probe loop bypasses this (its own runtime) and drives the failure/success recording.
        if (!device.Backoff.ShouldAttempt(DateTime.UtcNow))
            throw new ConnectionThrottledException(
                $"AbCip runtime create for '{def.Name}' on {device.Options.HostAddress} throttled — backoff window open after a recent Forward-Open failure.");

        // Review I-1 — an array tag (the EXPLICIT IsArray flag, incl. a 1-element array) sets
        // libplctag's elem_count so the read pulls every element in one CIP transaction; the read
        // path then boxes them into a typed CLR array. Scalar tags pass count 1 + IsArray false.
        var runtime = _tagFactory.Create(
            device.BuildCreateParams(
                parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def)));
        try
        {
            await runtime.InitializeAsync(ct).ConfigureAwait(false);
        }
        catch
        {
            runtime.Dispose();
            device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window
            throw;
        }
        device.Backoff.RecordSuccess(); // 05/STAB-8: Forward-Open succeeded — reset the window
        // Two concurrent callers can both miss the cache + both initialize a runtime; only the
        // first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
        if (device.Runtimes.TryAdd(def.Name, runtime))
            return runtime;
        runtime.Dispose();
        return device.Runtimes[def.Name];
    }

    /// <summary>
    ///     Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose
    ///     it so the next read/write call re-creates and re-initializes a fresh handle.
    ///     Called from <see cref="ReadSingleAsync"/>, <see cref="ReadGroupAsync"/>, and
    ///     <see cref="WriteAsync"/> after a non-zero libplctag status or transport exception —
    ///     mirroring the probe loop's recreate-on-failure behaviour.
    /// </summary>
    private static void EvictRuntime(DeviceState device, string tagName)
    {
        if (device.Runtimes.TryRemove(tagName, out var stale))
        {
            try { stale.Dispose(); } catch { }
        }
    }

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

    /// <inheritdoc />
    public long GetMemoryFootprint() => 0;

    /// <inheritdoc />
    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
    {
        _templateCache.Clear();
        return Task.CompletedTask;
    }

    // ---- ITagDiscovery ----

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

    /// <summary>Controller enumeration is real (CIP Symbol Object walk) — config-gated on
    /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
    public bool SupportsOnlineDiscovery => true;

    /// <inheritdoc />
    public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        var root = builder.Folder("AbCip", "AbCip");

        foreach (var device in _options.Devices)
        {
            var deviceLabel = device.DeviceName ?? device.HostAddress;
            var deviceFolder = root.Folder(device.HostAddress, deviceLabel);

            // Pre-declared tags — always emitted; the primary config path. UDT tags with declared
            // Members fan out into a sub-folder + one Variable per member instead of a single
            // Structure Variable (Structure has no useful scalar value + member-addressable paths
            // are what downstream consumers actually want). A tag reaches this device when its
            // device routing key matches the device's DeviceName or its host address.
            var preDeclared = _declaredTags.Where(t => RoutesToDevice(t, device));
            foreach (var tag in preDeclared)
            {
                if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;

                if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
                {
                    var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
                    foreach (var member in tag.Members)
                    {
                        var memberFullName = $"{tag.Name}.{member.Name}";
                        // Review I-1 — array-ness is the EXPLICIT IsArray flag (a 1-element array is
                        // still an array); a legacy member with ElementCount > 1 but the flag unset
                        // remains an array for back-compat.
                        var memberIsArray = member.IsArray || member.ElementCount > 1;
                        udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
                            FullName: memberFullName,
                            DriverDataType: member.DataType.ToDriverDataType(),
                            IsArray: memberIsArray,
                            ArrayDim: memberIsArray ? (uint)Math.Max(1, member.ElementCount) : null,
                            SecurityClass: member.Writable
                                ? SecurityClassification.Operate
                                : SecurityClassification.ViewOnly,
                            IsHistorized: false,
                            IsAlarm: false,
                            WriteIdempotent: member.WriteIdempotent));
                    }
                    continue;
                }

                deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
            }

            // Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
            // walker (LibplctagTagEnumerator) is the factory default,
            // so leaving the flag off keeps the strict-config path for deployments where only
            // declared tags should appear.
            if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
            {
                using var enumerator = _enumeratorFactory.Create();
                var deviceParams = state.BuildCreateParams("@tags", _options.Timeout);

                IAddressSpaceBuilder? discoveredFolder = null;
                // Dedup discovered member full-names within this device's Discovered folder.
                var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
                    .ConfigureAwait(false))
                {
                    if (discovered.IsSystemTag) continue;
                    if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue;

                    discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
                    var fullName = discovered.ProgramScope is null
                        ? discovered.Name
                        : $"Program:{discovered.ProgramScope}.{discovered.Name}";

                    // A discovered UDT/struct is expanded into a member sub-folder with one Variable
                    // per ATOMIC leaf (mirroring the pre-declared fan-out above), rather than the
                    // bogus single Structure → String placeholder Variable. When the shape can't be
                    // resolved (or yields no emittable leaf) we fall through to the legacy single
                    // Variable so nothing regresses.
                    if (discovered.DataType == AbCipDataType.Structure)
                    {
                        // Pass the discovered tag's CIP template instance id (surfaced by the Symbol
                        // Object decoder) so ResolveDiscoveredUdtShapeAsync can fetch the Template Object
                        // off the controller via FetchUdtShapeAsync — this is what makes the production
                        // top-level UDT fan-out functional (rather than degrading to a placeholder).
                        var shape = await ResolveDiscoveredUdtShapeAsync(
                            device.HostAddress, discovered.Name, discovered.TemplateInstanceId, cancellationToken)
                            .ConfigureAwait(false);
                        if (shape is not null)
                        {
                            // Materialise the member sub-folder LAZILY — only once a leaf is actually
                            // about to be emitted into it. A shape that yields no usable atomic leaf
                            // then leaves no empty sub-folder behind, and we fall through to the legacy
                            // single Variable below.
                            var capturedDiscoveredFolder = discoveredFolder;
                            var udtFolder = new Lazy<IAddressSpaceBuilder>(
                                () => capturedDiscoveredFolder.Folder(fullName, discovered.Name));
                            var anyEmitted = await FanOutDiscoveredUdtMembersAsync(
                                    udtFolder, device.HostAddress, fullName, shape, discovered.ReadOnly,
                                    depth: 1, emitted, visited: [discovered.Name], cancellationToken)
                                .ConfigureAwait(false);
                            if (anyEmitted) continue; // expanded — don't emit the placeholder Variable
                            // Empty shape (no usable atomic leaf): degrade to a single Variable below.
                        }
                    }

                    if (!emitted.Add(fullName)) continue;
                    // Review I-1 — a discovered array of length 1 is still an array; honour the
                    // explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
                    var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
                    discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
                        FullName: fullName,
                        DriverDataType: discovered.DataType.ToDriverDataType(),
                        IsArray: discoveredIsArray,
                        ArrayDim: discoveredIsArray ? (uint)Math.Max(1, discovered.ElementCount) : null,
                        SecurityClass: discovered.ReadOnly
                            ? SecurityClassification.ViewOnly
                            : SecurityClassification.Operate,
                        IsHistorized: false,
                        IsAlarm: false,
                        WriteIdempotent: false));
                }
            }
        }
    }

    /// <summary>
    ///     Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
    ///     ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
    ///     pre-declared UDT fan-out (member full-name = <c>Parent.Member</c>, dot-joined for nested
    ///     structs; atomic <c>DriverDataType</c>; SecurityClass from writability;
    ///     <c>IsHistorized:false</c>, <c>IsAlarm:false</c>). Atomic members emit directly; a
    ///     nested-struct member recurses into ITS members. Bounded by <see cref="MaxUdtDepth"/>
    ///     (deeper members are dropped) and a per-path <paramref name="visited"/> set (cyclic struct
    ///     references can't loop). A nested struct whose sub-shape can't be resolved is dropped —
    ///     never mis-emitted as a placeholder.
    /// </summary>
    /// <remarks>
    ///     The <paramref name="folder"/> is a <see cref="Lazy{T}"/> so the sub-folder node is
    ///     materialised only once a leaf is actually about to be emitted into it. A shape (or nested
    ///     sub-shape) that yields no emittable leaf therefore never leaves an empty sub-folder in the
    ///     browse tree.
    /// </remarks>
    /// <param name="folder">Lazy producer of the address-space sub-folder the member Variables are
    ///     emitted under; only forced when the first leaf is emitted.</param>
    /// <param name="deviceHostAddress">The device the UDT lives on (for nested sub-shape lookup).</param>
    /// <param name="parentFullName">Dotted full-name prefix of the parent (e.g. <c>Motor1</c> or
    ///     <c>Motor1.Status</c>).</param>
    /// <param name="shape">The resolved shape whose members are fanned out.</param>
    /// <param name="parentReadOnly">Whether the discovered parent tag was read-only (propagated to
    ///     members — the Symbol Object carries no per-member access bits).</param>
    /// <param name="depth">Current recursion depth (the parent's direct members are depth 1).</param>
    /// <param name="emitted">Dedup set of member full-names already emitted in this Discovered folder.</param>
    /// <param name="visited">Struct names on the current recursion path (cycle guard).</param>
    /// <param name="cancellationToken">Cancellation token for any nested sub-shape read.</param>
    /// <returns><c>true</c> when at least one atomic leaf was emitted.</returns>
    private async Task<bool> FanOutDiscoveredUdtMembersAsync(
        Lazy<IAddressSpaceBuilder> folder,
        string deviceHostAddress,
        string parentFullName,
        AbCipUdtShape shape,
        bool parentReadOnly,
        int depth,
        HashSet<string> emitted,
        HashSet<string> visited,
        CancellationToken cancellationToken)
    {
        // Defensive: the only caller already gates each descent on depth + 1 <= MaxUdtDepth, so a
        // depth past the cap can't be reached here — kept as a belt-and-suspenders guard.
        if (depth > MaxUdtDepth) return false;

        var anyEmitted = false;
        foreach (var member in shape.Members)
        {
            var memberFullName = $"{parentFullName}.{member.Name}";

            if (member.DataType == AbCipDataType.Structure)
            {
                // Nested struct: recurse into its sub-shape if it resolves, the depth cap allows it,
                // and we're not re-entering a struct already on this path (cycle guard). The nested
                // members live a level deeper, so the cap is checked against depth + 1.
                if (depth + 1 > MaxUdtDepth) continue;
                if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
                // The decoded parent shape carries the nested UDT's template instance id (low 12 bits of the
                // member info, captured by CipTemplateObjectDecoder) — thread it so the nested shape is fetched
                // off the controller via FetchUdtShapeAsync, making nested-struct members addressable in
                // production (name-keyed seeded shapes still win first, for the test seam).
                var nested = await ResolveDiscoveredUdtShapeAsync(
                    deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
                    .ConfigureAwait(false);
                if (nested is not null)
                {
                    // Materialise the nested sub-folder lazily under the parent folder (itself lazy),
                    // so a nested struct that yields no leaf leaves no empty sub-folder behind.
                    var parentFolder = folder;
                    var nestedFolder = new Lazy<IAddressSpaceBuilder>(
                        () => parentFolder.Value.Folder(memberFullName, member.Name));
                    var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
                            nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
                            depth + 1, emitted, visited, cancellationToken)
                        .ConfigureAwait(false);
                    anyEmitted |= nestedEmitted;
                }
                visited.Remove(member.Name);
                continue;
            }

            // Atomic leaf — emit one Variable. Dedup by full-name within the Discovered folder.
            // Forcing folder.Value here is what materialises the sub-folder on the first real leaf.
            if (!emitted.Add(memberFullName)) continue;
            var memberIsArray = member.ArrayLength > 1;
            folder.Value.Variable(member.Name, member.Name, new DriverAttributeInfo(
                FullName: memberFullName,
                DriverDataType: member.DataType.ToDriverDataType(),
                IsArray: memberIsArray,
                ArrayDim: memberIsArray ? (uint)Math.Max(1, member.ArrayLength) : null,
                // The Symbol Object carries no per-member ExternalAccess bits, so members inherit the
                // parent tag's read-only state (a safety/read-only UDT keeps all its members ViewOnly).
                SecurityClass: parentReadOnly
                    ? SecurityClassification.ViewOnly
                    : SecurityClassification.Operate,
                IsHistorized: false,
                IsAlarm: false,
                WriteIdempotent: false));
            anyEmitted = true;
        }

        return anyEmitted;
    }

    // Review I-1 — array-ness is the EXPLICIT IsArray flag (a 1-element array is still an array);
    // a legacy definition carrying only ElementCount > 1 stays an array for back-compat.
    private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1;

    // A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the
    // RawTagEntry.DeviceName) matches the device's DeviceName or its host address. TODO(v3 WaveC):
    // collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig.
    private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) =>
        string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)
        || (!string.IsNullOrEmpty(device.DeviceName)
            && string.Equals(tag.DeviceHostAddress, device.DeviceName, StringComparison.OrdinalIgnoreCase));

    private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
        FullName: tag.Name,
        DriverDataType: tag.DataType.ToDriverDataType(),
        IsArray: IsArrayTag(tag),
        ArrayDim: IsArrayTag(tag) ? (uint)Math.Max(1, tag.ElementCount) : null,
        SecurityClass: (tag.Writable && !tag.SafetyTag)
            ? SecurityClassification.Operate
            : SecurityClassification.ViewOnly,
        IsHistorized: false,
        IsAlarm: false,
        WriteIdempotent: tag.WriteIdempotent);

    /// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
    internal int DeviceCount => _devices.Count;

    /// <summary>Looked-up device state for the given host address. Tests + later-PR capabilities hit this.</summary>
    /// <param name="hostAddress">The host address of the device to look up.</param>
    /// <returns>The device state if found; null otherwise.</returns>
    internal DeviceState? GetDeviceState(string hostAddress) =>
        _devices.TryGetValue(hostAddress, out var s) ? s : null;

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

    /// <summary>Asynchronously releases all resources used by the driver.</summary>
    /// <returns>A value task representing the asynchronous disposal.</returns>
    public async ValueTask DisposeAsync()
    {
        await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
    }

    /// <summary>
    ///     Per-device runtime state. Holds the parsed host address, family profile, and the
    ///     live libplctag.NET <see cref="IAbCipTagRuntime"/> instances keyed by tag name.
    ///     Native tag lifetime is owned by the <c>Tag.Dispose()</c> inside each
    ///     <see cref="LibplctagTagRuntime"/>; libplctag.NET's own finalizer covers GC-collected
    ///     instances so no separate SafeHandle wrapper is needed here.
    /// </summary>
    internal sealed class DeviceState(
        AbCipHostAddress parsedAddress,
        AbCipDeviceOptions options,
        AbCipPlcFamilyProfile profile)
    {
        /// <summary>Gets the parsed host address for this device.</summary>
        public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
        /// <summary>Gets the configuration options for this device.</summary>
        public AbCipDeviceOptions Options { get; } = options;
        /// <summary>Gets the PLC family profile for this device.</summary>
        public AbCipPlcFamilyProfile Profile { get; } = profile;

        /// <summary>Gets the lock object used for probe synchronization.</summary>
        public object ProbeLock { get; } = new();

        /// <summary>
        ///     Per-device connect-attempt throttle (05/STAB-8). After a failed runtime create
        ///     (Forward-Open), data-path callers fail fast inside the backoff window instead of
        ///     re-creating + re-initializing a runtime per tag per tick; the probe loop bypasses it
        ///     (its own runtime) and drives failure/success so a recovered device resets the window.
        /// </summary>
        public ConnectionBackoff Backoff { get; } = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
        /// <summary>Gets or sets the current host state of this device.</summary>
        public HostState HostState { get; set; } = HostState.Unknown;
        /// <summary>Gets or sets the UTC timestamp when the host state was last changed.</summary>
        public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
        /// <summary>Gets or sets the cancellation token source for the probe loop.</summary>
        public CancellationTokenSource? ProbeCts { get; set; }
        /// <summary>Gets or sets whether the probe has been initialized.</summary>
        public bool ProbeInitialized { get; set; }

        /// <summary>
        ///     The fire-and-forget probe loop's <see cref="Task"/>. Stored so
        ///     <see cref="AbCipDriver.ShutdownAsync"/> can await the loop's clean exit after
        ///     cancelling <see cref="ProbeCts"/> and BEFORE disposing the CTS or the runtime
        ///     handles — otherwise the still-running loop can touch a disposed CTS or a cleared
        ///     runtime dictionary.
        /// </summary>
        public Task? ProbeTask { get; set; }

        /// <summary>
        ///     Per-tag runtime handles owned by this device. One entry per configured tag is
        ///     created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
        ///     <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
        ///     because <c>ReadAsync</c> is invoked concurrently by the server read path, every
        ///     polled subscription loop, and the alarm projection loop.
        /// </summary>
        public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> Runtimes { get; } =
            new(StringComparer.OrdinalIgnoreCase);

        /// <summary>
        ///     Parent-DINT runtimes created on-demand by <see cref="AbCipDriver.EnsureParentRuntimeAsync"/>
        ///     for BOOL-within-DINT RMW writes. Separate from <see cref="Runtimes"/> because a
        ///     bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
        ///     parent ("Motor.Flags") used to do the read + write.
        /// </summary>
        public System.Collections.Concurrent.ConcurrentDictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
            new(StringComparer.OrdinalIgnoreCase);

        private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();

        /// <summary>Gets or creates a semaphore for coordinating RMW (read-modify-write) operations on a parent tag.</summary>
        /// <param name="parentTagName">The name of the parent tag.</param>
        /// <returns>A semaphore for coordinating RMW operations.</returns>
        public SemaphoreSlim GetRmwLock(string parentTagName) =>
            _rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));

        private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _runtimeLocks =
            new(StringComparer.OrdinalIgnoreCase);

        /// <summary>
        ///     Per-runtime operation lock (05/STAB-4). A libplctag <c>Tag</c> handle is not safe for
        ///     concurrent Read/GetStatus/DecodeValue (or Encode/Write/GetStatus) — the server read
        ///     path, every poll-group loop, and the alarm-projection loop all call
        ///     <see cref="AbCipDriver.ReadAsync"/> / <see cref="AbCipDriver.WriteAsync"/> against the
        ///     same cached runtime, and <c>GetStatus</c> returns the <em>last</em> operation's status
        ///     — so an unserialised overlap decodes a torn / cross-attributed value with Good status.
        ///     Callers hold this lock around the whole op sequence. Keyed by tag name, which is also
        ///     the <see cref="Runtimes"/> dictionary key. Mirrors the field-proven AbLegacy pattern.
        /// </summary>
        /// <param name="tagName">The tag name (the <see cref="Runtimes"/> key).</param>
        /// <returns>The per-runtime operation semaphore.</returns>
        public SemaphoreSlim GetRuntimeLock(string tagName) =>
            _runtimeLocks.GetOrAdd(tagName, _ => new SemaphoreSlim(1, 1));

        /// <summary>
        ///     Compute the effective <see cref="AbCipTagCreateParams"/> for a
        ///     tag on this device. Combines the per-device options
        ///     (<see cref="AbCipDeviceOptions.AllowPacking"/>,
        ///     <see cref="AbCipDeviceOptions.ConnectionSize"/>) with the family profile defaults
        ///     so the wire layer sees one place that resolves both.
        /// </summary>
        /// <param name="tagName">The name of the tag to create parameters for.</param>
        /// <param name="timeout">The timeout for tag operations.</param>
        /// <param name="elementCount">libplctag <c>elem_count</c> — 1 for a scalar tag, the array
        ///     length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1.</param>
        /// <param name="isArray">Review I-1 — the EXPLICIT array signal threaded through so a
        ///     1-element array (<paramref name="elementCount"/> 1) is still read as an array.</param>
        /// <returns>The computed tag creation parameters.</returns>
        public AbCipTagCreateParams BuildCreateParams(
            string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false) => new(
            Gateway: ParsedAddress.Gateway,
            Port: ParsedAddress.Port,
            CipPath: ParsedAddress.CipPath,
            LibplctagPlcAttribute: Profile.LibplctagPlcAttribute,
            TagName: tagName,
            Timeout: timeout,
            AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
            ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize,
            ElementCount: elementCount < 1 ? 1 : elementCount,
            IsArray: isArray);

        /// <summary>Disposes all runtime tag handles and clears the caches.</summary>
        public void DisposeHandles()
        {
            foreach (var r in Runtimes.Values) r.Dispose();
            Runtimes.Clear();
            foreach (var r in ParentRuntimes.Values) r.Dispose();
            ParentRuntimes.Clear();
            // Dispose + clear the per-runtime/RMW gates so ReinitializeAsync cycles don't orphan
            // their SemaphoreSlim instances (each leaks a wait handle once contended).
            foreach (var sem in _runtimeLocks.Values) sem.Dispose();
            _runtimeLocks.Clear();
            foreach (var sem in _rmwLocks.Values) sem.Dispose();
            _rmwLocks.Clear();
        }
    }
}
