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

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

/// <summary>
///     AB Legacy / PCCC driver — SLC 500, MicroLogix, PLC-5, LogixPccc. Implements
///     <see cref="IDriver"/> only at PR 1 time; read / write / discovery / subscribe / probe /
///     host-resolver capabilities ship in PRs 2 and 3.
/// </summary>
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
    IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
    /// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
    /// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
    private AbLegacyDriverOptions _options;
    private readonly string _driverInstanceId;
    private readonly IAbLegacyTagFactory _tagFactory;
    private readonly ILogger<AbLegacyDriver> _logger;
    private readonly PollGroupEngine _poll;
    private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);

    // v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
    // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
    private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);

    // Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
    // authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
    private readonly EquipmentTagRefResolver<AbLegacyTagDefinition> _resolver;

    // volatile: _health is read by GetHealth() on any thread while ReadAsync / WriteAsync /
    // InitializeAsync write it from worker / poll threads. The record-reference assignment is
    // atomic on all .NET platforms, but without a memory barrier a reader can see a stale
    // snapshot indefinitely. volatile enforces acquire/release ordering so GetHealth() always
    // observes the most recently written value.
    private volatile DriverHealth _health = new(DriverState.Unknown, null, null);

    /// <summary>
    ///     Occurs when data values change.
    /// </summary>
    public event EventHandler<DataChangeEventArgs>? OnDataChange;

    /// <summary>
    ///     Occurs when host status changes.
    /// </summary>
    public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;

    /// <summary>
    ///     Initializes a new instance of the <see cref="AbLegacyDriver"/> class.
    /// </summary>
    /// <param name="options">The driver options.</param>
    /// <param name="driverInstanceId">The driver instance identifier.</param>
    /// <param name="tagFactory">The tag factory, or <c>null</c> to use the default.</param>
    /// <param name="logger">The logger, or <c>null</c> to use the null logger.</param>
    public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId,
        IAbLegacyTagFactory? tagFactory = null,
        ILogger<AbLegacyDriver>? logger = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        _options = options;
        _driverInstanceId = driverInstanceId;
        _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
        _logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
        _resolver = new EquipmentTagRefResolver<AbLegacyTagDefinition>(
            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>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, "AbLegacy poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
        var current = _health;
        if (current.State != DriverState.Faulted)
            _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
    }

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

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

    /// <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.</summary>
    private static bool HasConfigBody(string? driverConfigJson)
    {
        if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
        var trimmed = driverConfigJson.Trim();
        return trimmed is not "{}" and not "[]";
    }

    /// <inheritdoc />
    public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        _health = new DriverHealth(DriverState.Initializing, null, null);
        try
        {
            // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
            // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
            // with, so an operator's edit is discarded while the deployment still seals green.
            if (HasConfigBody(driverConfigJson))
                _options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);

            foreach (var device in _options.Devices)
            {
                var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
                    ?? throw new InvalidOperationException(
                        $"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
                var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
                _devices[device.HostAddress] = new DeviceState(addr, device, profile);
            }
            // 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 is threaded onto the
            // def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to
            // the owning device's host so the existing DeviceHostAddress-keyed routing continues to work.
            // A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init.
            foreach (var entry in _options.RawTags)
            {
                if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
                {
                    _logger.LogWarning(
                        "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
                        _driverInstanceId, entry.RawPath);
                    continue;
                }
                _tagsByRawPath[entry.RawPath] = def with
                {
                    WriteIdempotent = entry.WriteIdempotent,
                    DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
                };
            }

            // Validate tag types against their device's family profile. Long (32-bit integer)
            // and String (ST-file) are not supported by all PCCC families; reject them early
            // so a misconfigured tag fails at init time with a clear message rather than
            // surfacing an opaque comms error at runtime.
            foreach (var tag in _tagsByRawPath.Values)
            {
                if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue;
                var profile = deviceForTag.Profile;
                if (tag.DataType == AbLegacyDataType.Long && !profile.SupportsLongFile)
                    throw new InvalidOperationException(
                        $"Tag '{tag.Name}' is typed as Long but device '{tag.DeviceHostAddress}' " +
                        $"(family {deviceForTag.Options.PlcFamily}) does not support L-files.");
                if (tag.DataType == AbLegacyDataType.String && !profile.SupportsStringFile)
                    throw new InvalidOperationException(
                        $"Tag '{tag.Name}' is typed as String but device '{tag.DeviceHostAddress}' " +
                        $"(family {deviceForTag.Options.PlcFamily}) does not support ST-files.");
            }

            // Probe loops — one per device when enabled + probe address configured.
            if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
            {
                foreach (var state in _devices.Values)
                {
                    state.ProbeCts = new CancellationTokenSource();
                    var ct = state.ProbeCts.Token;
                    _ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
                }
            }
            _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
        }
        catch (Exception ex)
        {
            _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
            _logger.LogError(ex,
                "AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId);
            // Tear down any probe loops and cached state that were created before the failure so
            // that a caller who catches and abandons (rather than retrying via ReinitializeAsync)
            // doesn't leave orphaned background tasks, CancellationTokenSources, and libplctag
            // handles alive. Mirrors the body of ShutdownAsync without awaiting the poll engine
            // (nothing has been subscribed yet at init time).
            foreach (var state in _devices.Values)
            {
                try { state.ProbeCts?.Cancel(); } catch { }
                state.ProbeCts?.Dispose();
                state.ProbeCts = null;
                state.DisposeRuntimes();
            }
            _devices.Clear();
            _tagsByRawPath.Clear();
            _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
            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);
        foreach (var state in _devices.Values)
        {
            try { state.ProbeCts?.Cancel(); } catch { }
            state.ProbeCts?.Dispose();
            state.ProbeCts = null;
            state.DisposeRuntimes();
        }
        _devices.Clear();
        _tagsByRawPath.Clear();
        _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }

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

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

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

    /// <summary>
    ///     Gets the device count.
    /// </summary>
    internal int DeviceCount => _devices.Count;

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

    // ---- 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];
            if (!_resolver.TryResolve(reference, out var def))
            {
                results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
                continue;
            }
            if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
            {
                results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
                continue;
            }

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

                int status;
                object? value;
                // Serialise the Read → GetStatus → DecodeValue sequence against the shared
                // runtime — the server read path and the poll loop both call ReadAsync against
                // the same cached libplctag Tag handle, which is not concurrency-safe.
                var opLock = device.GetRuntimeLock(def.Name);
                await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
                try
                {
                    await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
                    status = runtime.GetStatus();
                    var parsed = AbLegacyAddress.TryParse(def.Address);
                    if (status != 0)
                        value = null;
                    else if (IsArrayTag(def))
                        value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def));
                    else
                        value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
                }
                finally
                {
                    opLock.Release();
                }

                if (status != 0)
                {
                    // Evict the stale handle so the next call re-creates it (mirrors AbCip).
                    EvictRuntime(device, def.Name);
                    results[i] = new DataValueSnapshot(null,
                        AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
                    _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
                        $"libplctag status {status} reading {reference}");
                    if (!device.FirstNonZeroStatusLogged)
                    {
                        device.FirstNonZeroStatusLogged = true;
                        _logger.LogWarning(
                            "AbLegacy non-zero libplctag status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
                            _driverInstanceId, def.DeviceHostAddress, reference, status);
                    }
                    continue;
                }
                // Healthy read — re-arm the per-device first-failure log so a future non-zero
                // status logs again rather than being suppressed by an old flag from a prior outage.
                device.FirstNonZeroStatusLogged = false;

                results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
                _health = new DriverHealth(DriverState.Healthy, now, null);
            }
            catch (OperationCanceledException) { throw; }
            catch (Exception ex)
            {
                // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
                EvictRuntime(device, def.Name);
                results[i] = new DataValueSnapshot(null,
                    AbLegacyStatusMapper.BadCommunicationError, null, now);
                _health = new DriverHealth(DriverState.Degraded, _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(AbLegacyStatusMapper.BadNodeIdUnknown);
                continue;
            }
            if (!def.Writable)
            {
                results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
                continue;
            }
            if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
            {
                results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
                continue;
            }

            try
            {
                var parsed = AbLegacyAddress.TryParse(def.Address);

                // PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N
                // bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every
                // bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files
                // (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements
                // don't reach this path because they're not Bit typed. NOTE: an Input-image (I) write is sent
                // to the PLC like any other write — the device drives the input image from physical inputs and
                // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver.
                if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit)
                {
                    results[i] = new WriteResult(
                        await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
                    continue;
                }

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

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

                if (status != 0)
                {
                    // Evict the stale handle so the next call re-creates it (mirrors AbCip).
                    EvictRuntime(device, def.Name);
                    results[i] = new WriteResult(AbLegacyStatusMapper.MapLibplctagStatus(status));
                }
                else
                {
                    results[i] = new WriteResult(AbLegacyStatusMapper.Good);
                }
            }
            catch (OperationCanceledException) { throw; }
            catch (NotSupportedException nse)
            {
                results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
            }
            catch (Exception ex) when (ex is FormatException or InvalidCastException)
            {
                results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch);
            }
            catch (OverflowException)
            {
                results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
            }
            catch (Exception ex)
            {
                // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
                EvictRuntime(device, def.Name);
                results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
            }
        }

        return results;
    }

    // ---- ITagDiscovery ----

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

    /// <inheritdoc />
    public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        var root = builder.Folder("AbLegacy", "AbLegacy");
        foreach (var device in _options.Devices)
        {
            var label = device.DeviceName ?? device.HostAddress;
            var deviceFolder = root.Folder(device.HostAddress, label);
            var tagsForDevice = _tagsByRawPath.Values.Where(t =>
                string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
            foreach (var tag in tagsForDevice)
            {
                var isArray = tag.ArrayLength is int len && len >= 1;
                var arrayDim = isArray
                    ? (uint)Math.Min(tag.ArrayLength!.Value, AbLegacyArray.MaxElements)
                    : (uint?)null;
                deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
                    FullName: tag.Name,
                    DriverDataType: tag.DataType.ToDriverDataType(),
                    IsArray: isArray,
                    ArrayDim: arrayDim,
                    SecurityClass: tag.Writable
                        ? SecurityClassification.Operate
                        : SecurityClassification.ViewOnly,
                    IsHistorized: false,
                    IsAlarm: false,
                    WriteIdempotent: tag.WriteIdempotent));
            }
        }
        return Task.CompletedTask;
    }

    // ---- 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)
    {
        var probeParams = new AbLegacyTagCreateParams(
            Gateway: state.ParsedAddress.Gateway,
            Port: state.ParsedAddress.Port,
            CipPath: state.EffectiveCipPath,
            LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
            TagName: _options.Probe.ProbeAddress!,
            Timeout: _options.Probe.Timeout);

        IAbLegacyTagRuntime? probeRuntime = null;
        while (!ct.IsCancellationRequested)
        {
            var success = false;
            try
            {
                probeRuntime ??= _tagFactory.Create(probeParams);
                if (!state.ProbeInitialized)
                {
                    await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
                    state.ProbeInitialized = true;
                }
                await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
                success = probeRuntime.GetStatus() == 0;
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
            catch
            {
                try { probeRuntime?.Dispose(); } catch { }
                probeRuntime = null;
                state.ProbeInitialized = false;
            }

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

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

    private void TransitionDeviceState(DeviceState state, HostState newState)
    {
        HostState old;
        lock (state.ProbeLock)
        {
            old = state.HostState;
            if (old == newState) return;
            state.HostState = newState;
            state.HostStateChangedUtc = DateTime.UtcNow;
        }
        if (newState == HostState.Stopped)
            _logger.LogWarning(
                "AbLegacy probe transition. Driver={DriverInstanceId} Device={DeviceHostAddress} From={Old} To={New}",
                _driverInstanceId, state.Options.HostAddress, old, newState);
        else
            _logger.LogInformation(
                "AbLegacy probe transition. Driver={DriverInstanceId} Device={DeviceHostAddress} From={Old} To={New}",
                _driverInstanceId, state.Options.HostAddress, old, newState);
        OnHostStatusChanged?.Invoke(this,
            new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
    }

    /// <summary>
    ///     Resolve the <see cref="RawTagEntry.DeviceName"/> a raw tag carries to the host-address key of
    ///     the device it should route to. The AB Legacy driver is multi-device and keys its
    ///     <c>_devices</c> table by <c>HostAddress</c>, so this maps the tag's owning device name to that
    ///     key: it matches the name against each configured <see cref="AbLegacyDeviceOptions"/> by its
    ///     <see cref="AbLegacyDeviceOptions.DeviceName"/> first, then by <c>HostAddress</c> as a fallback,
    ///     and returns the matched device's <c>HostAddress</c>. When the name matches nothing but the
    ///     driver has exactly one device, that sole device is used (single-device deployments never need
    ///     to name their tag's device). Otherwise returns empty — the tag is unroutable and its
    ///     read/write surfaces <c>BadNodeIdUnknown</c>.
    /// </summary>
    /// <remarks>
    ///     TODO(v3 WaveC): the live device host address must ultimately come from the owning
    ///     <c>Device</c> row's <c>DeviceConfig</c> (resolved by the deploy artifact / bootstrapper) rather
    ///     than from a configured <see cref="AbLegacyDriverOptions.Devices"/> entry. Until Wave C threads
    ///     the Device row through, the best available option is to route by matching the delivered
    ///     <see cref="RawTagEntry.DeviceName"/> against <c>_options.Devices</c> and reusing that entry's
    ///     <c>HostAddress</c> as the connection key.
    /// </remarks>
    /// <param name="deviceName">The owning device name delivered on the <see cref="RawTagEntry"/>.</param>
    /// <returns>The matched device's <c>HostAddress</c>; empty when unroutable.</returns>
    private string ResolveDeviceHost(string deviceName)
    {
        if (!string.IsNullOrEmpty(deviceName))
        {
            foreach (var d in _options.Devices)
                if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase))
                    return d.HostAddress;
            foreach (var d in _options.Devices)
                if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
                    return d.HostAddress;
        }
        // Best-available fallback: a single-device driver routes every tag to its sole device.
        return _options.Devices.Count == 1 ? _options.Devices[0].HostAddress : "";
    }

    // ---- 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
    ///     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>
    ///     Read-modify-write one bit within a PCCC word. Strips the /N bit suffix to form the
    ///     parent-word address (N7:0/3 → N7:0), creates / reuses a parent-word runtime typed at
    ///     the parent's native width (Int16 for N/I/O/S/A files, Int32 for the 32-bit L file),
    ///     and serialises concurrent bit writers against the same parent via a per-parent
    ///     <see cref="SemaphoreSlim"/>. The parent word is masked to its native width before the
    ///     bit operation so a sign-extended decode never corrupts the high bits.
    /// </summary>
    private async Task<uint> WriteBitInWordAsync(
        AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
    {
        // The parent word width follows the file letter: an L-file element is 32-bit, every
        // other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already
        // range-checked by AbLegacyAddress.TryParse (0..15 for 16-bit files, 0..31 for L), so
        // 1 << bit can never overflow the chosen width here.
        var isLong = bitAddress.FileLetter == "L";
        var parentType = isLong ? AbLegacyDataType.Long : AbLegacyDataType.Int;
        var widthMask = isLong ? unchecked((int)0xFFFFFFFF) : 0xFFFF;

        var parentAddress = bitAddress with { BitIndex = null };
        var parentName = parentAddress.ToLibplctagName();

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

            // Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
            // sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
            // RMW arithmetic to exactly the parent's bits.
            var current = Convert.ToInt32(parentRuntime.DecodeValue(parentType, bitIndex: null) ?? 0) & widthMask;
            var updated = Convert.ToBoolean(value)
                ? current | (1 << bit)
                : current & ~(1 << bit);
            updated &= widthMask;

            if (isLong)
                parentRuntime.EncodeValue(AbLegacyDataType.Long, bitIndex: null, updated);
            else
                parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
            await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
            var writeStatus = parentRuntime.GetStatus();
            return writeStatus == 0
                ? AbLegacyStatusMapper.Good
                : AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
        }
        finally
        {
            rmwLock.Release();
        }
    }

    private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
        AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
    {
        // Fast path: runtime already cached.
        if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;

        // Slow path: serialise creation per key so concurrent callers don't each create a
        // runtime and one of them gets overwritten + leaked. Only one caller initialises; the
        // others find the entry on the second TryGetValue inside the lock.
        var creationLock = device.GetCreationLock($"parent:{parentName}");
        await creationLock.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing;

            var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
                Gateway: device.ParsedAddress.Gateway,
                Port: device.ParsedAddress.Port,
                CipPath: device.EffectiveCipPath,
                LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
                TagName: parentName,
                Timeout: _options.Timeout));
            try
            {
                await runtime.InitializeAsync(ct).ConfigureAwait(false);
            }
            catch
            {
                runtime.Dispose();
                throw;
            }
            device.ParentRuntimes[parentName] = runtime;
            return runtime;
        }
        finally
        {
            creationLock.Release();
        }
    }

    /// <summary>
    ///     Whether a tag definition is an ARRAY. The canonical contract: a tag is
    ///     an array ⟺ its <see cref="AbLegacyTagDefinition.ArrayLength"/> is non-null (the parser
    ///     sets it ≥1 only when isArray:true), so a 1-element array (ArrayLength:1) IS an array
    ///     (review I-3). <c>null</c> ArrayLength ⇒ scalar.
    /// </summary>
    private static bool IsArrayTag(AbLegacyTagDefinition def) => def.ArrayLength is int len && len >= 1;

    /// <summary>
    ///     The effective libplctag element count for a tag definition: the tag's
    ///     <see cref="AbLegacyTagDefinition.ArrayLength"/> clamped to the PCCC file maximum
    ///     (<see cref="AbLegacyArray.MaxElements"/> = 256) when it is an array (≥1, INCLUDING 1),
    ///     or <c>1</c> when the tag is scalar (null ArrayLength). Used both to size the runtime at
    ///     create time and as the element count the read path decodes into an array.
    /// </summary>
    private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
        def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;

    private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
        DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
    {
        // Fast path: runtime already cached.
        if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;

        // Slow path: serialise creation per tag name so concurrent callers for the same tag
        // (server read path + poll loop) don't both create a runtime and one gets leaked.
        var creationLock = device.GetCreationLock($"tag:{def.Name}");
        await creationLock.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing;

            var parsed = AbLegacyAddress.TryParse(def.Address)
                ?? throw new InvalidOperationException(
                    $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");

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

    /// <summary>
    ///     Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose it so
    ///     the next read/write call re-creates and re-initializes a fresh libplctag handle. Called
    ///     after a non-zero libplctag status or a transport exception on a read/write — a non-zero
    ///     status can mean the controller dropped the connection or the handle became permanently
    ///     invalid (e.g. after a PLC download), and reusing it would return the same failure forever.
    ///     Mirrors <c>AbCipDriver.EvictRuntime</c> and the probe loop's recreate-on-failure behaviour
    ///     (previously AbLegacy leaned only on the probe loop + libplctag's internal recovery, so a
    ///     data-path fault recovered more slowly than in AbCip).
    /// </summary>
    /// <param name="device">The device whose runtime cache holds the stale handle.</param>
    /// <param name="tagName">The tag name key of the runtime to evict.</param>
    private static void EvictRuntime(DeviceState device, string tagName)
    {
        if (device.Runtimes.TryRemove(tagName, out var stale))
        {
            try { stale.Dispose(); } catch { /* best-effort */ }
        }
    }

    /// <summary>
    ///     Synchronous teardown. Mirrors the body of
    ///     <see cref="ShutdownAsync"/> but never wraps the async path in
    ///     <c>.AsTask().GetAwaiter().GetResult()</c>. The poll engine's <c>DisposeAsync</c> is
    ///     drained with a <c>ConfigureAwait(false)</c> awaiter so a captured single-threaded
    ///     <see cref="SynchronizationContext"/> can never be the resumption target — the
    ///     classic sync-over-async deadlock cannot occur. Any other awaitable cleanup is
    ///     translated to direct synchronous calls (cancel probe CTSs, dispose runtimes).
    /// </summary>
    public void Dispose()
    {
        // ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine
        // shutdown on the current thread without capturing the SynchronizationContext. The
        // engine cancels every loop CTS up-front then either completes immediately (no
        // subscriptions, common case for the test fixture) or awaits a bounded WhenAll on the
        // already-shutting-down loop tasks. We swallow exceptions so a buggy poll-loop teardown
        // can't poison the rest of the disposal chain.
        try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { }
        foreach (var state in _devices.Values)
        {
            try { state.ProbeCts?.Cancel(); } catch { }
            state.ProbeCts?.Dispose();
            state.ProbeCts = null;
            state.DisposeRuntimes();
        }
        _devices.Clear();
        _tagsByRawPath.Clear();
        _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }

    /// <summary>
    ///     Disposes the driver asynchronously.
    /// </summary>
    /// <returns>A task representing the asynchronous disposal.</returns>
    public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);

    internal sealed class DeviceState(
        AbLegacyHostAddress parsedAddress,
        AbLegacyDeviceOptions options,
        AbLegacyPlcFamilyProfile profile)
    {
        /// <summary>
        ///     Gets the parsed host address.
        /// </summary>
        public AbLegacyHostAddress ParsedAddress { get; } = parsedAddress;

        /// <summary>
        ///     Gets the device options.
        /// </summary>
        public AbLegacyDeviceOptions Options { get; } = options;

        /// <summary>
        ///     Gets the PLC family profile.
        /// </summary>
        public AbLegacyPlcFamilyProfile Profile { get; } = profile;

        /// <summary>
        ///     The CIP path to pass to libplctag. When the parsed host address has an empty CIP
        ///     path (e.g. <c>ab://10.0.0.5/</c>), the profile-supplied default is used instead so
        ///     that a SLC 500 misconfigured without an explicit path still gets the required
        ///     <c>1,0</c> backplane route. MicroLogix has an empty default by design (direct EIP).
        /// </summary>
        public string EffectiveCipPath => ParsedAddress.CipPath.Length > 0
            ? ParsedAddress.CipPath
            : Profile.DefaultCipPath;

        /// <summary>
        ///     Per-tag cached runtimes. <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
        ///     avoids the check-then-act race present on a plain <c>Dictionary</c>: two concurrent
        ///     <c>EnsureTagRuntimeAsync</c> callers for the same key both miss the lookup on a
        ///     plain dict and both create + store a runtime, leaking the loser. Access is guarded
        ///     by a per-key creation semaphore (<see cref="GetCreationLock"/>) so exactly one
        ///     runtime is created per tag name.
        /// </summary>
        public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> Runtimes { get; } =
            new(StringComparer.OrdinalIgnoreCase);

        /// <summary>
        ///     Parent-word runtimes for bit-within-word RMW writes. Keyed by the
        ///     parent address (bit suffix stripped) — e.g. writes to N7:0/3 + N7:0/5 share a
        ///     single parent runtime for N7:0.
        /// </summary>
        public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
            new(StringComparer.OrdinalIgnoreCase);

        /// <summary>
        ///     Per-key creation locks for <see cref="Runtimes"/> and <see cref="ParentRuntimes"/>.
        ///     A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
        ///     sequence so that a concurrent caller waits rather than creating a duplicate runtime
        ///     that would be leaked on <see cref="DisposeRuntimes"/>.
        /// </summary>
        private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _creationLocks = new(StringComparer.OrdinalIgnoreCase);

        /// <summary>
        ///     Gets or creates the creation lock for the specified key.
        /// </summary>
        /// <param name="key">The lock key.</param>
        /// <returns>The semaphore slim for the key.</returns>
        public SemaphoreSlim GetCreationLock(string key) =>
            _creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));

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

        /// <summary>
        ///     Gets or creates the read-modify-write lock for the specified parent name.
        /// </summary>
        /// <param name="parentName">The parent name.</param>
        /// <returns>The semaphore slim for the parent.</returns>
        public SemaphoreSlim GetRmwLock(string parentName) =>
            _rmwLocks.GetOrAdd(parentName, _ => new SemaphoreSlim(1, 1));

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

        /// <summary>
        ///     Per-runtime operation lock. A libplctag <c>Tag</c> handle is not safe for
        ///     concurrent Read/GetStatus/DecodeValue from multiple threads — the server read
        ///     path and the poll loop both call <see cref="AbLegacyDriver.ReadAsync"/> against
        ///     the same cached runtime. Callers hold this lock around the whole
        ///     Read → GetStatus → Decode (or Encode → Write → GetStatus) sequence so a status
        ///     or value is never observed mid-update by another thread. Keyed by tag name, which
        ///     is also the <see cref="Runtimes"/> dictionary key.
        /// </summary>
        /// <param name="tagName">The tag name.</param>
        /// <returns>The semaphore slim for the tag.</returns>
        public SemaphoreSlim GetRuntimeLock(string tagName) =>
            _runtimeLocks.GetOrAdd(tagName, _ => new SemaphoreSlim(1, 1));

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

        /// <summary>
        ///     Gets or sets the host state.
        /// </summary>
        public HostState HostState { get; set; } = HostState.Unknown;

        /// <summary>
        ///     Gets or sets the UTC time when the 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 a value indicating whether the probe has been initialized.
        /// </summary>
        public bool ProbeInitialized { get; set; }

        /// <summary>
        ///     Per-device latch for the structured "first non-zero
        ///     libplctag status" log. Reset to <see langword="false"/> on a successful read so a
        ///     future outage re-fires the warning rather than being suppressed by a stale flag.
        ///     Concurrent readers on the same device may race the unlatched check + set, but the
        ///     worst case is a small finite number of duplicate warnings at outage onset (one per
        ///     racing reader) — which is preferable to either silently losing the first warning
        ///     or paying lock contention on the hot read path.
        /// </summary>
        public bool FirstNonZeroStatusLogged { get; set; }

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