using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     TwinCAT ADS driver — talks to Beckhoff PLC runtimes (TC2 + TC3) via AMS / ADS. PR 1 ships
///     the <see cref="IDriver"/> skeleton; read / write / discover / subscribe / probe / host-
///     resolver land in PRs 2 and 3.
/// </summary>
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
    IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, IDisposable, IAsyncDisposable
{
    // Mutable so ReinitializeAsync can apply a new config generation (Driver.TwinCAT-001).
    // The constructor seeds it; InitializeAsync re-parses driverConfigJson over the top of it.
    private TwinCATDriverOptions _options;
    private readonly string _driverInstanceId;
    private readonly ITwinCATClientFactory _clientFactory;
    private readonly ILogger<TwinCATDriver> _logger;
    private readonly PollGroupEngine _poll;
    // ConcurrentDictionary so ShutdownAsync (Clear) and ReadAsync/WriteAsync/SubscribeAsync
    // (TryGetValue) don't race — plain Dictionary is not safe for concurrent read+write
    // (Driver.TwinCAT-009).
    private readonly ConcurrentDictionary<string, DeviceState> _devices =
        new(StringComparer.OrdinalIgnoreCase);
    private readonly ConcurrentDictionary<string, TwinCATTagDefinition> _tagsByName =
        new(StringComparer.OrdinalIgnoreCase);
    private DriverHealth _health = new(DriverState.Unknown, null, null);

    /// <summary>Occurs when a subscribed tag value changes.</summary>
    public event EventHandler<DataChangeEventArgs>? OnDataChange;
    /// <summary>Occurs when a device host connectivity status changes.</summary>
    public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
    /// <summary>Occurs when the Galaxy object hierarchy or TwinCAT symbol table is rediscovered.</summary>
    public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;

    /// <summary>Initializes a new instance of the <see cref="TwinCATDriver"/> class.</summary>
    /// <param name="options">Driver configuration options.</param>
    /// <param name="driverInstanceId">Unique driver instance identifier.</param>
    /// <param name="clientFactory">Optional ADS client factory; defaults to <see cref="AdsTwinCATClientFactory"/>.</param>
    /// <param name="logger">Optional logger; defaults to <see cref="NullLogger{TwinCATDriver}"/>.</param>
    public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
        ITwinCATClientFactory? clientFactory = null,
        ILogger<TwinCATDriver>? logger = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        _options = options;
        _driverInstanceId = driverInstanceId;
        _clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
        _logger = logger ?? NullLogger<TwinCATDriver>.Instance;
        _poll = new PollGroupEngine(
            reader: ReadAsync,
            onChange: (handle, tagRef, snapshot) =>
                OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
    }

    /// <summary>Gets the unique driver instance identifier.</summary>
    public string DriverInstanceId => _driverInstanceId;
    /// <summary>Gets the driver type name.</summary>
    public string DriverType => "TwinCAT";

    /// <summary>Initializes the driver with configuration and establishes device connections.</summary>
    /// <param name="driverConfigJson">JSON configuration string for the driver.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Completion task.</returns>
    public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        _health = new DriverHealth(DriverState.Initializing, null, null);
        try
        {
            // Apply the supplied config generation (Driver.TwinCAT-001). A blank or content-free
            // document keeps the constructor-seeded options — that path covers callers that have
            // already materialised options up front (the factory passes both, in agreement).
            if (!string.IsNullOrWhiteSpace(driverConfigJson))
            {
                var parsed = TwinCATDriverFactoryExtensions.ParseOptions(driverConfigJson, _driverInstanceId);
                if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
                    _options = parsed;
            }

            foreach (var device in _options.Devices)
            {
                var addr = TwinCATAmsAddress.TryParse(device.HostAddress)
                    ?? throw new InvalidOperationException(
                        $"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'.");
                _devices[device.HostAddress] = new DeviceState(addr, device);
            }
            foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;

            if (_options.Probe.Enabled)
            {
                foreach (var state in _devices.Values)
                {
                    state.ProbeCts = new CancellationTokenSource();
                    var ct = state.ProbeCts.Token;
                    // Store the task so ShutdownAsync can await it (Driver.TwinCAT-009).
                    state.ProbeTask = 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;
    }

    /// <summary>Reinitializes the driver by shutting down and reinitializing with new configuration.</summary>
    /// <param name="driverConfigJson">JSON configuration string for the driver.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Completion task.</returns>
    public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        await ShutdownAsync(cancellationToken).ConfigureAwait(false);
        await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>Shuts down the driver and releases all device connections and subscriptions.</summary>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Completion task.</returns>
    public async Task ShutdownAsync(CancellationToken cancellationToken)
    {
        // Native subs first — disposing the handles is cheap + lets the client close its
        // notifications before the AdsClient itself goes away.
        foreach (var sub in _nativeSubs.Values)
            foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
        _nativeSubs.Clear();

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

        // Cancel every probe loop and await its task before disposing the client + gate so the
        // loop can never touch a disposed object. (Driver.TwinCAT-009: ShutdownAsync previously
        // cancelled ProbeCts but did not await the task before calling DisposeClient.)
        foreach (var state in _devices.Values)
        {
            try { state.ProbeCts?.Cancel(); } catch { }
            if (state.ProbeTask is Task pt)
            {
                try { await pt.ConfigureAwait(false); }
                catch (OperationCanceledException) { /* expected — probe loop exits on cancel */ }
                catch { /* other probe errors are not fatal to shutdown */ }
            }
            state.ProbeCts?.Dispose();
            state.ProbeCts = null;
            state.DisposeClient();
            state.DisposeGate();
        }
        _devices.Clear();
        _tagsByName.Clear();
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }

    /// <summary>Gets the current driver health status.</summary>
    /// <returns>Driver health information.</returns>
    public DriverHealth GetHealth() => _health;

    /// <summary>
    ///     Estimated bytes attributable to this driver instance (Driver.TwinCAT-012).
    ///     This driver holds no flushable symbol cache — <c>BrowseSymbolsAsync</c> streams and
    ///     discards; the footprint reflects live allocations only:
    ///     ~256 bytes per pre-declared tag (tag-definition record + dictionary overhead) and
    ///     ~512 bytes per active native subscription.
    /// </summary>
    public long GetMemoryFootprint() =>
        (_tagsByName.Count * 256L) + (_nativeSubs.Count * 512L);

    /// <summary>
    ///     No flushable cache exists in this driver — the symbol table is streamed fresh on
    ///     every <see cref="DiscoverAsync"/> call. This is a no-op but is deliberately present
    ///     so Core's cache-budget enforcement sees a compliant Tier-A driver.
    /// </summary>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Completion task.</returns>
    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    /// <summary>Gets the count of configured devices.</summary>
    internal int DeviceCount => _devices.Count;
    /// <summary>Gets the device state for the specified host address.</summary>
    /// <param name="hostAddress">The ADS host address.</param>
    /// <returns>Device state or null if not found.</returns>
    internal DeviceState? GetDeviceState(string hostAddress) =>
        _devices.TryGetValue(hostAddress, out var s) ? s : null;

    // ---- IReadable ----

    /// <summary>Reads values for the specified tag references from ADS devices.</summary>
    /// <param name="fullReferences">The full tag references to read.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Data value snapshots for each reference.</returns>
    public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
        IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(fullReferences);
        var now = DateTime.UtcNow;
        var results = new DataValueSnapshot[fullReferences.Count];

        for (var i = 0; i < fullReferences.Count; i++)
        {
            var reference = fullReferences[i];
            if (!_tagsByName.TryGetValue(reference, out var def))
            {
                results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
                continue;
            }
            if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
            {
                results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
                continue;
            }

            try
            {
                var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
                var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
                var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
                var (value, status) = await client.ReadValueAsync(
                    symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);

                results[i] = new DataValueSnapshot(value, status, now, now);
                if (status == TwinCATStatusMapper.Good)
                    _health = new DriverHealth(DriverState.Healthy, now, null);
                else
                {
                    _logger.LogWarning(
                        "TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
                        _driverInstanceId, status, reference);
                    _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
                        $"ADS status {status:X8} reading {reference}");
                }
            }
            catch (OperationCanceledException) { throw; }
            catch (Exception ex)
            {
                results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
            }
        }

        return results;
    }

    // ---- IWritable ----

    /// <summary>Writes values to the specified tags on ADS devices.</summary>
    /// <param name="writes">The write requests to execute.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Write results for each request.</returns>
    public async Task<IReadOnlyList<WriteResult>> WriteAsync(
        IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(writes);
        var results = new WriteResult[writes.Count];

        for (var i = 0; i < writes.Count; i++)
        {
            var w = writes[i];
            if (!_tagsByName.TryGetValue(w.FullReference, out var def))
            {
                results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
                continue;
            }
            if (!def.Writable)
            {
                results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable);
                continue;
            }
            if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
            {
                results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
                continue;
            }

            try
            {
                var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
                var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
                var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
                var status = await client.WriteValueAsync(
                    symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
                results[i] = new WriteResult(status);
            }
            catch (OperationCanceledException) { throw; }
            catch (NotSupportedException nse)
            {
                results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
            }
            catch (Exception ex) when (ex is FormatException or InvalidCastException)
            {
                results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
            }
            catch (OverflowException)
            {
                results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
            }
            catch (Exception ex)
            {
                results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
            }
        }

        return results;
    }

    // ---- ITagDiscovery ----

    /// <summary>Discovers devices and tags from ADS configuration and optionally controller symbols.</summary>
    /// <param name="builder">Address space builder for adding discovered nodes.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Completion task.</returns>
    public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        var root = builder.Folder("TwinCAT", "TwinCAT");
        foreach (var device in _options.Devices)
        {
            var label = device.DeviceName ?? device.HostAddress;
            var deviceFolder = root.Folder(device.HostAddress, label);

            // Pre-declared tags — always emitted as the authoritative config path.
            var tagsForDevice = _options.Tags.Where(t =>
                string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
            foreach (var tag in tagsForDevice)
            {
                deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
                    FullName: tag.Name,
                    DriverDataType: tag.DataType.ToDriverDataType(),
                    IsArray: false,
                    ArrayDim: null,
                    SecurityClass: tag.Writable
                        ? SecurityClassification.Operate
                        : SecurityClassification.ViewOnly,
                    IsHistorized: false,
                    IsAlarm: false,
                    WriteIdempotent: tag.WriteIdempotent));
            }

            // Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any
            // client-side error so a flaky symbol-table download doesn't block discovery.
            if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
            {
                IAddressSpaceBuilder? discoveredFolder = null;
                try
                {
                    var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false);
                    await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false))
                    {
                        if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue;
                        if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type

                        discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
                        discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo(
                            FullName: sym.InstancePath,
                            DriverDataType: dt.ToDriverDataType(),
                            IsArray: false,
                            ArrayDim: null,
                            SecurityClass: sym.ReadOnly
                                ? SecurityClassification.ViewOnly
                                : SecurityClassification.Operate,
                            IsHistorized: false,
                            IsAlarm: false,
                            WriteIdempotent: false));
                    }
                }
                catch (OperationCanceledException) { throw; }
                catch (Exception ex)
                {
                    // Symbol-loader failure is non-fatal to discovery — pre-declared tags already
                    // shipped. Log so operators can correlate the partial discovery.
                    _logger.LogWarning(ex,
                        "TwinCAT driver '{DriverInstanceId}' symbol browse failed for device " +
                        "'{HostAddress}'; falling back to pre-declared tags only",
                        _driverInstanceId, device.HostAddress);
                }
            }
        }
    }

    // ---- ISubscribable (native ADS notifications with poll fallback) ----

    private readonly ConcurrentDictionary<long, NativeSubscription> _nativeSubs = new();
    private long _nextNativeSubId;

    /// <summary>
    ///     Subscribe via native ADS notifications when <see cref="TwinCATDriverOptions.UseNativeNotifications"/>
    ///     is <c>true</c>, otherwise fall through to the shared <see cref="PollGroupEngine"/>.
    ///     Native path registers one <see cref="ITwinCATNotificationHandle"/> per tag against the
    ///     target's PLC runtime — the PLC pushes changes on its own cycle so we skip the poll
    ///     loop entirely. Unsub path disposes the handles.
    /// </summary>
    /// <param name="fullReferences">The full tag references to subscribe to.</param>
    /// <param name="publishingInterval">The publishing interval for updates.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Subscription handle for managing the subscription.</returns>
    public async Task<ISubscriptionHandle> SubscribeAsync(
        IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
    {
        if (!_options.UseNativeNotifications)
            return _poll.Subscribe(fullReferences, publishingInterval);

        var id = Interlocked.Increment(ref _nextNativeSubId);
        var handle = new NativeSubscriptionHandle(id);
        var registrations = new List<ITwinCATNotificationHandle>(fullReferences.Count);
        var now = DateTime.UtcNow;

        try
        {
            foreach (var reference in fullReferences)
            {
                if (!_tagsByName.TryGetValue(reference, out var def)) continue;
                if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;

                var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
                var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
                var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
                var bitIndex = parsed?.BitIndex;

                var reg = await client.AddNotificationAsync(
                    symbolName, def.DataType, bitIndex, publishingInterval,
                    _options.NotificationMaxDelayMs,
                    (_, value) => OnDataChange?.Invoke(this,
                        new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
                            value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
                    cancellationToken).ConfigureAwait(false);
                registrations.Add(reg);
            }
        }
        catch (Exception ex)
        {
            // On any registration failure, tear down everything we got so far + rethrow. Leaves
            // the subscription in a clean "never existed" state rather than a half-registered
            // state the caller has to clean up.
            _logger.LogWarning(ex,
                "TwinCAT driver '{DriverInstanceId}' native-notification registration failed; " +
                "tearing down {Count} partial registrations",
                _driverInstanceId, registrations.Count);
            foreach (var r in registrations) { try { r.Dispose(); } catch { } }
            throw;
        }

        _nativeSubs[id] = new NativeSubscription(handle, registrations);
        return handle;
    }

    /// <summary>Unsubscribes from a native or poll-based subscription.</summary>
    /// <param name="handle">The subscription handle to unsubscribe.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Completion task.</returns>
    public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
    {
        if (handle is NativeSubscriptionHandle native && _nativeSubs.TryRemove(native.Id, out var sub))
        {
            foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
            return Task.CompletedTask;
        }
        _poll.Unsubscribe(handle);
        return Task.CompletedTask;
    }

    private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle
    {
        /// <summary>Gets the diagnostic identifier for the subscription.</summary>
        public string DiagnosticId => $"twincat-native-sub-{Id}";
    }

    private sealed record NativeSubscription(
        NativeSubscriptionHandle Handle,
        IReadOnlyList<ITwinCATNotificationHandle> Registrations);

    // ---- IHostConnectivityProbe ----

    /// <summary>Gets the connectivity status for all configured devices.</summary>
    /// <returns>List of host connectivity statuses.</returns>
    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)
    {
        while (!ct.IsCancellationRequested)
        {
            var success = false;
            try
            {
                // Probe-initiated connects honor TwinCATProbeOptions.Timeout — distinct from
                // the driver-wide _options.Timeout used by reads/writes (Driver.TwinCAT-014).
                var client = await EnsureConnectedAsync(state, ct, _options.Probe.Timeout)
                    .ConfigureAwait(false);
                success = await client.ProbeAsync(ct).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
            catch
            {
                // Probe failure — EnsureConnectedAsync's connect-failure path already disposed
                // + cleared the client, so next tick will reconnect.
            }

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

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

    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;
        }
        _logger.LogInformation(
            "TwinCAT driver '{DriverInstanceId}' device '{HostAddress}' state: {OldState} → {NewState}",
            _driverInstanceId, state.Options.HostAddress, old, newState);
        OnHostStatusChanged?.Invoke(this,
            new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
    }

    // ---- IPerCallHostResolver ----

    /// <summary>
    ///     Documented sentinel returned by <see cref="ResolveHost"/> when neither the tag nor a
    ///     fallback device is configured. Empty-string never matches an
    ///     <see cref="HostConnectivityStatus.HostName"/> emitted by this driver (every real
    ///     host is an <c>ads://…</c> URI), so it cleanly signals "unresolved" without colliding
    ///     with a real host key. Used to be <see cref="DriverInstanceId"/>, which is a logical
    ///     config-DB identifier — that collided with consumers who expected the resolver and the
    ///     connectivity-status table to share keys (Driver.TwinCAT-006).
    /// </summary>
    public const string UnresolvedHostSentinel = "";

    /// <summary>Resolves the device host address for the specified tag reference.</summary>
    /// <param name="fullReference">The full tag reference.</param>
    /// <returns>The host address or <see cref="UnresolvedHostSentinel"/> if not found.</returns>
    public string ResolveHost(string fullReference)
    {
        if (_tagsByName.TryGetValue(fullReference, out var def))
            return def.DeviceHostAddress;
        // First device's HostAddress when one exists; otherwise the unresolved sentinel —
        // intentionally NOT DriverInstanceId, which is a config-DB key, not a host address
        // (Driver.TwinCAT-006).
        return _options.Devices.FirstOrDefault()?.HostAddress ?? UnresolvedHostSentinel;
    }

    /// <summary>
    ///     Lazily connect a device's client, serialized per device by
    ///     <see cref="DeviceState.ConnectGate"/> (Driver.TwinCAT-007). Without the gate, a
    ///     concurrent read / write / probe could each create + connect a separate client and
    ///     leak all-but-one, or dispose a client another thread is mid-connect on. The S7 and
    ///     AB-CIP drivers serialize device access the same way; single-connection-per-PLC is
    ///     also what docs/v2/driver-specs.md recommends.
    /// </summary>
    private async Task<ITwinCATClient> EnsureConnectedAsync(
        DeviceState device, CancellationToken ct, TimeSpan? timeoutOverride = null)
    {
        // Fast path — already connected, no gate needed.
        if (device.Client is { IsConnected: true } fast) return fast;

        await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            // Re-check under the gate: another caller may have connected while we waited.
            if (device.Client is { IsConnected: true } c) return c;

            // Discard a stale (created-but-disconnected) client before making a fresh one.
            if (device.Client is { IsConnected: false } stale)
            {
                try { stale.Dispose(); } catch { /* best-effort */ }
                device.Client = null;
            }

            var client = _clientFactory.Create();
            client.OnSymbolVersionChanged += HandleSymbolVersionChanged;
            // timeoutOverride lets the probe loop use TwinCATProbeOptions.Timeout for probe-
            // initiated connects rather than the driver-level _options.Timeout
            // (Driver.TwinCAT-014). Reads / writes pass null and get the driver default.
            var effectiveTimeout = timeoutOverride ?? _options.Timeout;
            try
            {
                await client.ConnectAsync(device.ParsedAddress, effectiveTimeout, ct)
                    .ConfigureAwait(false);
                _logger.LogInformation(
                    "TwinCAT driver '{DriverInstanceId}' connected to {HostAddress}",
                    _driverInstanceId, device.Options.HostAddress);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex,
                    "TwinCAT driver '{DriverInstanceId}' failed to connect to {HostAddress}",
                    _driverInstanceId, device.Options.HostAddress);
                client.OnSymbolVersionChanged -= HandleSymbolVersionChanged;
                client.Dispose();
                throw;
            }
            device.Client = client;
            return client;
        }
        finally
        {
            device.ConnectGate.Release();
        }
    }

    /// <summary>
    ///     Routes a wire-detected ADS symbol-version-changed (DeviceSymbolVersionInvalid 1809 /
    ///     0x0711) to Core as an <see cref="IRediscoverable"/> invocation (Driver.TwinCAT-013).
    ///     A PLC re-download invalidates every symbol + notification handle, so the address
    ///     space must be rebuilt — this is the documented TwinCAT failure mode, not a transient
    ///     connection error.
    /// </summary>
    private void HandleSymbolVersionChanged(object? sender, EventArgs e) =>
        OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(
            "TwinCAT symbol-version-changed (DeviceSymbolVersionInvalid 0x0711) — PLC program re-downloaded",
            ScopeHint: "TwinCAT"));

    /// <summary>
    ///     Synchronous teardown — no <c>await</c>, no captured sync context. The OPC UA stack
    ///     thread can call <see cref="Dispose"/>; routing through <c>DisposeAsync().GetResult()</c>
    ///     can deadlock on a single-threaded sync context (Driver.TwinCAT-015,
    ///     docs/v2/driver-stability.md). The operations here are all genuinely synchronous —
    ///     cancel tokens, wait on task handles with a hard timeout, dispose clients — so a
    ///     synchronous path does the right thing without re-entering the scheduler.
    /// </summary>
    /// <summary>Synchronously disposes driver resources without awaiting async operations.</summary>
    public void Dispose()
    {
        // Dispose native subscriptions first — handle disposal is sync.
        foreach (var sub in _nativeSubs.Values)
            foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
        _nativeSubs.Clear();

        // PollGroupEngine.DisposeAsync awaits loop tasks; we drive that synchronously here
        // (bounded wait — same 5s ceiling DisposeAsync uses internally) using Wait() on the
        // returned ValueTask so no sync-context capture happens.
        try { _poll.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); } catch { }

        foreach (var state in _devices.Values)
        {
            try { state.ProbeCts?.Cancel(); } catch { }
            if (state.ProbeTask is Task pt)
            {
                try { pt.Wait(TimeSpan.FromSeconds(2)); } catch { /* probe-cancel races are expected */ }
            }
            state.ProbeCts?.Dispose();
            state.ProbeCts = null;
            state.DisposeClient();
            state.DisposeGate();
        }
        _devices.Clear();
        _tagsByName.Clear();
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }

    /// <summary>Asynchronously disposes driver resources.</summary>
    /// <returns>Completion task.</returns>
    public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);

    internal sealed class DeviceState(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options)
    {
        /// <summary>Gets the parsed AMS address for the device.</summary>
        public TwinCATAmsAddress ParsedAddress { get; } = parsedAddress;
        /// <summary>Gets the device configuration options.</summary>
        public TwinCATDeviceOptions Options { get; } = options;
        /// <summary>Gets or sets the active ADS client for this device.</summary>
        public ITwinCATClient? Client { get; set; }

        /// <summary>Serializes connect / reconnect so concurrent callers never race a client
        ///     create-or-dispose for this device (Driver.TwinCAT-007).</summary>
        public SemaphoreSlim ConnectGate { get; } = new(1, 1);

        /// <summary>Gets the lock object for synchronizing host state transitions.</summary>
        public object ProbeLock { get; } = new();
        /// <summary>Gets or sets the current host connectivity state.</summary>
        public HostState HostState { get; set; } = HostState.Unknown;
        /// <summary>Gets or sets the UTC timestamp of the last host state change.</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>The running probe-loop task — awaited by <see cref="TwinCATDriver.ShutdownAsync"/>
        ///     so the loop cannot touch a disposed client (Driver.TwinCAT-009).</summary>
        public Task? ProbeTask { get; set; }

        /// <summary>Disposes the active ADS client if any.</summary>
        public void DisposeClient()
        {
            Client?.Dispose();
            Client = null;
        }

        /// <summary>Disposes the connection gate semaphore.</summary>
        public void DisposeGate() => ConnectGate.Dispose();
    }
}
