using Shouldly;
using Xunit;
using S7.Net.Types;

namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;

/// <summary>
///     Unit tests for the S7 wide-type (8-byte numeric) byte-buffer codec: the pure
///     <see cref="S7Driver.DecodeScalarBlock"/> / <see cref="S7Driver.EncodeScalarBlock"/>
///     helpers and <see cref="S7Driver.ScalarByteWidth"/>. These decode/encode an
///     Int64/UInt64/LReal (Float64) scalar from a contiguous big-endian byte block — the
///     network I/O half (<c>Plc.ReadBytesAsync</c>/<c>WriteBytesAsync</c>) has no in-process
///     fake so only the codec is unit-proven (mirrors <see cref="S7ArrayReadTests"/>).
///     String (S7 classic STRING) decode/encode is proven here via
///     <see cref="S7.Net.Types.S7String"/>; DateTime (S7 classic DATE_AND_TIME / DT, 8-byte BCD)
///     decode/encode is proven via <see cref="S7.Net.Types.DateTime"/> (the 8-byte DT helper —
///     NOT DTL). Timer (S5TIME → seconds <c>double</c>) and Counter (word → <c>int</c> count)
///     decode is proven via <see cref="S7.Net.Types.Timer"/> / <see cref="S7.Net.Types.Counter"/>
///     and is routed by the parsed AREA (so a Timer/Float64 tag reads 2 bytes, not 8); both are
///     read-only this phase, so their encode throws <see cref="NotSupportedException"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ScalarBlockTests
{
    // ── Helpers ──────────────────────────────────────────────────────────────────────────

    // Wide scalars are byte-anchored: DB{n}.DBB{offset}, parser yields S7Size.Byte.
    private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254) =>
        new("WideTag", "DB1.DBB0", dt, StringLength: stringLength);

    private static S7ParsedAddress Addr() =>
        new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0);

    // Timer / Counter addresses: the parser stores the timer/counter NUMBER in ByteOffset,
    // DbNumber 0, Size Word (see S7AddressParser.ParseTimerOrCounter). A Timer tag is typed
    // Float64 (decoded to seconds) and a Counter tag Int32 (decoded to count) — T1 guard-c.
    private static S7TagDefinition TimerTag(int number = 5, bool writable = false) =>
        new("TimerTag", $"T{number}", S7DataType.Float64, Writable: writable);

    private static S7ParsedAddress TimerAddr(int number = 5) =>
        new(S7Area.Timer, DbNumber: 0, S7Size.Word, ByteOffset: number, BitOffset: 0);

    private static S7TagDefinition CounterTag(int number = 3, bool writable = false) =>
        new("CounterTag", $"C{number}", S7DataType.Int32, Writable: writable);

    private static S7ParsedAddress CounterAddr(int number = 3) =>
        new(S7Area.Counter, DbNumber: 0, S7Size.Word, ByteOffset: number, BitOffset: 0);

    // S7 is big-endian: most-significant byte first.
    private static byte[] BeUInt64(ulong v)
    {
        var b = new byte[8];
        for (var i = 0; i < 8; i++)
            b[i] = (byte)(v >> (56 - i * 8));
        return b;
    }

    // ── ScalarByteWidth ───────────────────────────────────────────────────────────────────

    /// <summary>Verifies the 8-byte numeric widths and the String/DateTime widths.</summary>
    [Theory]
    [InlineData(S7DataType.Int64, 8)]
    [InlineData(S7DataType.UInt64, 8)]
    [InlineData(S7DataType.Float64, 8)]
    [InlineData(S7DataType.DateTime, 8)]
    public void ScalarByteWidth_fixed_width_types(S7DataType dt, int expected)
        => S7Driver.ScalarByteWidth(Tag(dt), Addr()).ShouldBe(expected);

    /// <summary>Verifies String width is StringLength + 2 (S7 STRING header: max-len + actual-len).</summary>
    [Fact]
    public void ScalarByteWidth_String_is_length_plus_two()
        => S7Driver.ScalarByteWidth(Tag(S7DataType.String, stringLength: 10), Addr()).ShouldBe(12);

    /// <summary>Headline correctness assertion: a Timer tag is typed Float64 (which would otherwise
    /// yield width 8), but the Timer AREA must take precedence and read exactly 2 bytes (one S5TIME
    /// word). Without area-precedence the codec would read 8 bytes and mis-frame the timer.</summary>
    [Fact]
    public void ScalarByteWidth_Timer_is_two_despite_Float64_datatype()
        => S7Driver.ScalarByteWidth(TimerTag(), TimerAddr()).ShouldBe(2);

    /// <summary>A Counter tag is typed Int32; the Counter AREA reads exactly 2 bytes (one counter word).</summary>
    [Fact]
    public void ScalarByteWidth_Counter_is_two()
        => S7Driver.ScalarByteWidth(CounterTag(), CounterAddr()).ShouldBe(2);

    // ── DecodeScalarBlock — Int64 ─────────────────────────────────────────────────────────

    /// <summary>Verifies an Int64 block decodes from big-endian bytes.</summary>
    [Fact]
    public void DecodeScalarBlock_Int64_reads_big_endian()
    {
        var block = BeUInt64(0x0123456789ABCDEFUL);
        // First byte is the most-significant byte (0x01) — proves big-endian, not little-endian.
        block[0].ShouldBe((byte)0x01);

        var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block);
        result.ShouldBeOfType<long>().ShouldBe(0x0123456789ABCDEFL);
    }

    /// <summary>Verifies a negative Int64 decodes correctly (two's complement, high bit set).</summary>
    [Fact]
    public void DecodeScalarBlock_Int64_negative()
    {
        var block = BeUInt64(unchecked((ulong)-2L)); // 0xFFFF_FFFF_FFFF_FFFE
        var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block);
        result.ShouldBeOfType<long>().ShouldBe(-2L);
    }

    // ── DecodeScalarBlock — UInt64 ────────────────────────────────────────────────────────

    /// <summary>Verifies a UInt64 block decodes a value larger than long.MaxValue.</summary>
    [Fact]
    public void DecodeScalarBlock_UInt64_reads_value_above_long_max()
    {
        var block = BeUInt64(ulong.MaxValue); // 0xFFFF_FFFF_FFFF_FFFF
        var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.UInt64), Addr(), block);
        result.ShouldBeOfType<ulong>().ShouldBe(ulong.MaxValue);
    }

    // ── DecodeScalarBlock — Float64 (LReal) ───────────────────────────────────────────────

    /// <summary>Verifies a Float64 (LReal) block decodes from IEEE-754 big-endian.</summary>
    [Fact]
    public void DecodeScalarBlock_Float64_reads_ieee754_big_endian()
    {
        var bits = unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI));
        var block = BeUInt64(bits);
        var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Float64), Addr(), block);
        result.ShouldBeOfType<double>().ShouldBe(Math.PI, tolerance: 1e-12);
    }

    // ── EncodeScalarBlock — big-endian byte production ────────────────────────────────────

    /// <summary>Verifies Int64 encodes to big-endian bytes (MSB first).</summary>
    [Fact]
    public void EncodeScalarBlock_Int64_writes_big_endian()
    {
        var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Int64), 0x0123456789ABCDEFL);
        bytes.Length.ShouldBe(8);
        bytes.ShouldBe(BeUInt64(0x0123456789ABCDEFUL));
        bytes[0].ShouldBe((byte)0x01); // MSB first — little-endian regression guard.
    }

    /// <summary>Verifies UInt64 encodes to big-endian bytes.</summary>
    [Fact]
    public void EncodeScalarBlock_UInt64_writes_big_endian()
    {
        var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.UInt64), ulong.MaxValue);
        bytes.ShouldBe(BeUInt64(ulong.MaxValue));
    }

    /// <summary>Verifies Float64 encodes to IEEE-754 big-endian bytes.</summary>
    [Fact]
    public void EncodeScalarBlock_Float64_writes_ieee754_big_endian()
    {
        var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Float64), Math.PI);
        bytes.ShouldBe(BeUInt64(unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI))));
    }

    // ── Round-trip identity (encode → decode) ─────────────────────────────────────────────

    /// <summary>Verifies Int64 round-trips through encode→decode for positive, negative and edge values.</summary>
    [Theory]
    [InlineData(0L)]
    [InlineData(1L)]
    [InlineData(-1L)]
    [InlineData(long.MaxValue)]
    [InlineData(long.MinValue)]
    [InlineData(0x0123456789ABCDEFL)]
    public void Int64_round_trips(long value)
    {
        var tag = Tag(S7DataType.Int64);
        var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
        decoded.ShouldBeOfType<long>().ShouldBe(value);
    }

    /// <summary>Verifies UInt64 round-trips, including a large value above long.MaxValue.</summary>
    [Theory]
    [InlineData(0UL)]
    [InlineData(70_000UL)]
    [InlineData(ulong.MaxValue)]
    [InlineData(0x8000_0000_0000_0001UL)]
    public void UInt64_round_trips(ulong value)
    {
        var tag = Tag(S7DataType.UInt64);
        var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
        decoded.ShouldBeOfType<ulong>().ShouldBe(value);
    }

    /// <summary>Verifies Float64 (LReal) round-trips for representative doubles, including
    /// the IEEE-754 specials (NaN / ±Infinity) — these pass through BinaryPrimitives unchanged.</summary>
    [Theory]
    [InlineData(0.0)]
    [InlineData(3.141592653589793)]
    [InlineData(-2.5e-300)]
    [InlineData(1.7976931348623157e308)]
    [InlineData(double.NaN)]
    [InlineData(double.PositiveInfinity)]
    [InlineData(double.NegativeInfinity)]
    public void Float64_round_trips(double value)
    {
        var tag = Tag(S7DataType.Float64);
        var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
        // double.NaN != double.NaN, so compare via Shouldly's IsNaN for that case.
        if (double.IsNaN(value))
            decoded.ShouldBeOfType<double>().ShouldBe(double.NaN);
        else
            decoded.ShouldBeOfType<double>().ShouldBe(value);
    }

    // ── DecodeScalarBlock — String (S7 classic STRING) ────────────────────────────────────

    /// <summary>Verifies an S7 STRING block decodes to its C# string, ignoring the
    /// two-byte <c>[maxLen][curLen]</c> header and any reserved padding past curLen.</summary>
    [Fact]
    public void DecodeScalarBlock_String_reads_header_and_chars()
    {
        // S7 classic STRING layout: [maxLen=10][curLen=5]['H']['E']['L']['L']['O'][pad…].
        var block = S7String.ToByteArray("HELLO", 10);
        block.Length.ShouldBe(12);          // StringLength(10) + 2-byte header.
        block[0].ShouldBe((byte)10);        // maxLen.
        block[1].ShouldBe((byte)5);         // curLen.

        var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block);
        result.ShouldBeOfType<string>().ShouldBe("HELLO");
    }

    /// <summary>Verifies a hand-built STRING block (not produced by S7.Net) still decodes,
    /// pinning the on-the-wire layout we depend on.</summary>
    [Fact]
    public void DecodeScalarBlock_String_decodes_hand_built_block()
    {
        var block = new byte[12];
        block[0] = 10;                       // maxLen.
        block[1] = 5;                        // curLen.
        block[2] = (byte)'H'; block[3] = (byte)'E'; block[4] = (byte)'L';
        block[5] = (byte)'L'; block[6] = (byte)'O'; // bytes 7..11 stay zero (reserved padding).

        var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block);
        result.ShouldBeOfType<string>().ShouldBe("HELLO");
    }

    /// <summary>Verifies an empty S7 STRING decodes to the empty string.</summary>
    [Fact]
    public void DecodeScalarBlock_String_empty()
    {
        var block = S7String.ToByteArray("", 10);
        var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block);
        result.ShouldBeOfType<string>().ShouldBe("");
    }

    // ── EncodeScalarBlock — String (S7 classic STRING) ────────────────────────────────────

    /// <summary>Verifies a C# string encodes to a STRING block sized to the reserved field
    /// (StringLength + 2), with header <c>[maxLen=StringLength][curLen=value.Length]</c> and
    /// the chars laid out after it.</summary>
    [Fact]
    public void EncodeScalarBlock_String_writes_header_chars_and_pads_to_reserved()
    {
        var tag = Tag(S7DataType.String, stringLength: 10);
        var block = S7Driver.EncodeScalarBlock(tag, "HELLO");

        block.Length.ShouldBe(12);          // StringLength(10) + 2 — full reserved field, not curLen-sized.
        block[0].ShouldBe((byte)10);        // maxLen == declared StringLength.
        block[1].ShouldBe((byte)5);         // curLen == value.Length.
        block[2].ShouldBe((byte)'H');
        block[6].ShouldBe((byte)'O');
        block[7].ShouldBe((byte)0);         // reserved padding is zeroed.
        block[11].ShouldBe((byte)0);
    }

    /// <summary>Verifies a null value encodes as an empty STRING (curLen 0), not a throw.</summary>
    [Fact]
    public void EncodeScalarBlock_String_null_value_is_empty_string()
    {
        var tag = Tag(S7DataType.String, stringLength: 10);
        var block = S7Driver.EncodeScalarBlock(tag, value: null);

        block.Length.ShouldBe(12);
        block[0].ShouldBe((byte)10);        // maxLen.
        block[1].ShouldBe((byte)0);         // curLen 0.
    }

    /// <summary>Verifies a non-string value coerces via <c>Convert.ToString</c> before encoding.</summary>
    [Fact]
    public void EncodeScalarBlock_String_coerces_non_string_value()
    {
        var tag = Tag(S7DataType.String, stringLength: 10);
        var block = S7Driver.EncodeScalarBlock(tag, 1234);

        var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), block);
        decoded.ShouldBeOfType<string>().ShouldBe("1234");
    }

    /// <summary>Verifies a string longer than the reserved length throws (S7.Net rejects it;
    /// we do NOT silently truncate). Pins the overflow behaviour.</summary>
    [Fact]
    public void EncodeScalarBlock_String_overflow_throws()
    {
        var tag = Tag(S7DataType.String, stringLength: 5);
        // 6 chars into a 5-char reserved field — S7.Net throws ArgumentException.
        Should.Throw<ArgumentException>(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF"));
    }

    // ── String round-trip identity (encode → decode) ──────────────────────────────────────

    /// <summary>Verifies String round-trips through encode→decode, incl. empty and max-length.</summary>
    [Theory]
    [InlineData("")]
    [InlineData("A")]
    [InlineData("Hello, S7!")]          // 10 chars == StringLength (at max).
    [InlineData("Tag_Value 42")]
    public void String_round_trips(string value)
    {
        // StringLength sized to fit the longest sample (12) so none overflow.
        var tag = Tag(S7DataType.String, stringLength: 12);
        var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
        decoded.ShouldBeOfType<string>().ShouldBe(value);
    }

    /// <summary>Verifies a string exactly at the reserved length round-trips (boundary, no overflow).</summary>
    [Fact]
    public void String_at_max_length_round_trips()
    {
        var tag = Tag(S7DataType.String, stringLength: 5);
        var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, "ABCDE"));
        decoded.ShouldBeOfType<string>().ShouldBe("ABCDE");
    }

    // ── DecodeScalarBlock — DateTime (S7 classic DATE_AND_TIME / DT) ───────────────────────

    /// <summary>Verifies an 8-byte S7 DT block decodes to its <see cref="System.DateTime"/> value
    /// via <see cref="S7.Net.Types.DateTime"/> (the 8-byte BCD DT helper, not the 12-byte DTL).</summary>
    [Fact]
    public void DecodeScalarBlock_DateTime_reads_dt_bcd_block()
    {
        var expected = new System.DateTime(2026, 6, 17, 12, 34, 56);
        // Fixture built by S7.Net's own DT encoder so the block matches the on-the-wire DT layout.
        var block = global::S7.Net.Types.DateTime.ToByteArray(expected);
        block.Length.ShouldBe(8);

        var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.DateTime), Addr(), block);
        result.ShouldBeOfType<System.DateTime>().ShouldBe(expected);
    }

    // ── EncodeScalarBlock — DateTime ───────────────────────────────────────────────────────

    /// <summary>Verifies a <see cref="System.DateTime"/> encodes to an 8-byte DT block.</summary>
    [Fact]
    public void EncodeScalarBlock_DateTime_writes_eight_byte_block()
    {
        var value = new System.DateTime(2026, 6, 17, 12, 34, 56);
        var block = S7Driver.EncodeScalarBlock(Tag(S7DataType.DateTime), value);
        block.Length.ShouldBe(8);
        // Matches S7.Net's own DT encoder exactly (same on-the-wire bytes).
        block.ShouldBe(global::S7.Net.Types.DateTime.ToByteArray(value));
    }

    /// <summary>Verifies a string/ISO timestamp coerces via <c>Convert.ToDateTime</c> before encoding.</summary>
    [Fact]
    public void EncodeScalarBlock_DateTime_coerces_string_value()
    {
        var tag = Tag(S7DataType.DateTime);
        var block = S7Driver.EncodeScalarBlock(tag, "2026-06-17T12:34:56");

        var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), block);
        decoded.ShouldBeOfType<System.DateTime>().ShouldBe(new System.DateTime(2026, 6, 17, 12, 34, 56));
    }

    /// <summary>Verifies a year outside the S7 DT range (1990–2089) throws — S7.Net's DT encoder
    /// validates the range and raises <see cref="ArgumentOutOfRangeException"/>; we surface it.</summary>
    [Theory]
    [InlineData(1980)]
    [InlineData(2100)]
    public void EncodeScalarBlock_DateTime_out_of_range_year_throws(int year)
    {
        var tag = Tag(S7DataType.DateTime);
        var value = new System.DateTime(year, 1, 1, 0, 0, 0);
        Should.Throw<ArgumentOutOfRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
    }

    // ── DateTime round-trip identity (encode → decode) ────────────────────────────────────

    /// <summary>Verifies DateTime round-trips through encode→decode. S7 DT preserves full
    /// millisecond precision (the 8-byte BCD packs ms-tens/hundreds), so the identity holds to
    /// the millisecond — no precision loss to document below the second.</summary>
    [Theory]
    [InlineData(1990, 1, 1, 0, 0, 0, 0)]            // DT minimum.
    [InlineData(2026, 6, 17, 12, 34, 56, 0)]        // no sub-second.
    [InlineData(2026, 6, 17, 12, 34, 56, 789)]      // milliseconds preserved.
    [InlineData(2089, 12, 31, 23, 59, 59, 999)]     // DT maximum, ms boundary.
    public void DateTime_round_trips_to_the_millisecond(
        int y, int mo, int d, int h, int mi, int s, int ms)
    {
        var value = new System.DateTime(y, mo, d, h, mi, s, ms);
        var tag = Tag(S7DataType.DateTime);
        var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
        decoded.ShouldBeOfType<System.DateTime>().ShouldBe(value);
    }

    // ── DecodeScalarBlock — Timer (S5TIME → seconds, read-only) ───────────────────────────

    /// <summary>
    ///     Verifies a 2-byte S5TIME block decodes to a duration in SECONDS as a <c>double</c>,
    ///     routed by the Timer AREA (NOT the tag's Float64 DataType — that would mis-read the
    ///     2-byte block as an 8-byte LReal). The fixture is a hand-built S5TIME word: bits [15:14]
    ///     of the 16-bit word carry the time base (0=×10 ms, 1=×100 ms, 2=×1 s, 3=×10 s), bits
    ///     [13:12] are unused (always 0), and bits [11:0] carry the 3-digit BCD value (hundreds in
    ///     byte0 low nibble, tens in byte1 high nibble, ones in byte1 low nibble). Here base=2
    ///     (×1 s) and BCD=100 → 100.0 s (verified against S7.Net's
    ///     <see cref="S7.Net.Types.Timer.FromByteArray"/> S5TIME decode).
    /// </summary>
    [Fact]
    public void DecodeScalarBlock_Timer_decodes_s5time_to_seconds()
    {
        // [0x21,0x00]: byte0 high nibble 0x2 = base bits (×1 s), byte0 low nibble 0x1 = BCD hundreds,
        // byte1 0x00 = BCD tens+ones → value 100 × 1 s = 100.0 s.
        var block = new byte[] { 0x21, 0x00 };
        var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block);
        result.ShouldBeOfType<double>().ShouldBe(100.0, tolerance: 1e-9);
    }

    /// <summary>Verifies a sub-second S5TIME decodes correctly (base = 100 ms, BCD = 250 → 25.0 s).</summary>
    [Fact]
    public void DecodeScalarBlock_Timer_decodes_fractional_base()
    {
        // [0x12,0x50]: base bits 0x1 (×0.1 s), BCD 250 → 250 × 0.1 s = 25.0 s.
        var block = new byte[] { 0x12, 0x50 };
        var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block);
        result.ShouldBeOfType<double>().ShouldBe(25.0, tolerance: 1e-9);
    }

    /// <summary>Verifies a zero S5TIME word decodes to 0.0 seconds.</summary>
    [Fact]
    public void DecodeScalarBlock_Timer_zero()
    {
        var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), new byte[] { 0x00, 0x00 });
        result.ShouldBeOfType<double>().ShouldBe(0.0);
    }

    /// <summary>
    ///     Covers the two remaining S5TIME time bases to complete all four (0–3). S5TIME word
    ///     layout: bits[15:14]=base, bits[13:12]=0, bits[11:8]=BCD-hundreds, bits[7:4]=BCD-tens,
    ///     bits[3:0]=BCD-ones. Bases 1 and 2 are covered by the Fact tests above; base 0
    ///     (×0.01 s) and base 3 (×10 s) are added here.
    ///     <para>
    ///     Byte derivation:
    ///     <list type="bullet">
    ///         <item>Base 0 (×0.01 s) / BCD=015: byte0=[base=0&lt;&lt;6|0&lt;&lt;4|hundreds=0]=0x00,
    ///         byte1=[tens=1&lt;&lt;4|ones=5]=0x15 → 15 × 0.01 s = 0.15 s.</item>
    ///         <item>Base 3 (×10 s) / BCD=005: byte0=[base=3&lt;&lt;6|0&lt;&lt;4|hundreds=0]=0x30,
    ///         byte1=[tens=0&lt;&lt;4|ones=5]=0x05 → 5 × 10 s = 50.0 s.</item>
    ///     </list>
    ///     </para>
    /// </summary>
    [Theory]
    [InlineData(new byte[] { 0x00, 0x15 }, 0.15)]   // base 0 (×0.01 s), BCD=015 → 15×0.01=0.15 s
    [InlineData(new byte[] { 0x30, 0x05 }, 50.0)]   // base 3 (×10 s),   BCD=005 → 5×10=50.0 s
    public void DecodeScalarBlock_Timer_all_time_bases(byte[] block, double expectedSeconds)
    {
        var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block);
        result.ShouldBeOfType<double>().ShouldBe(expectedSeconds, tolerance: 1e-9);
    }

    // ── DecodeScalarBlock — Counter (BCD/raw word → count, read-only) ──────────────────────

    /// <summary>
    ///     Verifies a 2-byte counter block decodes to a COUNT as an <c>int</c>, routed by the
    ///     Counter AREA (the tag is typed Int32). S7.Net's
    ///     <see cref="S7.Net.Types.Counter.FromByteArray"/> reads the word big-endian; we surface
    ///     it as a (non-negative) <c>int</c>.
    /// </summary>
    [Fact]
    public void DecodeScalarBlock_Counter_decodes_to_int_count()
    {
        // Big-endian word 0x0042 = 66.
        var block = new byte[] { 0x00, 0x42 };
        var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block);
        result.ShouldBeOfType<int>().ShouldBe(66);
    }

    /// <summary>Verifies a larger counter value decodes (0x0123 = 291) — pins big-endian order.</summary>
    [Fact]
    public void DecodeScalarBlock_Counter_decodes_big_endian()
    {
        var block = new byte[] { 0x01, 0x23 };
        var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block);
        result.ShouldBeOfType<int>().ShouldBe(0x0123);
    }

    /// <summary>Verifies a zero counter decodes to 0.</summary>
    [Fact]
    public void DecodeScalarBlock_Counter_zero()
    {
        var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), new byte[] { 0x00, 0x00 });
        result.ShouldBeOfType<int>().ShouldBe(0);
    }

    // ── EncodeScalarBlock — Timer/Counter are read-only this phase ─────────────────────────

    /// <summary>Verifies a Timer write throws NotSupportedException — Timer/Counter are read-only.
    /// This backstop is still exercised even though <see cref="S7Driver.WriteAsync"/> now
    /// short-circuits to BadNotWritable before reaching EncodeScalarBlock — a mis-route (e.g.
    /// a future refactor that bypasses the WriteAsync guard) must still fail loudly here.</summary>
    [Fact]
    public void EncodeScalarBlock_Timer_throws_read_only()
        => Should.Throw<NotSupportedException>(() => S7Driver.EncodeScalarBlock(TimerTag(), 1.0));

    /// <summary>Verifies a Counter write throws NotSupportedException — Timer/Counter are read-only.</summary>
    [Fact]
    public void EncodeScalarBlock_Counter_throws_read_only()
        => Should.Throw<NotSupportedException>(() => S7Driver.EncodeScalarBlock(CounterTag(), 42));

    // ── WriteAsync guard — Timer/Counter area precondition ────────────────────────────────

    /// <summary>
    ///     Pins the precondition for the <see cref="S7Driver.WriteAsync"/> Timer/Counter
    ///     short-circuit: <see cref="S7AddressParser.TryParse"/> on a Timer address must
    ///     yield <see cref="S7Area.Timer"/> and on a Counter address must yield
    ///     <see cref="S7Area.Counter"/>. The WriteAsync guard detects the area via TryParse (for
    ///     transient equipment-tag refs not in _parsedByName) and returns BadNotWritable without
    ///     reaching EncodeScalarBlock. This test verifies the area detection that gate depends on
    ///     — a pure, no-PLC assertion. (<see cref="WriteAsync"/> itself requires a connected Plc
    ///     and is therefore integration-tested only; the EncodeScalarBlock_Timer/Counter_throws
    ///     tests above cover the defensive backstop if the guard is ever bypassed.)
    /// </summary>
    [Theory]
    [InlineData("T0",  S7Area.Timer)]
    [InlineData("T5",  S7Area.Timer)]
    [InlineData("T15", S7Area.Timer)]
    [InlineData("C0",  S7Area.Counter)]
    [InlineData("C3",  S7Area.Counter)]
    [InlineData("C10", S7Area.Counter)]
    public void AddressParser_yields_Timer_or_Counter_area_for_TC_addresses(string address, S7Area expectedArea)
    {
        S7AddressParser.TryParse(address, out var parsed).ShouldBeTrue();
        parsed.Area.ShouldBe(expectedArea,
            $"WriteAsync BadNotWritable guard relies on TryParse('{address}').Area == {expectedArea}");
    }
}
