using Shouldly;
using Xunit;

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

public sealed class ModbusRtuFramingTests
{
    private static MemoryStream Canned(params byte[] frame) => new(frame);

    [Fact]
    public void BuildAdu_prefixes_unit_and_appends_crc()
    {
        var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
        adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
    }

    [Fact]
    public async Task ReadResponse_FC03_returns_bare_pdu()
    {
        // addr=01 fc=03 byteCount=02 data=00 0A  + CRC(lo,hi)
        var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
        var frame = ModbusCrc.Append(body);
        await using var s = Canned(frame);
        var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
        pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
    }

    [Fact]
    public async Task ReadResponse_exception_frame_throws_ModbusException()
    {
        // addr=01 fc=0x83 exc=0x02 + CRC
        var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
        await using var s = Canned(frame);
        var ex = await Should.ThrowAsync<ModbusException>(
            ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
        ex.FunctionCode.ShouldBe((byte)0x03);
        ex.ExceptionCode.ShouldBe((byte)0x02);
    }

    [Fact]
    public async Task ReadResponse_bad_crc_throws_desync()
    {
        var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
        frame[^1] ^= 0xFF; // corrupt CRC high byte
        await using var s = Canned(frame);
        var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
            ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
        ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.CrcMismatch);
    }

    [Fact]
    public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
    {
        // addr=01 fc=06 addr=00 07 value=00 2A + CRC  -> PDU = 06 00 07 00 2A
        var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
        await using var s = Canned(frame);
        var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
        pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
    }

    [Fact]
    public async Task ReadResponse_wrong_unit_address_throws_desync()
    {
        // CRC-valid FC03 frame addressed to unit 0x02, but we asked for 0x01 -> unit-mismatch desync.
        var frame = ModbusCrc.Append(new byte[] { 0x02, 0x03, 0x02, 0x00, 0x0A });
        await using var s = Canned(frame);
        var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
            ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
        ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.UnitMismatch);
    }

    [Fact]
    public async Task ReadResponse_FC03_deframes_correctly_when_delivered_in_dribbles()
    {
        // Same valid FC03 frame, but the stream hands back only 1-2 bytes per ReadAsync — proves
        // the ReadExactlyAsync accumulation loop reassembles a length-less frame across many reads.
        var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
        await using var s = new DribbleStream(frame, chunk: 2);
        var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
        pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
    }

    [Fact]
    public async Task ReadResponse_stream_ends_early_throws_desync()
    {
        // Only the first 4 bytes of a 7-byte FC03 frame are available, then EOF (ReadAsync returns 0)
        // -> the truncation path in ReadExactlyAsync must surface a desync, not hang or mis-parse.
        var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
        var truncated = frame[..4];
        await using var s = new DribbleStream(truncated, chunk: 2);
        var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
            ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
        ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
    }

    /// <summary>
    ///     A read-only test double that dribbles its backing bytes out at most <c>chunk</c> at a time
    ///     per <see cref="ReadAsync(Memory{byte}, CancellationToken)"/> call, then returns 0 (EOF)
    ///     once exhausted. Exercises the partial-read accumulation loop and the truncation → desync
    ///     path that a single-shot <see cref="MemoryStream"/> never reaches.
    /// </summary>
    private sealed class DribbleStream(byte[] data, int chunk) : Stream
    {
        private int _pos;

        public override int Read(byte[] buffer, int offset, int count)
        {
            var n = Math.Min(Math.Min(chunk, count), data.Length - _pos);
            if (n <= 0) return 0;
            Array.Copy(data, _pos, buffer, offset, n);
            _pos += n;
            return n;
        }

        public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
        {
            var n = Math.Min(Math.Min(chunk, buffer.Length), data.Length - _pos);
            if (n <= 0) return ValueTask.FromResult(0);
            data.AsSpan(_pos, n).CopyTo(buffer.Span);
            _pos += n;
            return ValueTask.FromResult(n);
        }

        public override bool CanRead => true;
        public override bool CanSeek => false;
        public override bool CanWrite => false;
        public override long Length => data.Length;
        public override long Position { get => _pos; set => throw new NotSupportedException(); }
        public override void Flush() { }
        public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
        public override void SetLength(long value) => throw new NotSupportedException();
        public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
    }
}
