using System.Collections.Frozen;
using Mbproxy.Bcd;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Multiplexing;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;

namespace Mbproxy.Tests.Proxy;

/// <summary>
/// Unit tests for <see cref="BcdPduPipeline"/> using synthetic PDU byte arrays.
/// No network, no simulator. Each test builds a hand-rolled <see cref="BcdTagMap"/>,
/// calls <see cref="BcdPduPipeline.Process"/>, and asserts resulting bytes + counter deltas.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BcdPduPipelineTests
{
    private static readonly BcdPduPipeline Pipeline = new();

    // ── Factories ────────────────────────────────────────────────────────────

    /// <summary>
    /// Builds a <see cref="PerPlcContext"/> from a set of BcdTag entries.
    /// The context has a fresh <see cref="ProxyCounters"/> instance.
    /// </summary>
    private static PerPlcContext MakeContext(params BcdTag[] tags)
    {
        var frozen = tags
            .ToDictionary(t => t.Address)
            .ToFrozenDictionary();
        var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;

        return new PerPlcContext
        {
            PlcName  = "TestPLC",
            TagMap   = map,
            Counters = new ProxyCounters(),
            Logger   = NullLogger.Instance,
        };
    }

    /// <summary>
    /// The rewriter consumes <see cref="PerPlcContext.CurrentRequest"/>. Tests build a
    /// synthetic <see cref="InFlightRequest"/> to drive response decoding.
    /// </summary>
    private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty)
        => new(
            UnitId:            1,
            Fc:                fc,
            StartAddress:      startAddress,
            Qty:               qty,
            // We don't have a real UpstreamPipe in pipeline unit tests; the rewriter
            // never dereferences the party list, so an empty placeholder is safe.
            InterestedParties: Array.Empty<InterestedParty>(),
            SentAtUtc:         DateTimeOffset.UtcNow);

    /// <summary>FC03 response PDU: [fc=03][byteCount][reg0Hi][reg0Lo]...</summary>
    private static byte[] Fc03Response(params ushort[] registers)
    {
        var pdu = new byte[2 + registers.Length * 2];
        pdu[0] = 0x03;
        pdu[1] = (byte)(registers.Length * 2);
        for (int i = 0; i < registers.Length; i++)
        {
            pdu[2 + i * 2]     = (byte)(registers[i] >> 8);
            pdu[2 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
        }
        return pdu;
    }

    /// <summary>FC04 response PDU: same shape as FC03 but fc=04.</summary>
    private static byte[] Fc04Response(params ushort[] registers)
    {
        var pdu = Fc03Response(registers);
        pdu[0] = 0x04;
        return pdu;
    }

    /// <summary>FC03 request PDU: [fc=03][addrHi][addrLo][qtyHi][qtyLo]</summary>
    private static byte[] Fc03Request(ushort address, ushort qty)
        => [0x03, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(qty >> 8), (byte)(qty & 0xFF)];

    /// <summary>FC06 request PDU: [fc=06][addrHi][addrLo][valHi][valLo]</summary>
    private static byte[] Fc06Request(ushort address, ushort value)
        => [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)];

    /// <summary>FC16 request PDU: [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount][reg data...]</summary>
    private static byte[] Fc16Request(ushort start, params ushort[] registers)
    {
        ushort qty = (ushort)registers.Length;
        var pdu = new byte[6 + registers.Length * 2];
        pdu[0] = 0x10;
        pdu[1] = (byte)(start >> 8);
        pdu[2] = (byte)(start & 0xFF);
        pdu[3] = (byte)(qty >> 8);
        pdu[4] = (byte)(qty & 0xFF);
        pdu[5] = (byte)(registers.Length * 2);
        for (int i = 0; i < registers.Length; i++)
        {
            pdu[6 + i * 2]     = (byte)(registers[i] >> 8);
            pdu[6 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
        }
        return pdu;
    }

    /// <summary>
    /// Simulate sending an FC03/04 request then reading the response. Builds an
    /// <see cref="InFlightRequest"/> matching the request and attaches it to the
    /// response-call context.
    /// </summary>
    private void SendRequestThenProcessResponse(
        PerPlcContext ctx,
        byte[] requestPdu,
        byte[] responsePdu)
    {
        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, requestPdu.AsSpan(), ctx);

        // Extract the request start/qty so we can build the InFlightRequest the multiplexer
        // would attach to the response call.
        byte fc = requestPdu[0];
        ushort start = 0, qty = 0;
        if (fc is 0x03 or 0x04 && requestPdu.Length >= 5)
        {
            start = (ushort)((requestPdu[1] << 8) | requestPdu[2]);
            qty   = (ushort)((requestPdu[3] << 8) | requestPdu[4]);
        }

        var responseCtx = ctx.WithCurrentRequest(MakeInFlight(fc, start, qty));
        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, responsePdu.AsSpan(), responseCtx);
    }

    // ── Helper to read a register pair from a response PDU ──────────────────

    private static ushort ReadReg(byte[] pdu, int offsetWords)
        => (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);

    // ── FC03 response tests ──────────────────────────────────────────────────

    [Fact]
    public void FC03_Single16BitBcd_AtReadAddress_DecodesNibbles()
    {
        // Raw wire value 0x1234 → decoded binary 1234
        var ctx = MakeContext(BcdTag.Create(100, 16));
        var req = Fc03Request(100, 1);
        var rsp = Fc03Response(0x1234);

        SendRequestThenProcessResponse(ctx, req, rsp);

        ReadReg(rsp, 0).ShouldBe((ushort)1234);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
    }

    [Fact]
    public void FC03_Full32BitBcdPair_WithinReadRange_DecodesNibbles()
    {
        // 32-bit BCD pair at 100/101: low=0x5678 (5678), high=0x1234 (1234)
        // Decoded = 1234 * 10000 + 5678 = 12345678
        // Binary: low 4 digits = 5678, high 4 digits = 1234
        var ctx = MakeContext(BcdTag.Create(100, 32));
        var req = Fc03Request(100, 2);
        var rsp = Fc03Response(0x5678, 0x1234); // [0]=low, [1]=high

        SendRequestThenProcessResponse(ctx, req, rsp);

        ReadReg(rsp, 0).ShouldBe((ushort)5678);  // decoded low 4 digits
        ReadReg(rsp, 1).ShouldBe((ushort)1234);  // decoded high 4 digits
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
    }

    [Fact]
    public void FC03_Partial32Bit_LowOnly_qty1_AtLowAddr_PassesThroughRaw()
    {
        // Read qty=1 at the low address of a 32-bit pair — only half the pair is in range.
        var ctx = MakeContext(BcdTag.Create(100, 32));
        var req = Fc03Request(100, 1);
        var rsp = Fc03Response(0x5678);

        SendRequestThenProcessResponse(ctx, req, rsp);

        ReadReg(rsp, 0).ShouldBe((ushort)0x5678); // unchanged
        ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void FC03_Partial32Bit_HighOnly_qty1_AtHighAddr_PassesThroughRaw()
    {
        // Read qty=1 starting at the HIGH register of a 32-bit pair (address 101 when tag is at 100).
        // TryGetForRange returns OffsetWords = -1 for the hit (low register is before the range).
        var ctx = MakeContext(BcdTag.Create(100, 32));
        var req = Fc03Request(101, 1); // only reading high register
        var rsp = Fc03Response(0x1234);

        SendRequestThenProcessResponse(ctx, req, rsp);

        ReadReg(rsp, 0).ShouldBe((ushort)0x1234); // unchanged (partial overlap)
        ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void FC03_Mixed_16BitBcd_And_NonBcd_InSameRead_OnlyBcdSlotRewritten()
    {
        // Registers: [0]=non-BCD at addr 99, [1]=BCD 16-bit at addr 100, [2]=non-BCD at addr 101
        var ctx = MakeContext(BcdTag.Create(100, 16));
        var req = Fc03Request(99, 3);
        var rsp = Fc03Response(0xABCD, 0x1234, 0xDEAD);

        SendRequestThenProcessResponse(ctx, req, rsp);

        ReadReg(rsp, 0).ShouldBe((ushort)0xABCD); // non-BCD, unchanged
        ReadReg(rsp, 1).ShouldBe((ushort)1234);    // BCD decoded
        ReadReg(rsp, 2).ShouldBe((ushort)0xDEAD); // non-BCD, unchanged
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
    }

    [Fact]
    public void FC03_BadNibble_At16BitBcdSlot_PassesThroughRaw_AndIncrementsInvalidBcd()
    {
        // 0x12A4 has nibble 'A' which is not a valid BCD digit.
        var ctx = MakeContext(BcdTag.Create(100, 16));
        var req = Fc03Request(100, 1);
        var rsp = Fc03Response(0x12A4);

        SendRequestThenProcessResponse(ctx, req, rsp);

        ReadReg(rsp, 0).ShouldBe((ushort)0x12A4); // unchanged
        ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    // ── FC04 response tests ──────────────────────────────────────────────────

    [Fact]
    public void FC04_Single16BitBcd_AtReadAddress_DecodesNibbles()
    {
        var ctx = MakeContext(BcdTag.Create(200, 16));
        // FC04 request: same shape as FC03 but fc=04
        var req = new byte[] { 0x04, 0x00, 0xC8, 0x00, 0x01 }; // addr=200, qty=1
        var rsp = Fc04Response(0x9876);

        SendRequestThenProcessResponse(ctx, req, rsp);

        ReadReg(rsp, 0).ShouldBe((ushort)9876);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
    }

    // ── FC06 request tests ───────────────────────────────────────────────────

    [Fact]
    public void FC06_Write16BitBcd_EncodesClientBinaryToNibbles()
    {
        // Client writes binary 1234 → PLC should receive BCD 0x1234
        var ctx = MakeContext(BcdTag.Create(300, 16));
        var pdu = Fc06Request(300, 1234); // client sends binary 1234

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
        sentValue.ShouldBe((ushort)0x1234); // BCD nibbles
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
    }

    [Fact]
    public void FC06_WriteToLowAddrOf32BitPair_PassesThroughRaw_WithPartialWarning()
    {
        // FC06 can only write 1 register; if the target is the LOW addr of a 32-bit pair,
        // that's a partial write — pass through raw.
        var ctx = MakeContext(BcdTag.Create(400, 32));
        var pdu = Fc06Request(400, 9999); // 400 is the low address of the 32-bit pair

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
        sentValue.ShouldBe((ushort)9999); // unchanged (raw binary)
        ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void FC06_WriteToHighAddrOf32BitPair_PassesThroughRaw_WithPartialWarning()
    {
        // Writing to address 401 when the 32-bit pair is at 400/401 — high register only.
        var ctx = MakeContext(BcdTag.Create(400, 32));
        var pdu = Fc06Request(401, 0x1234); // 401 is the high address

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
        sentValue.ShouldBe((ushort)0x1234); // unchanged
        ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void FC06_WriteValueOutsideRange_InvalidBcd_PassesThroughRaw()
    {
        // Binary 10000 cannot be represented as 4-digit BCD (max 9999).
        var ctx = MakeContext(BcdTag.Create(300, 16));
        var pdu = Fc06Request(300, 10000); // 10000 > 9999

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
        sentValue.ShouldBe((ushort)10000); // raw passthrough
        ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    // ── FC16 request tests ───────────────────────────────────────────────────

    [Fact]
    public void FC16_WriteSingle16BitBcd_InMultiWrite_EncodesBcdSlotOnly()
    {
        // Registers 500, 501, 502, 503: only 502 is a BCD tag.
        // Non-BCD registers should pass through unchanged.
        var ctx = MakeContext(BcdTag.Create(502, 16));
        var pdu = Fc16Request(500, 0x0010, 0x0020, 1234, 0x0040);

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        // Register at offset 0 (addr 500): unchanged
        ushort r0 = (ushort)((pdu[6] << 8) | pdu[7]);
        r0.ShouldBe((ushort)0x0010);

        // Register at offset 2 (addr 502): binary 1234 → BCD 0x1234
        ushort r2 = (ushort)((pdu[10] << 8) | pdu[11]);
        r2.ShouldBe((ushort)0x1234);

        // Register at offset 3 (addr 503): unchanged
        ushort r3 = (ushort)((pdu[12] << 8) | pdu[13]);
        r3.ShouldBe((ushort)0x0040);

        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
    }

    [Fact]
    public void FC16_WriteFull32BitBcdPair_EncodesAsNibbles()
    {
        // 32-bit BCD pair at 600/601: client sends 12345678 as CDAB binary.
        // The proxy should encode to low=0x5678, high=0x1234.
        // Client sends: low-4-digits=5678, high-4-digits=1234 (in CDAB order)
        var ctx = MakeContext(BcdTag.Create(600, 32));
        // Client sends binary: low register = low 4 digits = 5678, high register = high 4 digits = 1234
        // But actually the pipeline needs to reconstruct the value:
        // decoded = clientHigh * 10000 + clientLow = 1234 * 10000 + 5678 = 12345678
        // Then encode: (bcdLow=0x5678, bcdHigh=0x1234)
        var pdu = Fc16Request(600, 5678, 1234); // [0]=low-word=5678, [1]=high-word=1234

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        // After encoding: low=BCD(5678)=0x5678, high=BCD(1234)=0x1234
        ushort sentLow  = (ushort)((pdu[6] << 8) | pdu[7]);
        ushort sentHigh = (ushort)((pdu[8] << 8) | pdu[9]);
        sentLow.ShouldBe((ushort)0x5678);
        sentHigh.ShouldBe((ushort)0x1234);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
    }

    /// <summary>
    /// A client writing a 32-bit BCD value where either word exceeds 9999 must NOT be
    /// silently mutated by the `high*10000+low` reconstruction. Validation rejects the
    /// slot, increments invalidBcdWarnings, and passes the raw bytes through. (Otherwise
    /// the codec would accept e.g. (high=9999, low=9999) → 99_989_999 → re-encode as
    /// (high=9998, low=9999), silently losing 1 from the high word.)
    /// </summary>
    [Fact]
    public void FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning()
    {
        var ctx = MakeContext(BcdTag.Create(800, 32));
        // qty=2, low at offset 0, high at offset 1; both at 0xFFFF (= 65535, > 9999).
        var pdu = Fc16Request(800, 0xFFFF, 0xFFFF);
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original, "OOR client values must pass through raw, not be silently mutated");
        ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    /// <summary>
    /// A malformed FC16 request that claims qty=N but ships fewer than 6+N*2 bytes must
    /// NOT be partially rewritten. Without the up-front length check, each individual
    /// slot's per-slot bounds check would skip the OOB slot, leaving early slots rewritten
    /// and late slots untouched (a half-rewritten request reaching the PLC).
    /// </summary>
    [Fact]
    public void FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite()
    {
        var ctx = MakeContext(BcdTag.Create(900, 16));
        // Build a normal 1-register write, then trim 1 byte off the end so qty=1 but only
        // 1 byte of register data remains.
        var pdu = Fc16Request(900, 1234);
        byte[] truncated = pdu.AsSpan(0, pdu.Length - 1).ToArray();
        byte[] original  = [..truncated];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, truncated.AsSpan(), ctx);

        truncated.ShouldBe(original, "truncated FC16 must pass through raw");
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    /// <summary>
    /// DL205/DL260 caps FC03/FC04 reads at qty=128 (above Modbus spec's 125; documented
    /// in docs/Reference/dl205.md). The proxy must NOT truncate the qty field — a request with
    /// qty &gt; 128 at non-BCD addresses must pass through unchanged so the PLC's own
    /// validator returns exception 03 to the client. This is the transparent-pass-through
    /// contract for FCs and addresses the rewriter doesn't own.
    /// </summary>
    [Fact]
    public void FC03_Request_QtyAbove128_AtNonBcdAddress_PassesThroughUnchanged()
    {
        var ctx = MakeContext(BcdTag.Create(1024, 16)); // tag elsewhere; not in this read
        var req = Fc03Request(address: 5000, qty: 200);
        byte[] original = [..req];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, req.AsSpan(), ctx);

        req.ShouldBe(original, "qty must NOT be truncated; the PLC validates and returns ex03");
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    /// <summary>
    /// Symmetric inverse of the existing partial-overlap test: the write range starts ON
    /// the high register of a 32-bit pair (low word is BEFORE the write range). Must also
    /// be passed through raw with a partial warning, not half-rewritten.
    /// </summary>
    [Fact]
    public void FC16_WriteStartsOnHighWord_Of32BitPair_PassesThroughRaw_WithPartialWarning()
    {
        // 32-bit BCD tag at 700/701; write range 701–702 (qty=2). Low (700) is OUT of
        // range; high (701) is IN range — partial overlap on the high side.
        var ctx = MakeContext(BcdTag.Create(700, 32));
        var pdu = Fc16Request(701, 0xCCCC, 0xDDDD);
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original, "high-only partial overlap must pass through raw");
        ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    /// <summary>
    /// Mixed slots in a single FC03 read: a 16-bit BCD tag, a 32-bit BCD pair, and an
    /// unconfigured register. Each slot should be handled independently — the 16-bit and
    /// 32-bit rewritten, the unconfigured register passed through unchanged.
    /// </summary>
    [Fact]
    public void FC03_Mixed_16Bit_32Bit_AndNonBcd_InOneRead_OnlyConfiguredSlotsRewritten()
    {
        // Layout:
        //   addr 100: 16-bit BCD     → wire 0x1234 → decoded 1234 (= 0x04D2 binary)
        //   addr 101: unconfigured   → passes through 0x9999
        //   addr 102: 32-bit BCD low → wire 0x5678 (BCD digits 5,6,7,8 → 5678)
        //   addr 103: 32-bit BCD high→ wire 0x1234 (BCD digits 1,2,3,4 → 1234)
        //                              decoded = 1234*10_000 + 5678 = 12_345_678
        //                              emitted as base-10000 binary CDAB:
        //                                low  = 12_345_678 % 10_000 = 5678  (binary 0x162E)
        //                                high = 12_345_678 / 10_000 = 1234  (binary 0x04D2)
        var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(102, 32));
        var inFlight = MakeInFlight(0x03, startAddress: 100, qty: 4);
        var responseCtx = ctx.WithCurrentRequest(inFlight);

        var pdu = Fc03Response(0x1234, 0x9999, 0x5678, 0x1234);

        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), responseCtx);

        // pdu[0]=fc, pdu[1]=byteCount, pdu[2..] = register bytes (2 per register).
        ushort reg100 = (ushort)((pdu[2] << 8) | pdu[3]);
        ushort reg101 = (ushort)((pdu[4] << 8) | pdu[5]);
        ushort reg102 = (ushort)((pdu[6] << 8) | pdu[7]);
        ushort reg103 = (ushort)((pdu[8] << 8) | pdu[9]);

        reg100.ShouldBe((ushort)1234,   "16-bit BCD slot must decode to 1234");
        reg101.ShouldBe((ushort)0x9999, "unconfigured register must pass through unchanged");
        reg102.ShouldBe((ushort)5678,   "32-bit pair low must emit decimal 5678 as binary");
        reg103.ShouldBe((ushort)1234,   "32-bit pair high must emit decimal 1234 as binary");
        // Slot count: 1 from 16-bit + 2 from 32-bit pair = 3 rewritten slots.
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(3);
    }

    /// <summary>
    /// FC16 response handling. The response carries no register values (just an echo of
    /// [fc][start][qty]) so the rewriter must pass it through unchanged regardless of
    /// tag-map content.
    /// </summary>
    [Fact]
    public void FC16_Response_PassesThroughUnchanged_RegardlessOfTagMap()
    {
        var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(200, 32));
        // FC16 response: [fc=10][startHi][startLo][qtyHi][qtyLo] = 5 bytes total.
        var pdu = new byte[] { 0x10, 0x00, 0x64, 0x00, 0x05 };
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original, "FC16 response carries no register data and must pass through");
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void FC16_WritePartiallyOverlapping32BitPair_PassesThroughRaw_WithPartialWarning()
    {
        // Write range 700–701 (2 regs), but 32-bit BCD tag is at 701/702.
        // Only the low register (701) is in range; high register (702) is not.
        var ctx = MakeContext(BcdTag.Create(701, 32));
        var pdu = Fc16Request(700, 0xAAAA, 0xBBBB); // writes 700 and 701; tag needs 701 and 702

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        // The low register (at offset 1 in pdu, i.e., addr 701) should be unchanged.
        ushort r1 = (ushort)((pdu[8] << 8) | pdu[9]);
        r1.ShouldBe((ushort)0xBBBB);
        ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    // ── Pass-through FCs ─────────────────────────────────────────────────────

    [Fact]
    public void FC01_Request_IsPassedThroughUnchanged()
    {
        var ctx = MakeContext(BcdTag.Create(100, 16));
        var pdu = new byte[] { 0x01, 0x00, 0x64, 0x00, 0x08 }; // FC01 read coils
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void FC02_Request_IsPassedThroughUnchanged()
    {
        var ctx = MakeContext(BcdTag.Create(100, 16));
        var pdu = new byte[] { 0x02, 0x00, 0x64, 0x00, 0x08 }; // FC02 read discrete inputs
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void FC05_Request_IsPassedThroughUnchanged()
    {
        var ctx = MakeContext(BcdTag.Create(100, 16));
        var pdu = new byte[] { 0x05, 0x00, 0x64, 0xFF, 0x00 }; // FC05 write single coil
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void FC15_Request_IsPassedThroughUnchanged()
    {
        var ctx = MakeContext(BcdTag.Create(100, 16));
        var pdu = new byte[] { 0x0F, 0x00, 0x64, 0x00, 0x08, 0x01, 0xAB }; // FC15 write multiple coils
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    // ── Exception response test ──────────────────────────────────────────────

    [Fact]
    public void FC03_ExceptionResponse_PassesThroughRaw_LogsPassthrough_IncrementsBackendException()
    {
        var ctx = MakeContext(BcdTag.Create(100, 16));
        // Exception response: [fc|0x80=0x83][exceptionCode=02]
        var pdu = new byte[] { 0x83, 0x02 };
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original); // bytes unchanged
        ctx.Counters.Snapshot().BackendException02.ShouldBe(1);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    // ── Empty BcdTagMap tests ────────────────────────────────────────────────

    [Fact]
    public void EmptyTagMap_FC03Response_ProducesZeroRewrites()
    {
        var ctx = MakeContext(/* no tags */);
        var req = Fc03Request(100, 3);
        var rsp = Fc03Response(0x1234, 0x5678, 0x9ABC);
        byte[] original = [..rsp];

        SendRequestThenProcessResponse(ctx, req, rsp);

        rsp.ShouldBe(original);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    [Fact]
    public void EmptyTagMap_FC06Request_ProducesZeroRewrites()
    {
        var ctx = MakeContext(/* no tags */);
        var pdu = Fc06Request(300, 1234);
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original);
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
    }

    // ── Counter snapshot accuracy ────────────────────────────────────────────

    [Fact]
    public void CounterSnapshot_ReflectsIncrementsExactly()
    {
        // Process 3 FC03 responses with one 16-bit BCD slot each, plus one bad-nibble.
        var ctx = MakeContext(BcdTag.Create(100, 16));

        for (int i = 0; i < 3; i++)
        {
            var req = Fc03Request(100, 1);
            var rsp = Fc03Response(0x1234);
            SendRequestThenProcessResponse(ctx, req, rsp);
        }

        // One with a bad nibble.
        {
            var req = Fc03Request(100, 1);
            var rsp = Fc03Response(0x12A4);
            SendRequestThenProcessResponse(ctx, req, rsp);
        }

        var snap = ctx.Counters.Snapshot();
        snap.RewrittenSlots.ShouldBe(3);      // 3 successful decodes
        snap.InvalidBcdWarnings.ShouldBe(1);  // 1 bad-nibble pass-through
        // PdusForwarded = 4 requests + 4 responses = 8
        snap.PdusForwarded.ShouldBe(8);
        snap.Fc03.ShouldBe(8); // both request and response increment by FC (request FC03)
    }

    // ── PDU length invariant ─────────────────────────────────────────────────

    [Fact]
    public void PduLength_IsNeverChangedByRewriting()
    {
        // Build a response with two 16-bit BCD tags. After rewriting, the PDU must be
        // exactly the same byte count as before.
        var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(101, 16));
        var req = Fc03Request(100, 2);
        var rsp = Fc03Response(0x1234, 0x5678);
        int originalLength = rsp.Length;

        SendRequestThenProcessResponse(ctx, req, rsp);

        rsp.Length.ShouldBe(originalLength); // MBAP transparency contract
        ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
    }

    // ── FC counter tracking ──────────────────────────────────────────────────

    [Fact]
    public void FcCounters_IncrementCorrectly_ForEachFunctionCode()
    {
        var ctx = MakeContext(BcdTag.Create(100, 16));

        // FC03 request
        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc03Request(100, 1).AsSpan(), ctx);
        // FC04 request
        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
            new byte[] { 0x04, 0x00, 0x64, 0x00, 0x01 }.AsSpan(), ctx);
        // FC06 request
        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc06Request(300, 1234).AsSpan(), ctx);
        // FC16 request
        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc16Request(100, 0x1234).AsSpan(), ctx);
        // FC01 (Other)
        Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
            new byte[] { 0x01, 0x00, 0x00, 0x00, 0x01 }.AsSpan(), ctx);

        var snap = ctx.Counters.Snapshot();
        snap.Fc03.ShouldBe(1);
        snap.Fc04.ShouldBe(1);
        snap.Fc06.ShouldBe(1);
        snap.Fc16.ShouldBe(1);
        snap.FcOther.ShouldBe(1);
        snap.PdusForwarded.ShouldBe(5);
    }

    // ── Extra coverage: backend exception codes ──────────────────────────────

    [Fact]
    public void BackendExceptions_AllCodes_TrackSeparately()
    {
        var ctx = MakeContext();

        // Codes 1–4 get individual counters; code 5 goes to Other.
        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
            new byte[] { 0x81, 0x01 }.AsSpan(), ctx);
        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
            new byte[] { 0x81, 0x02 }.AsSpan(), ctx);
        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
            new byte[] { 0x81, 0x03 }.AsSpan(), ctx);
        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
            new byte[] { 0x81, 0x04 }.AsSpan(), ctx);
        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
            new byte[] { 0x81, 0x05 }.AsSpan(), ctx); // code 5 → Other

        var snap = ctx.Counters.Snapshot();
        snap.BackendException01.ShouldBe(1);
        snap.BackendException02.ShouldBe(1);
        snap.BackendException03.ShouldBe(1);
        snap.BackendException04.ShouldBe(1);
        snap.BackendExceptionOther.ShouldBe(1);
    }

    // ── Plain PduContext (no BCD context) → no-op ────────────────────────────

    [Fact]
    public void PlainPduContext_IsPassedThroughWithoutError()
    {
        // If a plain PduContext is passed (not PerPlcContext), the pipeline must
        // return cleanly without throwing, leaving bytes unchanged.
        var ctx  = new PduContext { PlcName = "Test" };
        var pdu  = Fc03Response(0x1234);
        byte[] original = [..pdu];

        Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);

        pdu.ShouldBe(original);
    }
}
