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.FOCAS;

/// <summary>
///     FOCAS driver for Fanuc CNC controllers (FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / Series
///     35i / Power Mate i). Talks to the CNC via the Fanuc FOCAS/2 FWLIB protocol through an
///     <see cref="IFocasClient"/> the deployment supplies — FWLIB itself is Fanuc-proprietary
///     and cannot be redistributed.
/// </summary>
/// <remarks>
///     PR 1 ships <see cref="IDriver"/> only; read / write / discover / subscribe / probe / host-
///     resolver capabilities land in PRs 2 and 3. The <see cref="IFocasClient"/> abstraction
///     shipped here lets PR 2 onward stay license-clean — all tests run against a fake client
///     + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
///     fail fast.
/// </remarks>
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
    IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
    /// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
    /// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
    private FocasDriverOptions _options;
    private readonly string _driverInstanceId;
    /// <summary>Mutable for the same reason as <see cref="_options"/> — the <c>Backend</c> config key
    /// selects it, so the two must move together or a new tag set polls through an old backend.</summary>
    private IFocasClientFactory _clientFactory;

    /// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
    /// was constructed directly (tests, or a caller supplying its own backend), which keeps the
    /// constructor-supplied options authoritative — exactly the prior behaviour.</summary>
    private readonly Func<string, FocasDriverBinding>? _rebind;
    private readonly PollGroupEngine _poll;
    private readonly ILogger<FocasDriver> _logger;
    private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
    // v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags
    // via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath).
    private readonly Dictionary<string, FocasTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
    // Resolves a read/write fullReference (always a RawPath in v3) to a tag definition by a single
    // authored-table hit. A miss is a miss — the driver surfaces BadNodeIdUnknown, never a blob-parse.
    private readonly EquipmentTagRefResolver<FocasTagDefinition> _resolver;
    // Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync /
    // WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot
    // call. ConcurrentDictionary is required because the poll loop
    // (ReadAsync) and the host write thread (WriteAsync) both mutate this cache when resolver-
    // produced equipment tags are encountered for the first time.
    private readonly ConcurrentDictionary<string, FocasAddress> _parsedAddressesByTagName =
        new(StringComparer.OrdinalIgnoreCase);
    private FocasAlarmProjection? _alarmProjection;
    // _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
    // Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
    // record is immutable so there is no torn-read risk on the object itself.
    private DriverHealth _health = new(DriverState.Unknown, null, null);

    /// <summary>Occurs when data changes on a subscribed tag.</summary>
    public event EventHandler<DataChangeEventArgs>? OnDataChange;
    /// <summary>Occurs when a device host connection status changes.</summary>
    public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
    /// <summary>Occurs when an alarm event is raised.</summary>
    public event EventHandler<AlarmEventArgs>? OnAlarmEvent;

    /// <summary>Initializes a new instance of the <see cref="FocasDriver"/> class with the provided options and dependencies.</summary>
    /// <param name="options">The driver configuration options.</param>
    /// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
    /// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
    /// <param name="logger">Optional logger instance.</param>
    /// <param name="rebind">Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency,
    /// supplied by the factory so <see cref="InitializeAsync"/> can adopt a changed config. Null (direct
    /// construction, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied
    /// options + backend.</param>
    public FocasDriver(FocasDriverOptions options, string driverInstanceId,
        IFocasClientFactory? clientFactory = null,
        ILogger<FocasDriver>? logger = null,
        Func<string, FocasDriverBinding>? rebind = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        _options = options;
        _driverInstanceId = driverInstanceId;
        _clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
        _rebind = rebind;
        _logger = logger ?? NullLogger<FocasDriver>.Instance;
        _resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
            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);
    }

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

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

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

    /// <summary>
    ///     Opens the configured CNC handles and builds the authored tag table.
    ///     <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
    ///     the factory (Gitea #516), and the re-parse is <b>all-or-nothing</b>: the typed options and the
    ///     <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> call
    ///     and are adopted together. That pairing is the point — adopting options alone would poll a NEW
    ///     device/tag set through the OLD backend, which is why this driver was excluded from the first
    ///     #516 pass.</para>
    ///     <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
    ///     no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
    ///     remains the outer guarantee.</para>
    /// </summary>
    /// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
    /// <param name="cancellationToken">Cancellation token for the operation.</param>
    /// <returns>A completed task.</returns>
    public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
        try
        {
            // #516: adopt a changed config. Options and the backend client factory are re-derived by ONE
            // parse and assigned together — a partial adoption would poll a new device/tag set through the
            // old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the
            // constructor-supplied pair, which is what every lifecycle test passing "{}" relies on.
            if (_rebind is not null && HasConfigBody(driverConfigJson))
            {
                var binding = _rebind(driverConfigJson);
                _options = binding.Options;
                _clientFactory = binding.ClientFactory;
            }

            // Fail fast if the factory is a stub/unimplemented backend — the operator must
            // see an actionable error at init rather than a phantom-Healthy driver that fails
            // every read/write/subscribe silently.
            _clientFactory.EnsureUsable();

            foreach (var device in _options.Devices)
            {
                var addr = FocasHostAddress.TryParse(device.HostAddress)
                    ?? throw new InvalidOperationException(
                        $"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
                _devices[device.HostAddress] = new DeviceState(addr, device);
            }
            // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
            // blob is mapped by the pure factory; the entry's WriteIdempotent flag + DeviceName are
            // threaded onto the def (they live on the RawTagEntry, not inside the blob). Per-tag misses
            // are tolerant (skipped + logged → BadNodeIdUnknown at read, "a miss is a miss") — a single
            // bad tag must not fail the whole driver init. The ONE hard failure is an unknown device
            // segment: a tag routed to a device not in the Devices list is a driver-level config error
            // that fails fast (Driver.FOCAS-003).
            foreach (var entry in _options.RawTags)
            {
                if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
                {
                    _logger.LogWarning(
                        "FOCAS tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
                        _driverInstanceId, entry.RawPath);
                    continue;
                }
                // TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig
                // (keyed by the RawPath's device segment). Until Wave C threads DeviceConfig through, recover
                // the host by matching the entry's DeviceName against the configured Devices list.
                var deviceHost = ResolveDeviceHost(entry.DeviceName);
                if (deviceHost is null)
                    throw new InvalidOperationException(
                        $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
                        $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
                if (!_devices.TryGetValue(deviceHost, out var device))
                    throw new InvalidOperationException(
                        $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
                        $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
                // Per-tag address / capability-matrix validation is tolerant: an unparseable address or a
                // capability-matrix violation skips the tag (→ BadNodeIdUnknown at read) rather than failing
                // the whole init (R2-11 05/UNDER-6 — the same pre-flight, surfacing a resolver miss).
                var parsed = FocasAddress.TryParse(def.Address);
                if (parsed is null)
                {
                    _logger.LogWarning(
                        "FOCAS tag has invalid Address '{Address}'; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
                        def.Address, _driverInstanceId, entry.RawPath);
                    continue;
                }
                if (FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
                {
                    _logger.LogWarning(
                        "FOCAS tag '{RawPath}' ({Address}) rejected by capability matrix: {Reason}; skipping. Driver={DriverInstanceId}",
                        entry.RawPath, def.Address, reason, _driverInstanceId);
                    continue;
                }
                _tagsByRawPath[entry.RawPath] = def with
                {
                    DeviceHostAddress = deviceHost,
                    WriteIdempotent = entry.WriteIdempotent,
                };
                // Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every
                // hot-path call. The address string has already been validated by FocasAddress.TryParse
                // above; reusing the parsed record avoids per-tick allocs on subscription pollers.
                _parsedAddressesByTagName[entry.RawPath] = parsed;
            }

            if (_options.Probe.Enabled)
            {
                foreach (var state in _devices.Values)
                {
                    state.ProbeCts = new CancellationTokenSource();
                    var ct = state.ProbeCts.Token;
                    _ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
                }
            }

            if (_options.HandleRecycle.Enabled)
            {
                foreach (var state in _devices.Values)
                {
                    state.RecycleCts = new CancellationTokenSource();
                    var ct = state.RecycleCts.Token;
                    _ = Task.Run(() => RecycleLoopAsync(state, ct), ct);
                }
            }

            if (_options.AlarmProjection.Enabled)
                _alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);

            if (_options.FixedTree.Enabled)
            {
                foreach (var state in _devices.Values)
                {
                    state.FixedTreeCts = new CancellationTokenSource();
                    var ct = state.FixedTreeCts.Token;
                    _ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
                }
            }

            Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
        }
        catch (Exception ex)
        {
            Volatile.Write(ref _health, new DriverHealth(DriverState.Faulted, null, ex.Message));
            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 _poll.DisposeAsync().ConfigureAwait(false);
        if (_alarmProjection is { } proj)
        {
            await proj.DisposeAsync().ConfigureAwait(false);
            _alarmProjection = null;
        }
        foreach (var state in _devices.Values)
        {
            // Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
            // but we now log the cause so a noisy shutdown leaves a Debug trace.
            try { state.ProbeCts?.Cancel(); }
            catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
            state.ProbeCts?.Dispose();
            state.ProbeCts = null;
            try { state.RecycleCts?.Cancel(); }
            catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
            state.RecycleCts?.Dispose();
            state.RecycleCts = null;
            try { state.FixedTreeCts?.Cancel(); }
            catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
            state.FixedTreeCts?.Dispose();
            state.FixedTreeCts = null;
            state.DisposeClient();
        }
        _devices.Clear();
        _tagsByRawPath.Clear();
        _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
        _parsedAddressesByTagName.Clear();
        Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
    }

    /// <inheritdoc />
    public DriverHealth GetHealth() => Volatile.Read(ref _health);
    /// <inheritdoc />
    public long GetMemoryFootprint() => 0;
    /// <inheritdoc />
    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    /// <summary>Gets the number of configured devices.</summary>
    internal int DeviceCount => _devices.Count;
    /// <summary>Gets the state of a device by host address.</summary>
    /// <param name="hostAddress">The host address of the device.</param>
    /// <returns>The device state if found; otherwise null.</returns>
    internal DeviceState? GetDeviceState(string hostAddress) =>
        _devices.TryGetValue(hostAddress, out var s) ? s : null;

    /// <summary>Test seam — the resolved options the factory built this driver from.</summary>
    internal FocasDriverOptions Options => _options;

    /// <summary>Test seam — returns true when a parsed <see cref="FocasAddress"/> for the given
    /// reference is present in the address cache (both authored and equipment-tag paths).</summary>
    /// <param name="reference">The tag or equipment-tag reference to check.</param>
    /// <returns><see langword="true"/> if a parsed address is cached for the reference; otherwise <see langword="false"/>.</returns>
    internal bool IsParsedAddressCached(string reference) =>
        _parsedAddressesByTagName.ContainsKey(reference);

    // TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig (keyed by
    // the RawPath's device segment). Until Wave C threads DeviceConfig through, recover the host by matching
    // the RawTagEntry's DeviceName against the configured Devices list (by DeviceName, falling back to
    // HostAddress; and, for a single-device driver, an empty DeviceName routes to the sole device). Returns
    // null when no device matches — the caller fails the tag fast (Driver.FOCAS-003).
    private string? ResolveDeviceHost(string deviceName)
    {
        foreach (var d in _options.Devices)
        {
            if (string.Equals(d.DeviceName ?? d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)
                || string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
                return d.HostAddress;
        }
        if (string.IsNullOrEmpty(deviceName) && _options.Devices.Count == 1)
            return _options.Devices[0].HostAddress;
        return null;
    }

    // Resolves a tag definition to its parsed FocasAddress, caching the result so that
    // equipment tags (resolver-produced, not seeded at InitializeAsync) don't re-parse the
    // address string on every ReadAsync / WriteAsync hot-path call.
    // Throwing inside the GetOrAdd factory propagates the exception to the caller and does
    // NOT store anything in the dictionary — consistent with the existing "fail fast on a
    // malformed address" behaviour from the init-time validation of authored tags.
    private FocasAddress ResolveParsedAddress(FocasTagDefinition def) =>
        _parsedAddressesByTagName.GetOrAdd(def.Name, _ =>
            FocasAddress.TryParse(def.Address)
                ?? throw new InvalidOperationException(
                    $"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'."));

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

        for (var i = 0; i < fullReferences.Count; i++)
        {
            var reference = fullReferences[i];

            // Fixed-tree T1 — fixed-tree references are synthesized from the cached
            // dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
            // already fires them on cadence.
            if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx)
            {
                results[i] = fx;
                continue;
            }

            if (!_resolver.TryResolve(reference, out var def))
            {
                results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
                continue;
            }
            if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
            {
                results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
                continue;
            }

            try
            {
                var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
                var parsed = ResolveParsedAddress(def);
                var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);

                results[i] = new DataValueSnapshot(value, status, now, now);
                if (status == FocasStatusMapper.Good)
                    Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
                else
                    Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
                        Volatile.Read(ref _health).LastSuccessfulRead,
                        $"FOCAS status 0x{status:X8} reading {reference}"));
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
            catch (OperationCanceledException)
            {
                // Per-call timeout (not external cancellation) — the read stalled past the device
                // Timeout budget. Surface a recoverable comm error so the BadWaitingForInitialData
                // seed is overwritten and health degrades, instead of the read hanging forever.
                results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
                Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
                    Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS read timed out for {reference}"));
            }
            catch (Exception ex)
            {
                results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
                Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
                    Volatile.Read(ref _health).LastSuccessfulRead, ex.Message));
            }
        }

        return results;
    }

    // ---- IWritable ----

    /// <inheritdoc />
    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 (!_resolver.TryResolve(w.FullReference, out var def))
            {
                results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
                continue;
            }
            if (!def.Writable)
            {
                results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
                continue;
            }
            if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
            {
                results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
                continue;
            }

            try
            {
                var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
                var parsed = ResolveParsedAddress(def);
                var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
                results[i] = new WriteResult(status);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
            catch (OperationCanceledException)
            {
                // Per-call timeout (not external cancellation) — the write stalled past the device
                // Timeout budget. Surface a recoverable comm error rather than aborting the batch.
                results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
                Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
                    Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS write timed out for {w.FullReference}"));
            }
            catch (NotSupportedException nse)
            {
                results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
                Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
                    Volatile.Read(ref _health).LastSuccessfulRead, nse.Message));
            }
            catch (Exception ex) when (ex is FormatException or InvalidCastException)
            {
                results[i] = new WriteResult(FocasStatusMapper.BadTypeMismatch);
            }
            catch (OverflowException)
            {
                results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange);
            }
            catch (Exception ex)
            {
                results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
                Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
                    Volatile.Read(ref _health).LastSuccessfulRead, ex.Message));
            }
        }

        return results;
    }

    // ---- ITagDiscovery ----

    /// <inheritdoc />
    // Retry-until-stable: the FixedTree subtree is filled in asynchronously by
    // FixedTreeLoopAsync a couple of seconds AFTER connect, so the first post-connect
    // DiscoverAsync pass would miss it — the host must re-run discovery until the captured
    // node set is non-empty and stable.
    public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;

    /// <summary>The FixedTree device folder is real enumeration — config-gated on FixedTree.Enabled,
    /// which the universal browser's PatchForBrowse turns on. FOCAS is the UntilStable driver: the
    /// FixedTree cache fills a couple of seconds post-connect, so the browser's settle loop re-captures
    /// until the node set is non-empty and stable.</summary>
    public bool SupportsOnlineDiscovery => true;

    /// <inheritdoc />
    public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        var root = builder.Folder("FOCAS", "FOCAS");
        foreach (var device in _options.Devices)
        {
            var label = device.DeviceName ?? device.HostAddress;
            var deviceFolder = root.Folder(device.HostAddress, label);

            // Fixed-tree T1 — Identity + Axes subtrees, populated once per session
            // from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
            if (_options.FixedTree.Enabled
                && _devices.TryGetValue(device.HostAddress, out var state)
                && state.FixedTreeCache is { } cache)
            {
                var identity = deviceFolder.Folder("Identity", "Identity");
                EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
                EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
                EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
                EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
                EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
                EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);

                var axesFolder = deviceFolder.Folder("Axes", "Axes");
                foreach (var axis in cache.Axes)
                {
                    var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
                    EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
                    EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
                    EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
                    EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
                    if (cache.Capabilities.ServoLoad)
                        EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
                }
                EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
                EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");

                // Spindle subtree — one folder per discovered spindle, suppressed
                // entirely on series that don't export cnc_rdspdlname. Per-spindle
                // Load + MaxRpm each gated on their own capability probe.
                if (cache.Capabilities.Spindles)
                {
                    var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
                    for (var i = 0; i < cache.Spindles.Count; i++)
                    {
                        var s = cache.Spindles[i];
                        var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
                        var spindleFolder = spindleRoot.Folder(name, name);
                        if (cache.Capabilities.SpindleLoad)
                            EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
                        if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
                            EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
                    }
                }

                // Fixed-tree T2 — Program + OperationMode subtrees (gated on capability).
                if (cache.Capabilities.ProgramInfo)
                {
                    var program = deviceFolder.Folder("Program", "Program");
                    EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
                    EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
                    EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
                    EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
                    EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
                    EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);

                    var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
                    EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
                    EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
                }

                // Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
                if (cache.Capabilities.Timers)
                {
                    var timers = deviceFolder.Folder("Timers", "Timers");
                    EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
                    EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
                    EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
                    EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
                }
            }

            var tagsForDevice = _tagsByRawPath.Values.Where(t =>
                string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
            foreach (var tag in tagsForDevice)
            {
                // The wire backend is read-only by design (WriteAsync returns BadNotWritable
                // for every address) so all FOCAS tags are advertised as ViewOnly regardless
                // of the Writable config field. Advertising Operate would mislead OPC UA
                // clients and the DriverNodeManager ACL layer into granting write permission
                // on nodes that can never be written.
                deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
                    FullName: tag.Name,
                    DriverDataType: tag.DataType.ToDriverDataType(),
                    IsArray: false,
                    ArrayDim: null,
                    SecurityClass: SecurityClassification.ViewOnly,
                    IsHistorized: false,
                    IsAlarm: false,
                    WriteIdempotent: tag.WriteIdempotent));
            }
        }
        return Task.CompletedTask;
    }

    private enum FocasDriverDataType { String, Int32, Float64 }

    private static void EmitIdentityVariable(
        IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
    {
        var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
        folder.Variable(field, field, new DriverAttributeInfo(
            FullName: fullName,
            DriverDataType: type switch
            {
                FocasDriverDataType.Int32 => DriverDataType.Int32,
                FocasDriverDataType.Float64 => DriverDataType.Float64,
                _ => DriverDataType.String,
            },
            IsArray: false,
            ArrayDim: null,
            SecurityClass: SecurityClassification.ViewOnly,
            IsHistorized: false,
            IsAlarm: false,
            WriteIdempotent: false));
    }

    private static void EmitAxisVariable(
        IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
    {
        var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
        folder.Variable(field, field, new DriverAttributeInfo(
            FullName: fullName,
            DriverDataType: DriverDataType.Float64,
            IsArray: false,
            ArrayDim: null,
            SecurityClass: SecurityClassification.ViewOnly,
            IsHistorized: false,
            IsAlarm: false,
            WriteIdempotent: false));
    }

    /// <summary>
    ///     Emit a variable under a named fixed-tree folder (Program, OperationMode,
    ///     …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
    /// </summary>
    private static void EmitFixedVariable(
        IAddressSpaceBuilder folder, string deviceHost, string folderPath,
        string field, DriverDataType type)
    {
        var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
        folder.Variable(field, field, new DriverAttributeInfo(
            FullName: fullName,
            DriverDataType: type,
            IsArray: false,
            ArrayDim: null,
            SecurityClass: SecurityClassification.ViewOnly,
            IsHistorized: false,
            IsAlarm: false,
            WriteIdempotent: false));
    }

    /// <summary>
    ///     Canonical full-reference shape for a fixed-tree node. Keeps the device
    ///     host as a prefix so multi-device configs don't collide, and the rest is
    ///     the path inside the tree. Matches what poll-loop snapshots publish +
    ///     what <see cref="ReadAsync"/> looks up.
    /// </summary>
    /// <param name="deviceHost">The host address of the device.</param>
    /// <param name="path">The path within the fixed tree.</param>
    /// <returns>The canonical full reference for the node.</returns>
    internal static string FixedTreeReference(string deviceHost, string path) =>
        $"{deviceHost}/{path}";

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

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

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

    // ---- IHostConnectivityProbe ----

    /// <inheritdoc />
    public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
        [.. _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
            {
                // The probe bypasses the connect backoff — its own interval is the bounded recovery
                // cadence, and a successful probe connect resets the window for the data + loop paths.
                var client = await EnsureConnectedAsync(state, ct, bypassBackoff: true).ConfigureAwait(false);
                // Apply Probe.Timeout so a hung CNC socket gets cancelled at the configured
                // budget rather than blocking until the OS TCP timeout.
                // TimeSpan.Zero / negative means "no per-probe timeout" — fall back to the loop
                // cancellation token unmodified.
                var probeTimeout = _options.Probe.Timeout;
                if (probeTimeout > TimeSpan.Zero)
                {
                    using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
                    linked.CancelAfter(probeTimeout);
                    success = await client.ProbeAsync(linked.Token).ConfigureAwait(false);
                }
                else
                {
                    success = await client.ProbeAsync(ct).ConfigureAwait(false);
                }
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
            catch (OperationCanceledException ex)
            {
                // Per-probe timeout fired — the loop is still alive. Treat as a failed probe so
                // the host state transitions to Stopped, and log so silent timeouts are visible.
                _logger.LogDebug(ex, "FOCAS probe timed out for {Host} after {Timeout}",
                    state.Options.HostAddress, _options.Probe.Timeout);
            }
            catch (Exception ex)
            {
                /* connect-failure path already disposed + cleared the client */
                _logger.LogDebug(ex, "FOCAS probe failed for {Host}", state.Options.HostAddress);
            }

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

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

    /// <summary>
    ///     Per-device fixed-tree poll loop. First tick resolves sysinfo + axis names
    ///     (once) so <see cref="DiscoverAsync"/> can render the subtree on its next
    ///     invocation; every tick thereafter fires a <c>cnc_rddynamic2</c> per axis
    ///     and publishes OnDataChange for the axis positions + feed rate + spindle
    ///     speed.
    /// </summary>
    private async Task FixedTreeLoopAsync(DeviceState state, CancellationToken ct)
    {
        // Bootstrap: identity + axis names + per-optional-API capability probe.
        // Each optional call is attempted once; failures (EW_FUNC / EW_NOOPT / EW_VERSION)
        // record the capability as unsupported and suppress the corresponding nodes
        // in DiscoverAsync + the poll loop.
        while (!ct.IsCancellationRequested && state.FixedTreeCache is null)
        {
            try
            {
                var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
                var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
                var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);

                // Per-axis decimal-place figures (cnc_getfigure), fetched once. Auto figures win
                // over the manual PositionDecimalPlaces config at the publish seam; an empty list
                // (or a failed/empty figure read) makes every axis fall back to the config knob.
                // Defensive: a figure-read failure must NOT fault device init — default to empty.
                state.PositionFigures = await SafeProbe(() => client.GetPositionFiguresAsync(ct), []);

                // Optional-API probes — each returns empty / throws when unsupported.
                var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
                var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
                var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
                var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
                var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
                var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);

                var caps = new FocasFixedTreeCapabilities(
                    Spindles: spindles.Count > 0,
                    SpindleLoad: spindleLoad.Count > 0,
                    SpindleMaxRpm: spindleMaxRpms.Count > 0,
                    ServoLoad: servoLoads.Count > 0,
                    ProgramInfo: programInfo is not null,
                    Timers: timer is not null);

                state.FixedTreeCache = new FocasFixedTreeCache(
                    sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "FOCAS fixed-tree bootstrap failed for {Host} — retrying",
                    state.Options.HostAddress);
                try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
                catch (OperationCanceledException) { return; }
            }
        }

        // Prime the spindle-loads cache from bootstrap if supported — avoids a
        // "tree is there but reads say BadNodeIdUnknown" window on startup.
        if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
        {
            try
            {
                var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
                var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
                for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
            }
            catch (Exception ex)
            {
                /* first-tick poll will retry */
                _logger.LogDebug(ex,
                    "FOCAS bootstrap spindle-loads prime failed for {Host} — first poll tick will retry",
                    state.Options.HostAddress);
            }
        }

        var programPollDue = DateTime.MinValue;
        var timerPollDue = DateTime.MinValue;
        while (!ct.IsCancellationRequested)
        {
            try
            {
                var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
                var cache = state.FixedTreeCache;
                if (cache is null) break;
                FocasDynamicSnapshot? firstAxisSnap = null;
                for (var i = 0; i < cache.Axes.Count; i++)
                {
                    var axisIndex = i + 1; // FOCAS uses 1-based axis indexing
                    var axis = cache.Axes[i];
                    var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
                    PublishAxisSnapshot(state, axis, snap, i); // i = 0-based axis index → PositionFigures lookup
                    if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
                }

                // Servo loads + spindle loads — both return bulk arrays, so folding
                // into the axis cadence is cheap. Each is gated by the bootstrap
                // capability probe — unsupported on this series = silent skip.
                if (cache.Capabilities.ServoLoad)
                {
                    try
                    {
                        var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false);
                        PublishServoLoads(state, loads);
                    }
                    catch (Exception ex)
                    {
                        /* transient — next tick retries */
                        _logger.LogDebug(ex, "FOCAS servo-loads poll failed for {Host}",
                            state.Options.HostAddress);
                    }
                }
                if (cache.Capabilities.SpindleLoad)
                {
                    try
                    {
                        var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
                        for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
                    }
                    catch (Exception ex)
                    {
                        /* transient */
                        _logger.LogDebug(ex, "FOCAS spindle-loads poll failed for {Host}",
                            state.Options.HostAddress);
                    }
                }

                // Program-info poll runs on its own cadence — much slower than the axis
                // poll because program / mode transitions are operator-driven.
                var programInterval = _options.FixedTree.ProgramPollInterval;
                if (cache.Capabilities.ProgramInfo
                    && programInterval > TimeSpan.Zero && DateTime.UtcNow >= programPollDue)
                {
                    try
                    {
                        var program = await client.GetProgramInfoAsync(ct).ConfigureAwait(false);
                        state.LastProgramInfo = program;
                        if (firstAxisSnap is { } s) state.LastProgramAxisRef = s;
                    }
                    catch (Exception ex)
                    {
                        /* transient — next tick retries */
                        _logger.LogDebug(ex, "FOCAS program-info poll failed for {Host}",
                            state.Options.HostAddress);
                    }
                    programPollDue = DateTime.UtcNow + programInterval;
                }

                // Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
                var timerInterval = _options.FixedTree.TimerPollInterval;
                if (cache.Capabilities.Timers
                    && timerInterval > TimeSpan.Zero && DateTime.UtcNow >= timerPollDue)
                {
                    foreach (FocasTimerKind kind in Enum.GetValues<FocasTimerKind>())
                    {
                        try
                        {
                            var t = await client.GetTimerAsync(kind, ct).ConfigureAwait(false);
                            state.LastTimers[kind] = t;
                        }
                        catch (Exception ex)
                        {
                            /* per-kind failures are non-fatal */
                            _logger.LogDebug(ex, "FOCAS timer poll failed for {Host} kind={Kind}",
                                state.Options.HostAddress, kind);
                        }
                    }
                    timerPollDue = DateTime.UtcNow + timerInterval;
                }
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
            catch (Exception ex)
            {
                /* next tick retries — transient blips are expected */
                _logger.LogDebug(ex, "FOCAS fixed-tree poll tick failed for {Host}",
                    state.Options.HostAddress);
            }

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

    /// <summary>
    ///     Cache a fresh axis snapshot. The poll loop doesn't fire <c>OnDataChange</c>
    ///     directly — subscribers go through the normal <c>SubscribeAsync</c> →
    ///     <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
    ///     <see cref="TryReadFixedTree"/> and returns these cached values.
    /// </summary>
    private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap, int axisIndex)
    {
        var host = state.Options.HostAddress;
        // cnc_rddynamic2 returns positions as scaled integers; divide by 10^figures so they
        // surface in engineering units on the Float64 axis nodes. The figure is per-axis:
        // an auto cnc_getfigure figure WINS, and the configured PositionDecimalPlaces is the
        // fallback when the CNC didn't report one for that axis (see AxisFactor). A figure of
        // 0 yields factor 1.0 — i.e. the integer widened to double, byte-identical to legacy
        // behaviour (12345 / 1.0 == 12345.0). The managed WireFocasClient fetches figures live
        // via ReadPositionFiguresAsync; the config knob is the per-axis fallback only. FeedRate /
        // SpindleSpeed (rate snapshot) and ServoLoad are NOT position-scaled (published elsewhere).
        var factor = AxisFactor(state, axisIndex);
        state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition / factor;
        state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition / factor;
        state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition / factor;
        state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo / factor;
    }

    /// <summary>
    ///     Resolve the position-scale factor (10^figure) for a single axis. Auto
    ///     (<c>cnc_getfigure</c>) wins per-axis; manual <c>PositionDecimalPlaces</c> is the
    ///     fallback when the CNC didn't report a figure for that axis. Both clamp non-negative;
    ///     0 ⇒ factor 1.0 (legacy byte-identical). The managed wire backend fetches figures live
    ///     via <c>ReadPositionFiguresAsync</c>; the fallback branch fires only when the CNC
    ///     reports no figure for a given axis (or the figure read returns empty).
    /// </summary>
    private static double AxisFactor(DeviceState state, int axisIndex)
    {
        var figures = state.PositionFigures;
        var dp = axisIndex >= 0 && axisIndex < figures.Count && figures[axisIndex] >= 0
            ? figures[axisIndex]
            : Math.Max(0, state.Options.PositionDecimalPlaces);
        return dp > 0 ? Math.Pow(10, dp) : 1.0;
    }

    private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
    {
        var host = state.Options.HostAddress;
        state.LastFixedSnapshots[FixedTreeReference(host, "Axes/FeedRate/Actual")] = snap.ActualFeedRate;
        state.LastFixedSnapshots[FixedTreeReference(host, "Axes/SpindleSpeed/Actual")] = snap.ActualSpindleSpeed;
    }

    /// <summary>
    ///     Cache servo-load percentages keyed by axis name. Stored separately from
    ///     <c>LastFixedSnapshots</c> (which is keyed by full-reference) because the
    ///     servo-load read path resolves by axis name.
    /// </summary>
    private static void PublishServoLoads(DeviceState state, IReadOnlyList<FocasServoLoad> loads)
    {
        foreach (var load in loads)
            state.LastServoLoads[load.AxisName] = load.LoadPercent;
    }

    private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
        state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;

    /// <summary>
    ///     Call an optional probe that returns a collection; swallow any exception
    ///     and return <paramref name="fallback"/>. Used by bootstrap to capture
    ///     per-series capability without letting one failed probe take down the
    ///     entire bootstrap sequence.
    /// </summary>
    private static async Task<IReadOnlyList<T>> SafeProbe<T>(
        Func<Task<IReadOnlyList<T>>> probe, IReadOnlyList<T> fallback)
    {
        try { return await probe().ConfigureAwait(false); }
        catch { return fallback; }
    }

    /// <summary>
    ///     Nullable variant — probe returns a single object or null on failure.
    /// </summary>
    private static async Task<T?> SafeTryProbe<T>(Func<Task<T>> probe) where T : class
    {
        try { return await probe().ConfigureAwait(false); }
        catch { return null; }
    }

    /// <summary>
    ///     Read cached last-fixed-tree snapshots. Returns the projected value when
    ///     the reference looks like a fixed-tree FullName; null when it doesn't
    ///     (callers fall through to the user-authored tag path).
    /// </summary>
    private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now)
    {
        foreach (var state in _devices.Values)
        {
            if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
            if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
                return new DataValueSnapshot(raw, FocasStatusMapper.Good, now, now);

            // Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
            var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
            if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
            {
                var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
                if (state.LastServoLoads.TryGetValue(axisName, out var load))
                    return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
            }

            // Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
            if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
                && state.FixedTreeCache is { } spindleCache)
            {
                var tail = suffixFull["Spindle/".Length..];
                var slash = tail.IndexOf('/');
                if (slash > 0)
                {
                    var spindleName = tail[..slash];
                    var field = tail[(slash + 1)..];
                    var idx = -1;
                    for (var i = 0; i < spindleCache.Spindles.Count; i++)
                    {
                        var s = spindleCache.Spindles[i];
                        var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
                        if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
                    }
                    if (idx >= 0)
                    {
                        object? value = field switch
                        {
                            "Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
                            "MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
                            _ => null,
                        };
                        if (value is not null)
                            return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
                    }
                }
            }
            // Identity strings + program / op-mode fields aren't cached as doubles —
            // re-derive from the struct caches.
            if (state.FixedTreeCache is { } cache)
            {
                var suffix = reference[(state.Options.HostAddress.Length + 1)..];
                var value = suffix switch
                {
                    "Identity/SeriesNumber" => (object)cache.SysInfo.Series,
                    "Identity/Version" => cache.SysInfo.Version,
                    "Identity/MaxAxes" => cache.SysInfo.MaxAxis,
                    "Identity/CncType" => cache.SysInfo.CncType,
                    "Identity/MtType" => cache.SysInfo.MtType,
                    "Identity/AxisCount" => cache.SysInfo.AxesCount,
                    "Program/Name" => (object?)state.LastProgramInfo?.Name,
                    "Program/ONumber" => state.LastProgramInfo?.ONumber,
                    "Program/BlockCount" => state.LastProgramInfo?.BlockCount,
                    "Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
                    "Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
                    "Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
                    "OperationMode/Mode" => state.LastProgramInfo?.Mode,
                    "OperationMode/ModeText" => state.LastProgramInfo is { } pi
                        ? FocasOpMode.ToText(pi.Mode) : null,
                    "Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
                    "Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
                    "Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
                    "Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
                    _ => null,
                };
                if (value is not null)
                    return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
            }
        }
        return null;
    }

    private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try { await Task.Delay(_options.HandleRecycle.Interval, ct).ConfigureAwait(false); }
            catch (OperationCanceledException) { break; }

            // Close the current handle — the next Read / Write / Probe call triggers
            // EnsureConnectedAsync, which reopens a fresh one. We don't block here on
            // reconnect because the goal is just to release the FWLIB handle slot; a
            // readable tick one probe cycle later is an acceptable cost.
            try { state.DisposeClient(); }
            catch (Exception ex)
            {
                /* already disposed or race — next EnsureConnected recovers */
                _logger.LogDebug(ex, "FOCAS handle-recycle dispose failed for {Host}", state.Options.HostAddress);
            }
        }
    }

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

    // ---- IAlarmSource ----

    /// <inheritdoc />
    public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
        IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
    {
        if (_alarmProjection is null)
            throw new NotSupportedException(
                "FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
        return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
    }

    /// <inheritdoc />
    public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
        _alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;

    /// <inheritdoc />
    public Task AcknowledgeAsync(
        IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
        _alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;

    /// <summary>Raises an alarm event with the provided arguments.</summary>
    /// <param name="args">The alarm event arguments.</param>
    internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);

    /// <summary>
    ///     Poll every configured device's active-alarm list in one pass. Used by the alarm
    ///     projection — kept <c>internal</c> rather than <c>public</c> because callers that
    ///     want alarm events should subscribe through <c>IAlarmSource</c> instead.
    /// </summary>
    /// <param name="deviceFilter">Optional set of device host addresses to filter results; null includes all devices.</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>A list of tuples containing host address and active alarms for each device.</returns>
    internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
        ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
    {
        var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
        foreach (var state in _devices.Values)
        {
            if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
            try
            {
                var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
                var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
                result.Add((state.Options.HostAddress, alarms));
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
            catch (Exception ex)
            {
                /* surface a device-local fault on the next tick */
                _logger.LogDebug(ex, "FOCAS alarm poll failed for {Host}", state.Options.HostAddress);
            }
        }
        return result;
    }

    // ---- IPerCallHostResolver ----

    /// <summary>
    ///     Resolve a reference to the device host that keys its per-host resilience
    ///     (bulkhead / circuit breaker). CONV-4: routes through
    ///     <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
    ///     JSON) resolves to its OWN authored device rather than the first-device fallback. The
    ///     empty-host guard covers FOCAS's <c>?? ""</c> default so an address-less equipment tag
    ///     falls back rather than keying an empty host. The resolver caches parses, so this adds no
    ///     per-call cost after first use.
    /// </summary>
    /// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
    /// <returns>The device host key.</returns>
    public string ResolveHost(string fullReference)
    {
        if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress))
            return def.DeviceHostAddress;
        return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
    }

    /// <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 connect failure.
    /// </summary>
    private sealed class ConnectionThrottledException(string message) : Exception(message);

    private async Task<IFocasClient> EnsureConnectedAsync(
        DeviceState device, CancellationToken ct, bool bypassBackoff = false)
    {
        if (device.Client is { IsConnected: true } c) return c;

        // 05/STAB-8: inside an open backoff window a data-path / per-tick-loop caller fails fast — no
        // socket create, no wire connect. The probe loop bypasses this and drives failure/success.
        if (!bypassBackoff && !device.Backoff.ShouldAttempt(DateTime.UtcNow))
            throw new ConnectionThrottledException(
                $"FOCAS connect to {device.Options.HostAddress} throttled — backoff window open after a recent connect failure.");

        // Discard the existing client (if any) before creating a new one. A client that is
        // non-null but not connected may have been disposed by a HandleRecycle race or a prior
        // teardown — retrying ConnectAsync on a disposed FocasWireClient hits ThrowIfDisposed and
        // returns a permanent BadCommunicationError with no recovery. Replacing it unconditionally
        // ensures EnsureConnectedAsync always works with a fresh, non-disposed instance.
        if (device.Client is not null)
        {
            device.Client.Dispose();
            device.Client = null;
        }

        // Wrap the raw wire client so every operation on the device's single FOCAS/2 socket is
        // serialized (request→response on one socket cannot interleave) and time-bounded. Without
        // this, the equipment poll, fixed-tree loop, probe, and recycle loop collide on the shared
        // socket and a stalled read blocks forever — leaving bound tags at BadWaitingForInitialData.
        device.Client = new SynchronizedFocasClient(_clientFactory.Create(), _options.Timeout);
        try
        {
            await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
        }
        catch
        {
            device.Client.Dispose();
            device.Client = null;
            device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window
            throw;
        }
        device.Backoff.RecordSuccess(); // 05/STAB-8: recovery resets the window immediately
        return device.Client;
    }

    /// <summary>Disposes the driver and releases all resources synchronously.</summary>
    public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
    /// <summary>Disposes the driver and releases all resources asynchronously.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);

    /// <summary>
    ///     Per-device fixed-tree cache populated once at first successful connect and
    ///     read-only thereafter. Used by <see cref="DiscoverAsync"/> to render the
    ///     tree + by <see cref="TryReadFixedTree"/> for synchronous Identity/* reads.
    /// </summary>
    internal sealed record FocasFixedTreeCache(
        FocasSysInfo SysInfo,
        IReadOnlyList<FocasAxisName> Axes,
        IReadOnlyList<FocasSpindleName> Spindles,
        IReadOnlyList<int> SpindleMaxRpms,
        FocasFixedTreeCapabilities Capabilities);

    /// <summary>
    ///     Per-device optional-API capability flags — which of the "this may or may not
    ///     exist on this CNC series" calls succeeded at bootstrap. Drives per-series
    ///     node suppression so a 16i that doesn't export <c>cnc_rdspmaxrpm</c> simply
    ///     doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
    ///     <c>BadDeviceFailure</c> on every read).
    /// </summary>
    internal sealed record FocasFixedTreeCapabilities(
        bool Spindles,          // cnc_rdspdlname returned 1+ spindle names
        bool SpindleLoad,       // cnc_rdspload bootstrap probe succeeded
        bool SpindleMaxRpm,     // cnc_rdspmaxrpm bootstrap probe succeeded
        bool ServoLoad,         // cnc_rdsvmeter bootstrap probe returned data
        bool ProgramInfo,       // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
        bool Timers);           // cnc_rdtimer works for at least PowerOn

    internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
    {
        /// <summary>Gets the parsed host address.</summary>
        public FocasHostAddress ParsedAddress { get; } = parsedAddress;
        /// <summary>Gets the device configuration options.</summary>
        public FocasDeviceOptions Options { get; } = options;
        /// <summary>Gets or sets the FOCAS client instance.</summary>
        public IFocasClient? Client { get; set; }

        /// <summary>
        ///     Per-device connect-attempt throttle (05/STAB-8). A dead CNC fails fast inside the
        ///     backoff window instead of paying a full two-socket reconnect on every tick of each of
        ///     the read / write / probe / fixed-tree / recycle loops; the probe loop bypasses it and a
        ///     successful connect resets it. FOCAS's <c>EnsureConnectedAsync</c> is not gate-serialized
        ///     (the loops already race the client swap by design), so this is best-effort — atomic
        ///     enough on 64-bit for the int/DateTime state, and a benign extra/skipped attempt is the
        ///     worst outcome of a race.
        /// </summary>
        public ConnectionBackoff Backoff { get; } = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));

        /// <summary>Gets the lock object for probe synchronization.</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 timestamp when host state 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 the cancellation token source for the recycle loop.</summary>
        public CancellationTokenSource? RecycleCts { get; set; }
        /// <summary>Gets or sets the cancellation token source for the fixed-tree loop.</summary>
        public CancellationTokenSource? FixedTreeCts { get; set; }
        /// <summary>Gets or sets the fixed-tree cache for this device.</summary>
        public FocasFixedTreeCache? FixedTreeCache { get; set; }
        /// <summary>
        ///     Gets the last fixed-tree snapshots by field name. Double-typed so axis
        ///     positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
        ///     scale applied at <see cref="PublishAxisSnapshot"/>; integer fields
        ///     (feed rate, spindle speed) widen to double on store.
        /// </summary>
        public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
        /// <summary>Gets or sets the last program information snapshot.</summary>
        public FocasProgramInfo? LastProgramInfo { get; set; }
        /// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
        public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
        /// <summary>Gets the last timer values by timer kind.</summary>
        public ConcurrentDictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = new();
        /// <summary>Gets the last servo load percentages by servo name.</summary>
        public ConcurrentDictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
        /// <summary>Gets the last spindle load percentages by spindle index.</summary>
        public ConcurrentDictionary<int, int> LastSpindleLoads { get; } = new();
        /// <summary>
        ///     Gets or sets the per-axis position decimal-place figures fetched once at init via
        ///     <c>cnc_getfigure</c> (parallel to the axis-name list; index = axis). An auto figure
        ///     for an axis WINS over the configured <c>PositionDecimalPlaces</c>; an empty list (or
        ///     a failed/empty figure read) makes every axis fall back to that config knob.
        /// </summary>
        public IReadOnlyList<int> PositionFigures { get; set; } = [];

        /// <summary>Disposes the FOCAS client instance.</summary>
        public void DisposeClient()
        {
            Client?.Dispose();
            Client = null;
        }
    }
}
