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 <see cref="IDriver"/> only for now — read/write/
///     subscribe/discover capabilities ship in subsequent PRs (3–8) and family-specific quirk
///     profiles ship in PRs 9–12.
/// </summary>
/// <remarks>
///     <para>Wire layer is libplctag 1.6.x (plan decision #11). 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 bulkhead + circuit-breaker isolation per plan decision #144.</para>
///
///     <para>Tier A per plan decisions #143–145 — 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 every
///     <see cref="PlcTagHandle"/> and reconnects each device.</para>
/// </remarks>
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
    IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
    private readonly AbCipDriverOptions _options;
    private readonly string _driverInstanceId;
    private readonly IAbCipTagFactory _tagFactory;
    private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
    private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
    private readonly AbCipTemplateCache _templateCache = new();
    private readonly PollGroupEngine _poll;
    private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
    private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
    private readonly AbCipAlarmProjection _alarmProjection;
    private DriverHealth _health = new(DriverState.Unknown, null, null);

    public event EventHandler<DataChangeEventArgs>? OnDataChange;
    public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
    public event EventHandler<AlarmEventArgs>? OnAlarmEvent;

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

    public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
        IAbCipTagFactory? tagFactory = null,
        IAbCipTagEnumeratorFactory? enumeratorFactory = null,
        IAbCipTemplateReaderFactory? templateReaderFactory = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        _options = options;
        _driverInstanceId = driverInstanceId;
        _tagFactory = tagFactory ?? new LibplctagTagFactory();
        _enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
        _templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
        _poll = new PollGroupEngine(
            reader: ReadAsync,
            onChange: (handle, tagRef, snapshot) =>
                OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
        _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
    }

    /// <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>
    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 = new AbCipTagCreateParams(
            Gateway: device.ParsedAddress.Gateway,
            Port: device.ParsedAddress.Port,
            CipPath: device.ParsedAddress.CipPath,
            LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
            TagName: $"@udt/{templateInstanceId}",
            Timeout: _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
        {
            // Template read failure — log via the driver's health surface so operators see it,
            // but don't propagate since callers should fall back to declaration-driven UDT
            // semantics rather than failing the whole discovery run.
            return null;
        }
    }

    /// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
    internal AbCipTemplateCache TemplateCache => _templateCache;

    public string DriverInstanceId => _driverInstanceId;
    public string DriverType => "AbCip";

    public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        _health = new DriverHealth(DriverState.Initializing, null, null);
        try
        {
            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);
                _devices[device.HostAddress] = new DeviceState(addr, device, profile);
            }
            foreach (var tag in _options.Tags)
            {
                _tagsByName[tag.Name] = 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);
                        _tagsByName[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;
                    _ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
                }
            }
            _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
        }
        catch (Exception ex)
        {
            _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
            throw;
        }
        return Task.CompletedTask;
    }

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

    public async Task ShutdownAsync(CancellationToken cancellationToken)
    {
        await _alarmProjection.DisposeAsync().ConfigureAwait(false);
        await _poll.DisposeAsync().ConfigureAwait(false);
        foreach (var state in _devices.Values)
        {
            try { state.ProbeCts?.Cancel(); } catch { }
            state.ProbeCts?.Dispose();
            state.ProbeCts = null;
            state.DisposeHandles();
        }
        _devices.Clear();
        _tagsByName.Clear();
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }

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

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

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

    // ---- IAlarmSource (ALMD projection, #177) ----

    /// <summary>
    ///     Subscribe to ALMD alarm transitions on <paramref name="sourceNodeIds"/>. Each id
    ///     names a declared ALMD UDT tag; the projection polls the tag's <c>InFaulted</c> +
    ///     <c>Severity</c> members at <see cref="AbCipDriverOptions.AlarmPollInterval"/> and
    ///     fires <see cref="OnAlarmEvent"/> on 0→1 (raise) + 1→0 (clear) transitions.
    ///     Feature-gated — when <see cref="AbCipDriverOptions.EnableAlarmProjection"/> is
    ///     <c>false</c> (the default), returns a handle wrapping a no-op subscription so
    ///     capability negotiation still works; <see cref="OnAlarmEvent"/> never fires.
    /// </summary>
    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);
    }

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

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

    // ---- IHostConnectivityProbe ----

    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 = new AbCipTagCreateParams(
            Gateway: state.ParsedAddress.Gateway,
            Port: state.ParsedAddress.Port,
            CipPath: state.ParsedAddress.CipPath,
            LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
            TagName: _options.Probe.ProbeTagPath!,
            Timeout: _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;
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                break;
            }
            catch
            {
                // Wire / init error — tear down the probe runtime so the next tick re-creates it.
                try { probeRuntime?.Dispose(); } catch { }
                probeRuntime = null;
                state.ProbeInitialized = false;
            }

            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 the device host address for a given tag full-reference. Per plan decision #144
    ///     the Phase 6.1 resilience pipeline keys its bulkhead + breaker on
    ///     <c>(DriverInstanceId, hostName)</c> so multi-PLC drivers get per-device isolation —
    ///     one dead PLC trips only its own breaker. Unknown references fall back to the
    ///     first configured device's host address rather than throwing — the invoker handles the
    ///     mislookup at the capability level when the actual read returns BadNodeIdUnknown.
    /// </summary>
    public string ResolveHost(string fullReference)
    {
        if (_tagsByName.TryGetValue(fullReference, out var def))
            return def.DeviceHostAddress;
        return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
    }

    // ---- IReadable ----

    /// <summary>
    ///     Read each <c>fullReference</c> in order. Unknown tags surface as
    ///     <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
    ///     <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
    ///     <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
    ///     Admin UI sees a tight feedback loop between read failures + the driver's state.
    /// </summary>
    public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
        IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(fullReferences);
        var now = DateTime.UtcNow;
        var results = new DataValueSnapshot[fullReferences.Count];

        // Task #194 — 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 path that's been here since PR 3. 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.
        var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);

        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 (!_tagsByName.TryGetValue(reference, out var def))
        {
            results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
            return;
        }
        if (!_devices.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);
            await runtime.ReadAsync(ct).ConfigureAwait(false);

            var status = runtime.GetStatus();
            if (status != 0)
            {
                results[fb.OriginalIndex] = new DataValueSnapshot(null,
                    AbCipStatusMapper.MapLibplctagStatus(status), null, now);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
                    $"libplctag status {status} reading {reference}");
                return;
            }

            var tagPath = AbCipTagPath.TryParse(def.TagPath);
            var bitIndex = tagPath?.BitIndex;
            var value = runtime.DecodeValue(def.DataType, bitIndex);
            results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
            _health = new DriverHealth(DriverState.Healthy, now, null);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            results[fb.OriginalIndex] = new DataValueSnapshot(null,
                AbCipStatusMapper.BadCommunicationError, null, now);
            _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
        }
    }

    /// <summary>
    ///     Task #194 — 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 (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
        {
            StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
            return;
        }

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

            var status = runtime.GetStatus();
            if (status != 0)
            {
                var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
                StampGroupStatus(group, results, now, mapped);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
                    $"libplctag status {status} reading UDT {group.ParentName}");
                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);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
            _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
        }
    }

    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 ----

    /// <summary>
    ///     Write each request in order. Writes are NOT auto-retried by the driver — per plan
    ///     decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
    ///     and the resilience pipeline (layered above the driver) decides whether to replay.
    ///     Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
    ///     as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
    /// </summary>
    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 (!_tagsByName.TryGetValue(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 (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
                continue;
            }

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

                // BOOL-within-DINT writes — per task #181, 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);
                runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
                await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);

                var status = runtime.GetStatus();
                results[i] = new WriteResult(status == 0
                    ? AbCipStatusMapper.Good
                    : AbCipStatusMapper.MapLibplctagStatus(status));
                if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (NotSupportedException nse)
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
            }
            catch (FormatException fe)
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
            }
            catch (InvalidCastException ice)
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
            }
            catch (OverflowException oe)
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
            }
            catch (Exception ex)
            {
                results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
            }
        }

        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 shipped in pass 1 of task #181.
    /// </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(new AbCipTagCreateParams(
            Gateway: device.ParsedAddress.Gateway,
            Port: device.ParsedAddress.Port,
            CipPath: device.ParsedAddress.CipPath,
            LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
            TagName: parentTagName,
            Timeout: _options.Timeout));
        try
        {
            await runtime.InitializeAsync(ct).ConfigureAwait(false);
        }
        catch
        {
            runtime.Dispose();
            throw;
        }
        device.ParentRuntimes[parentTagName] = runtime;
        return runtime;
    }

    /// <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}'.");

        var runtime = _tagFactory.Create(new AbCipTagCreateParams(
            Gateway: device.ParsedAddress.Gateway,
            Port: device.ParsedAddress.Port,
            CipPath: device.ParsedAddress.CipPath,
            LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
            TagName: parsed.ToLibplctagName(),
            Timeout: _options.Timeout));
        try
        {
            await runtime.InitializeAsync(ct).ConfigureAwait(false);
        }
        catch
        {
            runtime.Dispose();
            throw;
        }
        device.Runtimes[def.Name] = runtime;
        return runtime;
    }

    public DriverHealth GetHealth() => _health;

    /// <summary>
    ///     CLR-visible allocation footprint only — libplctag's native heap is invisible to the
    ///     GC. driver-specs.md §3 flags this: operators must watch whole-process RSS for the
    ///     full picture, and <see cref="ReinitializeAsync"/> is the Tier-B remediation.
    /// </summary>
    public long GetMemoryFootprint() => 0;

    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
    {
        _templateCache.Clear();
        return Task.CompletedTask;
    }

    // ---- ITagDiscovery ----

    /// <summary>
    ///     Stream the driver's tag set into the builder. Pre-declared tags from
    ///     <see cref="AbCipDriverOptions.Tags"/> emit first; optionally, the
    ///     <see cref="IAbCipTagEnumerator"/> walks each device's symbol table and adds
    ///     controller-discovered tags under a <c>Discovered/</c> sub-folder. System / module /
    ///     routine / task tags are hidden via <see cref="AbCipSystemTagFilter"/>.
    /// </summary>
    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).
            var preDeclared = _options.Tags.Where(t =>
                string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
            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}";
                        udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
                            FullName: memberFullName,
                            DriverDataType: member.DataType.ToDriverDataType(),
                            IsArray: false,
                            ArrayDim: 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 since task #178 shipped,
            // 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 = new AbCipTagCreateParams(
                    Gateway: state.ParsedAddress.Gateway,
                    Port: state.ParsedAddress.Port,
                    CipPath: state.ParsedAddress.CipPath,
                    LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
                    TagName: "@tags",
                    Timeout: _options.Timeout);

                IAddressSpaceBuilder? discoveredFolder = null;
                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}";
                    discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
                        FullName: fullName,
                        DriverDataType: discovered.DataType.ToDriverDataType(),
                        IsArray: false,
                        ArrayDim: null,
                        SecurityClass: discovered.ReadOnly
                            ? SecurityClassification.ViewOnly
                            : SecurityClassification.Operate,
                        IsHistorized: false,
                        IsAlarm: false,
                        WriteIdempotent: false));
                }
            }
        }
    }

    private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
        FullName: tag.Name,
        DriverDataType: tag.DataType.ToDriverDataType(),
        IsArray: false,
        ArrayDim: 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>
    internal DeviceState? GetDeviceState(string hostAddress) =>
        _devices.TryGetValue(hostAddress, out var s) ? s : null;

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

    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 <see cref="PlcTagHandle"/> cache keyed by tag path. PRs 3–8 populate + consume
    ///     this dict via libplctag.
    /// </summary>
    internal sealed class DeviceState(
        AbCipHostAddress parsedAddress,
        AbCipDeviceOptions options,
        AbCipPlcFamilyProfile profile)
    {
        public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
        public AbCipDeviceOptions Options { get; } = options;
        public AbCipPlcFamilyProfile Profile { get; } = profile;

        public object ProbeLock { get; } = new();
        public HostState HostState { get; set; } = HostState.Unknown;
        public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
        public CancellationTokenSource? ProbeCts { get; set; }
        public bool ProbeInitialized { get; set; }

        public Dictionary<string, PlcTagHandle> TagHandles { get; } =
            new(StringComparer.OrdinalIgnoreCase);

        /// <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"/>).
        /// </summary>
        public Dictionary<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 Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
            new(StringComparer.OrdinalIgnoreCase);

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

        public SemaphoreSlim GetRmwLock(string parentTagName) =>
            _rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));

        public void DisposeHandles()
        {
            foreach (var h in TagHandles.Values) h.Dispose();
            TagHandles.Clear();
            foreach (var r in Runtimes.Values) r.Dispose();
            Runtimes.Clear();
            foreach (var r in ParentRuntimes.Values) r.Dispose();
            ParentRuntimes.Clear();
        }
    }
}
