using System.Buffers.Binary;
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
///     <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
///     driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
///     <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
/// </summary>
/// <remarks>
///     Scope limits: Historian + alarm capabilities are out of scope (the protocol doesn't
///     express them). Subscriptions overlay a polling loop via the shared
///     <see cref="PollGroupEngine"/> since Modbus has no native push model.
/// </remarks>
public sealed class ModbusDriver
    : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
    // Polled subscriptions delegate to the shared PollGroupEngine. The driver only supplies
    // the reader + on-change bridge; the engine owns the loop, interval floor, and lifecycle.
    private readonly PollGroupEngine _poll;
    private readonly string _driverInstanceId;

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

    // Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
    // collection has at most one entry. HostName is the Host:Port string so the Admin UI can
    // display the PLC endpoint uniformly with Galaxy platforms/engines.
    private readonly object _probeLock = new();
    private HostState _hostState = HostState.Unknown;
    private DateTime _hostStateChangedUtc = DateTime.UtcNow;
    private CancellationTokenSource? _probeCts;
    private readonly ModbusDriverOptions _options;
    private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;

    private IModbusTransport? _transport;
    private DriverHealth _health = new(DriverState.Unknown, null, null);
    private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);

    public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
        Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
    {
        ArgumentNullException.ThrowIfNull(options);
        _options = options;
        _driverInstanceId = driverInstanceId;
        _transportFactory = transportFactory
            ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout, o.AutoReconnect));
        _poll = new PollGroupEngine(
            reader: ReadAsync,
            onChange: (handle, tagRef, snapshot) =>
                OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
    }

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

    public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
    {
        _health = new DriverHealth(DriverState.Initializing, null, null);
        try
        {
            _transport = _transportFactory(_options);
            await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
            foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
            _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);

            // PR 23: kick off the probe loop once the transport is up. Initial state stays
            // Unknown until the first probe tick succeeds — avoids broadcasting a premature
            // Running transition before any register round-trip has happened.
            if (_options.Probe.Enabled)
            {
                _probeCts = new CancellationTokenSource();
                _ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
            }
        }
        catch (Exception ex)
        {
            _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
            throw;
        }
    }

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

    public async Task ShutdownAsync(CancellationToken cancellationToken)
    {
        try { _probeCts?.Cancel(); } catch { }
        _probeCts?.Dispose();
        _probeCts = null;

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

        if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
        _transport = null;
        _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
    }

    public DriverHealth GetHealth() => _health;
    public long GetMemoryFootprint() => 0;
    public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    // ---- ITagDiscovery ----

    public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(builder);
        var folder = builder.Folder("Modbus", "Modbus");
        foreach (var t in _options.Tags)
        {
            folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
                FullName: t.Name,
                DriverDataType: MapDataType(t.DataType),
                IsArray: false,
                ArrayDim: null,
                SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
                IsHistorized: false,
                IsAlarm: false,
                WriteIdempotent: t.WriteIdempotent));
        }
        return Task.CompletedTask;
    }

    // ---- IReadable ----

    public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
        IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
    {
        var transport = RequireTransport();
        var now = DateTime.UtcNow;
        var results = new DataValueSnapshot[fullReferences.Count];
        for (var i = 0; i < fullReferences.Count; i++)
        {
            if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
            {
                results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
                continue;
            }
            try
            {
                var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
                results[i] = new DataValueSnapshot(value, 0u, now, now);
                _health = new DriverHealth(DriverState.Healthy, now, null);
            }
            catch (ModbusException mex)
            {
                results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
            }
            catch (Exception ex)
            {
                // Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
                // as communication error so callers can distinguish it from tag-level faults.
                results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
                _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
            }
        }
        return results;
    }

    private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
    {
        switch (tag.Region)
        {
            case ModbusRegion.Coils:
            {
                var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
                var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
                return (resp[2] & 0x01) == 1;
            }
            case ModbusRegion.DiscreteInputs:
            {
                var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
                var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
                return (resp[2] & 0x01) == 1;
            }
            case ModbusRegion.HoldingRegisters:
            case ModbusRegion.InputRegisters:
            {
                var quantity = RegisterCount(tag);
                var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
                // Auto-chunk when the tag's register span exceeds the caller-configured cap.
                // Affects long strings (FC03/04 > 125 regs is spec-forbidden; DL205 caps at 128,
                // Mitsubishi Q caps at 64). Non-string tags max out at 4 regs so the cap never
                // triggers for numerics.
                var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
                var data = quantity <= cap
                    ? await ReadRegisterBlockAsync(transport, fc, tag.Address, quantity, ct).ConfigureAwait(false)
                    : await ReadRegisterBlockChunkedAsync(transport, fc, tag.Address, quantity, cap, ct).ConfigureAwait(false);
                return DecodeRegister(data, tag);
            }
            default:
                throw new InvalidOperationException($"Unknown region {tag.Region}");
        }
    }

    private async Task<byte[]> ReadRegisterBlockAsync(
        IModbusTransport transport, byte fc, ushort address, ushort quantity, CancellationToken ct)
    {
        var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
                               (byte)(quantity >> 8), (byte)(quantity & 0xFF) };
        var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
        // resp = [fc][byte-count][data...]
        var data = new byte[resp[1]];
        Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
        return data;
    }

    private async Task<byte[]> ReadRegisterBlockChunkedAsync(
        IModbusTransport transport, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
    {
        var assembled = new byte[totalRegs * 2];
        ushort done = 0;
        while (done < totalRegs)
        {
            var chunk = (ushort)Math.Min(cap, totalRegs - done);
            var chunkBytes = await ReadRegisterBlockAsync(transport, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
            Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
            done += chunk;
        }
        return assembled;
    }

    // ---- IWritable ----

    public async Task<IReadOnlyList<WriteResult>> WriteAsync(
        IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
    {
        var transport = RequireTransport();
        var results = new WriteResult[writes.Count];
        for (var i = 0; i < writes.Count; i++)
        {
            var w = writes[i];
            if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
            {
                results[i] = new WriteResult(StatusBadNodeIdUnknown);
                continue;
            }
            if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
            {
                results[i] = new WriteResult(StatusBadNotWritable);
                continue;
            }
            try
            {
                await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
                results[i] = new WriteResult(0u);
            }
            catch (ModbusException mex)
            {
                results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
            }
            catch (Exception)
            {
                results[i] = new WriteResult(StatusBadInternalError);
            }
        }
        return results;
    }

    // BitInRegister writes need a read-modify-write against the full holding register. A
    // per-register lock keeps concurrent bit-write callers from stomping on each other —
    // Write bit 0 and Write bit 5 targeting the same register can arrive on separate
    // subscriber threads, and without serialising the RMW the second-to-commit value wins
    // + the first bit update is lost.
    private readonly System.Collections.Concurrent.ConcurrentDictionary<ushort, SemaphoreSlim> _rmwLocks = new();

    private SemaphoreSlim GetRmwLock(ushort address) =>
        _rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));

    private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
    {
        // BitInRegister → RMW dispatch ahead of the normal encode path so the lock + read-modify-
        // write sequence doesn't hit EncodeRegister's defensive throw.
        if (tag.DataType == ModbusDataType.BitInRegister &&
            tag.Region is ModbusRegion.HoldingRegisters)
        {
            await WriteBitInRegisterAsync(transport, tag, value, ct).ConfigureAwait(false);
            return;
        }

        switch (tag.Region)
        {
            case ModbusRegion.Coils:
            {
                var on = Convert.ToBoolean(value);
                var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
                                       on ? (byte)0xFF : (byte)0x00, 0x00 };
                await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
                return;
            }
            case ModbusRegion.HoldingRegisters:
            {
                var bytes = EncodeRegister(value, tag);
                if (bytes.Length == 2)
                {
                    var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
                                           bytes[0], bytes[1] };
                    await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
                }
                else
                {
                    // FC 16 (Write Multiple Registers) for 32-bit types.
                    var qty = (ushort)(bytes.Length / 2);
                    var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
                    if (qty > writeCap)
                        throw new InvalidOperationException(
                            $"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
                            $"Split the tag (e.g. shorter StringLength) — partial FC16 chunks would lose atomicity.");
                    var pdu = new byte[6 + 1 + bytes.Length];
                    pdu[0] = 0x10;
                    pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
                    pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
                    pdu[5] = (byte)bytes.Length;
                    Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
                    await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
                }
                return;
            }
            default:
                throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
        }
    }

    /// <summary>
    ///     Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised
    ///     against other bit writes targeting the same register via <see cref="GetRmwLock"/>.
    /// </summary>
    private async Task WriteBitInRegisterAsync(
        IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
    {
        var bit = tag.BitIndex;
        if (bit > 15)
            throw new InvalidOperationException(
                $"BitInRegister bit index {bit} out of range (0-15) for tag {tag.Name}.");
        var on = Convert.ToBoolean(value);

        var rmwLock = GetRmwLock(tag.Address);
        await rmwLock.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            // FC03 read 1 holding register at tag.Address.
            var readPdu = new byte[] { 0x03, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
            var readResp = await transport.SendAsync(_options.UnitId, readPdu, ct).ConfigureAwait(false);
            // resp = [fc][byte-count=2][hi][lo]
            var current = (ushort)((readResp[2] << 8) | readResp[3]);

            var updated = on
                ? (ushort)(current | (1 << bit))
                : (ushort)(current & ~(1 << bit));

            // FC06 write single holding register.
            var writePdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
                                        (byte)(updated >> 8), (byte)(updated & 0xFF) };
            await transport.SendAsync(_options.UnitId, writePdu, ct).ConfigureAwait(false);
        }
        finally
        {
            rmwLock.Release();
        }
    }

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

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

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

    // ---- IHostConnectivityProbe ----

    public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
    {
        lock (_probeLock)
            return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
    }

    /// <summary>
    ///     Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
    ///     Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
    ///     by endpoint without needing the driver-instance-id in the Admin dashboard.
    /// </summary>
    public string HostName => $"{_options.Host}:{_options.Port}";

    private async Task ProbeLoopAsync(CancellationToken ct)
    {
        var transport = _transport; // captured reference; disposal tears the loop down via ct
        while (!ct.IsCancellationRequested)
        {
            var success = false;
            try
            {
                using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                probeCts.CancelAfter(_options.Probe.Timeout);
                var pdu = new byte[] { 0x03,
                    (byte)(_options.Probe.ProbeAddress >> 8),
                    (byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
                _ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
                success = true;
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                return;
            }
            catch
            {
                // transport / timeout / exception PDU — treated as Stopped below
            }

            TransitionTo(success ? HostState.Running : HostState.Stopped);

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

    private void TransitionTo(HostState newState)
    {
        HostState old;
        lock (_probeLock)
        {
            old = _hostState;
            if (old == newState) return;
            _hostState = newState;
            _hostStateChangedUtc = DateTime.UtcNow;
        }
        OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
    }

    // ---- codec ----

    /// <summary>
    ///     How many 16-bit registers a given tag occupies. Accounts for multi-register logical
    ///     types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
    ///     from 2 chars per register).
    /// </summary>
    internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
    {
        ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister or ModbusDataType.Bcd16 => 1,
        ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 2,
        ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
        ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
        _ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
    };

    /// <summary>
    ///     Word-swap the input into the big-endian layout the decoders expect. For 2-register
    ///     types this reverses the two words; for 4-register types it reverses the four words
    ///     (PLC stored [hi-mid, low-mid, hi-high, low-high] → memory [hi-high, low-high, hi-mid, low-mid]).
    /// </summary>
    private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
    {
        if (order == ModbusByteOrder.BigEndian) return data.ToArray();
        var result = new byte[data.Length];
        for (var word = 0; word < data.Length / 2; word++)
        {
            var srcWord = data.Length / 2 - 1 - word;
            result[word * 2] = data[srcWord * 2];
            result[word * 2 + 1] = data[srcWord * 2 + 1];
        }
        return result;
    }

    internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
    {
        switch (tag.DataType)
        {
            case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
            case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
            case ModbusDataType.Bcd16:
            {
                var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
                return (int)DecodeBcd(raw, nibbles: 4);
            }
            case ModbusDataType.Bcd32:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                var raw = BinaryPrimitives.ReadUInt32BigEndian(b);
                return (int)DecodeBcd(raw, nibbles: 8);
            }
            case ModbusDataType.BitInRegister:
            {
                var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
                return (raw & (1 << tag.BitIndex)) != 0;
            }
            case ModbusDataType.Int32:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadInt32BigEndian(b);
            }
            case ModbusDataType.UInt32:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadUInt32BigEndian(b);
            }
            case ModbusDataType.Float32:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadSingleBigEndian(b);
            }
            case ModbusDataType.Int64:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadInt64BigEndian(b);
            }
            case ModbusDataType.UInt64:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadUInt64BigEndian(b);
            }
            case ModbusDataType.Float64:
            {
                var b = NormalizeWordOrder(data, tag.ByteOrder);
                return BinaryPrimitives.ReadDoubleBigEndian(b);
            }
            case ModbusDataType.String:
            {
                // ASCII, 2 chars per register. HighByteFirst (standard) packs the first char in
                // the high byte of each register; LowByteFirst (DL205/DL260) packs the first char
                // in the low byte. Respect StringLength (truncate nul-padded regions).
                var chars = new char[tag.StringLength];
                for (var i = 0; i < tag.StringLength; i++)
                {
                    var regIdx = i / 2;
                    var highByte = data[regIdx * 2];
                    var lowByte  = data[regIdx * 2 + 1];
                    byte b;
                    if (tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst)
                        b = (i % 2 == 0) ? highByte : lowByte;
                    else
                        b = (i % 2 == 0) ? lowByte : highByte;
                    if (b == 0) return new string(chars, 0, i);
                    chars[i] = (char)b;
                }
                return new string(chars);
            }
            default:
                throw new InvalidOperationException($"Non-register data type {tag.DataType}");
        }
    }

    internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
    {
        switch (tag.DataType)
        {
            case ModbusDataType.Int16:
            {
                var v = Convert.ToInt16(value);
                var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
            }
            case ModbusDataType.UInt16:
            {
                var v = Convert.ToUInt16(value);
                var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
            }
            case ModbusDataType.Bcd16:
            {
                var v = Convert.ToUInt32(value);
                if (v > 9999) throw new OverflowException($"BCD16 value {v} exceeds 4 decimal digits");
                var raw = (ushort)EncodeBcd(v, nibbles: 4);
                var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, raw); return b;
            }
            case ModbusDataType.Bcd32:
            {
                var v = Convert.ToUInt32(value);
                if (v > 99_999_999u) throw new OverflowException($"BCD32 value {v} exceeds 8 decimal digits");
                var raw = EncodeBcd(v, nibbles: 8);
                var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, raw);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.Int32:
            {
                var v = Convert.ToInt32(value);
                var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.UInt32:
            {
                var v = Convert.ToUInt32(value);
                var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.Float32:
            {
                var v = Convert.ToSingle(value);
                var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.Int64:
            {
                var v = Convert.ToInt64(value);
                var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.UInt64:
            {
                var v = Convert.ToUInt64(value);
                var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.Float64:
            {
                var v = Convert.ToDouble(value);
                var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
                return NormalizeWordOrder(b, tag.ByteOrder);
            }
            case ModbusDataType.String:
            {
                var s = Convert.ToString(value) ?? string.Empty;
                var regs = (tag.StringLength + 1) / 2;
                var b = new byte[regs * 2];
                for (var i = 0; i < tag.StringLength && i < s.Length; i++)
                {
                    var regIdx = i / 2;
                    var destIdx = tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst
                        ? (i % 2 == 0 ? regIdx * 2 : regIdx * 2 + 1)
                        : (i % 2 == 0 ? regIdx * 2 + 1 : regIdx * 2);
                    b[destIdx] = (byte)s[i];
                }
                // remaining bytes stay 0 — nul-padded per PLC convention
                return b;
            }
            case ModbusDataType.BitInRegister:
                // Reached only if BitInRegister is somehow passed outside the HoldingRegisters
                // path. Normal BitInRegister writes dispatch through WriteBitInRegisterAsync via
                // the RMW shortcut in WriteOneAsync.
                throw new InvalidOperationException(
                    "BitInRegister writes must go through WriteBitInRegisterAsync (HoldingRegisters region only).");
            default:
                throw new InvalidOperationException($"Non-register data type {tag.DataType}");
        }
    }

    private static DriverDataType MapDataType(ModbusDataType t) => t switch
    {
        ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
        ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
        ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
        ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
        ModbusDataType.Float32 => DriverDataType.Float32,
        ModbusDataType.Float64 => DriverDataType.Float64,
        ModbusDataType.String => DriverDataType.String,
        ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32,
        _ => DriverDataType.Int32,
    };

    /// <summary>
    ///     Decode an N-nibble binary-coded-decimal value. Each nibble of <paramref name="raw"/>
    ///     encodes one decimal digit (most-significant nibble first). Rejects nibbles &gt; 9 —
    ///     the hardware sometimes produces garbage during transitions and silent non-BCD reads
    ///     would quietly corrupt the caller's data.
    /// </summary>
    internal static uint DecodeBcd(uint raw, int nibbles)
    {
        uint result = 0;
        for (var i = nibbles - 1; i >= 0; i--)
        {
            var digit = (raw >> (i * 4)) & 0xF;
            if (digit > 9)
                throw new InvalidDataException(
                    $"Non-BCD nibble 0x{digit:X} at position {i} of raw=0x{raw:X}");
            result = result * 10 + digit;
        }
        return result;
    }

    /// <summary>
    ///     Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
    ///     against the nibble capacity (10^nibbles - 1).
    /// </summary>
    internal static uint EncodeBcd(uint value, int nibbles)
    {
        uint result = 0;
        for (var i = 0; i < nibbles; i++)
        {
            var digit = value % 10;
            result |= digit << (i * 4);
            value /= 10;
        }
        return result;
    }

    private IModbusTransport RequireTransport() =>
        _transport ?? throw new InvalidOperationException("ModbusDriver not initialized");

    private const uint StatusBadInternalError = 0x80020000u;
    private const uint StatusBadNodeIdUnknown = 0x80340000u;
    private const uint StatusBadNotWritable = 0x803B0000u;
    private const uint StatusBadOutOfRange = 0x803C0000u;
    private const uint StatusBadNotSupported = 0x803D0000u;
    private const uint StatusBadDeviceFailure = 0x80550000u;
    private const uint StatusBadCommunicationError = 0x80050000u;

    /// <summary>
    ///     Map a server-returned Modbus exception code to the most informative OPC UA
    ///     StatusCode. Keeps the driver's outward-facing status surface aligned with what a
    ///     Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
    ///     Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
    ///     generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
    ///     operators see a CPU-mode problem rather than a driver bug, etc. Per
    ///     <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
    ///     extensions.
    /// </summary>
    internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
    {
        0x01 => StatusBadNotSupported,     // Illegal Function — FC not in supported list
        0x02 => StatusBadOutOfRange,       // Illegal Data Address — register outside mapped range
        0x03 => StatusBadOutOfRange,       // Illegal Data Value — quantity over per-FC cap
        0x04 => StatusBadDeviceFailure,    // Server Failure — CPU in PROGRAM mode during protected write
        0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
        0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
        _ => StatusBadInternalError,
    };

    public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
    public async ValueTask DisposeAsync()
    {
        if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
        _transport = null;
    }
}
