using System.Buffers.Binary;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;

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

/// <summary>
///     Offline byte-level coverage for the managed FOCAS/2 wire-protocol decode layer
///     (<see cref="FocasWireProtocol"/>) — the big-endian framing + block-envelope decode that
///     is this driver's analogue of P/Invoke struct marshalling. Before this suite the entire
///     wire decode surface (including the <c>cnc_getfigure</c> figure stride) ran without a
///     single byte-level test; every other test drives the <c>IFocasClient</c> seam through the
///     Fake, which bypasses all framing. Recorded as Driver.FOCAS-013.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWireProtocolTests
{
    // ---- BuildPdu header layout ----

    [Fact]
    public void BuildPdu_writes_magic_version_type_direction_and_length()
    {
        var body = new byte[] { 0xde, 0xad, 0xbe, 0xef };
        var pdu = FocasWireProtocol.BuildPdu(
            FocasWireProtocol.TypeData, FocasWireProtocol.DirectionRequest, body);

        pdu.Length.ShouldBe(10 + body.Length);
        pdu[0].ShouldBe((byte)0xa0);
        pdu[1].ShouldBe((byte)0xa0);
        pdu[2].ShouldBe((byte)0xa0);
        pdu[3].ShouldBe((byte)0xa0);
        BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(4, 2)).ShouldBe(FocasWireProtocol.Version);
        pdu[6].ShouldBe(FocasWireProtocol.TypeData);
        pdu[7].ShouldBe(FocasWireProtocol.DirectionRequest);
        BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(8, 2)).ShouldBe((ushort)body.Length);
        pdu.AsSpan(10).ToArray().ShouldBe(body);
    }

    [Fact]
    public void BuildPdu_rejects_a_body_larger_than_ushort_max()
    {
        var tooBig = new byte[ushort.MaxValue + 1];
        Should.Throw<ArgumentOutOfRangeException>(() =>
            FocasWireProtocol.BuildPdu(FocasWireProtocol.TypeData, FocasWireProtocol.DirectionRequest, tooBig));
    }

    [Fact]
    public void BuildInitiateBody_writes_the_socket_index_big_endian()
    {
        var body = FocasWireProtocol.BuildInitiateBody(2);
        body.Length.ShouldBe(2);
        BinaryPrimitives.ReadUInt16BigEndian(body).ShouldBe((ushort)2);
    }

    // ---- BuildPdu -> ReadPduAsync round-trip over a real socket pair ----

    [Fact]
    public async Task BuildPdu_round_trips_through_ReadPduAsync()
    {
        var body = new byte[] { 1, 2, 3, 4, 5 };
        var pdu = FocasWireProtocol.BuildPdu(
            FocasWireProtocol.TypeData, FocasWireProtocol.DirectionResponse, body);

        var (client, server) = await ConnectedPairAsync();
        try
        {
            await server.GetStream().WriteAsync(pdu);
            var read = await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None);

            read.Type.ShouldBe(FocasWireProtocol.TypeData);
            read.Direction.ShouldBe(FocasWireProtocol.DirectionResponse);
            read.Body.ShouldBe(body);
        }
        finally { client.Dispose(); server.Dispose(); }
    }

    [Fact]
    public async Task ReadPduAsync_rejects_bad_magic()
    {
        var bad = new byte[10];
        bad[0] = 0x00; // not 0xa0
        var (client, server) = await ConnectedPairAsync();
        try
        {
            await server.GetStream().WriteAsync(bad);
            await Should.ThrowAsync<FocasWireException>(async () =>
                await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None));
        }
        finally { client.Dispose(); server.Dispose(); }
    }

    [Fact]
    public async Task ReadPduAsync_rejects_unsupported_version()
    {
        var header = new byte[10];
        new byte[] { 0xa0, 0xa0, 0xa0, 0xa0 }.CopyTo(header, 0);
        BinaryPrimitives.WriteUInt16BigEndian(header.AsSpan(4, 2), 99); // unsupported version
        var (client, server) = await ConnectedPairAsync();
        try
        {
            await server.GetStream().WriteAsync(header);
            await Should.ThrowAsync<FocasWireException>(async () =>
                await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None));
        }
        finally { client.Dispose(); server.Dispose(); }
    }

    // The 10-byte header framing is identical across supported versions (only the version field
    // differs) — older controls + the mock answer v1, modern controls answer v3 (FANUC 30i-B).
    // Validated live 2026-06-25; see docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
    [Theory]
    [InlineData((ushort)1)]
    [InlineData((ushort)3)]
    public async Task ReadPduAsync_accepts_supported_version(ushort version)
    {
        var body = new byte[] { 9, 8, 7 };
        var pdu = new byte[10 + body.Length];
        new byte[] { 0xa0, 0xa0, 0xa0, 0xa0 }.CopyTo(pdu, 0);
        BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(4, 2), version);
        pdu[6] = FocasWireProtocol.TypeData;
        pdu[7] = FocasWireProtocol.DirectionResponse;
        BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(8, 2), (ushort)body.Length);
        body.CopyTo(pdu.AsSpan(10));

        var (client, server) = await ConnectedPairAsync();
        try
        {
            await server.GetStream().WriteAsync(pdu);
            var read = await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None);

            read.Type.ShouldBe(FocasWireProtocol.TypeData);
            read.Direction.ShouldBe(FocasWireProtocol.DirectionResponse);
            read.Body.ShouldBe(body);
        }
        finally { client.Dispose(); server.Dispose(); }
    }

    // ---- BuildRequestBody framing ----

    [Fact]
    public void BuildRequestBody_prefixes_the_block_count_and_concatenates_blocks()
    {
        var blocks = new[]
        {
            new RequestBlock(0x0018, PathId: 1),
            new RequestBlock(0x0019, Arg1: 7, PathId: 1),
        };
        var body = FocasWireProtocol.BuildRequestBody(blocks);

        BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(0, 2)).ShouldBe((ushort)2);
        // First block sits right after the 2-byte count; its declared block length is at +0.
        var firstLen = BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(2, 2));
        firstLen.ShouldBe((ushort)0x1c); // no extra payload → header-only block
        BinaryPrimitives.ReadUInt16BigEndian(body.AsSpan(2 + 6, 2)).ShouldBe((ushort)0x0018); // command @ +6
    }

    // ---- ParseResponseBlocks: command / RC / payload extraction ----

    [Fact]
    public void ParseResponseBlocks_decodes_command_rc_and_payload()
    {
        var payload = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
        var body = BuildResponseBody(
            (command: (ushort)0x0018, rc: (short)0, payload: payload),
            (command: (ushort)0x0019, rc: (short)6 /* EW_NOOPT */, payload: Array.Empty<byte>()));

        var blocks = FocasWireProtocol.ParseResponseBlocks(body);

        blocks.Count.ShouldBe(2);
        blocks[0].Command.ShouldBe((ushort)0x0018);
        blocks[0].Rc.ShouldBe((short)0);
        blocks[0].Payload.ShouldBe(payload);
        blocks[1].Command.ShouldBe((ushort)0x0019);
        blocks[1].Rc.ShouldBe((short)6);
        blocks[1].Payload.ShouldBeEmpty();
    }

    [Fact]
    public void ParseResponseBlocks_returns_empty_for_a_body_shorter_than_the_count_field()
    {
        FocasWireProtocol.ParseResponseBlocks(new byte[] { 0x00 }).ShouldBeEmpty();
    }

    [Fact]
    public void ParseResponseBlocks_throws_on_a_truncated_block_length_field()
    {
        // count says 1 block, but only one stray byte follows the count.
        var body = new byte[] { 0x00, 0x01, 0x00 };
        Should.Throw<FocasWireException>(() => FocasWireProtocol.ParseResponseBlocks(body));
    }

    [Fact]
    public void ParseResponseBlocks_throws_when_the_declared_block_length_is_below_the_header()
    {
        var body = new byte[4];
        BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), 1);   // 1 block
        BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(2, 2), 0x08); // block length < 0x10
        Should.Throw<FocasWireException>(() => FocasWireProtocol.ParseResponseBlocks(body));
    }

    // ---- The cnc_getfigure figure stride: decode a response block into shorts ----

    [Fact]
    public void ParseResponseBlocks_yields_a_payload_that_decodes_as_a_sequence_of_figure_shorts()
    {
        // cnc_getfigure (command 0x00d3) returns one big-endian short per axis. Build the
        // response block and assert the payload decodes to the [3, 1, 0] figure sequence the
        // driver's auto-scale path reads.
        var figurePayload = new byte[6];
        BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(0, 2), 3);
        BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(2, 2), 1);
        BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(4, 2), 0);

        var body = BuildResponseBody((command: (ushort)0x00d3, rc: (short)0, payload: figurePayload));
        var block = FocasWireProtocol.ParseResponseBlocks(body).Single();

        var figures = new List<int>();
        for (var offset = 0; offset + 2 <= block.Payload.Length; offset += 2)
            figures.Add(BinaryPrimitives.ReadInt16BigEndian(block.Payload.AsSpan(offset, 2)));

        figures.ShouldBe(new[] { 3, 1, 0 });
    }

    // ---- ReadAscii / ReadNameRecord ----

    [Fact]
    public void ReadAscii_stops_at_the_first_NUL_and_trims_trailing_space()
    {
        var bytes = "30i \0junkafternul"u8.ToArray();
        FocasWireProtocol.ReadAscii(bytes).ShouldBe("30i");
    }

    [Fact]
    public void ReadAscii_trims_trailing_spaces_without_a_NUL()
    {
        FocasWireProtocol.ReadAscii("A1.0   "u8.ToArray()).ShouldBe("A1.0");
    }

    [Fact]
    public void ReadNameRecord_reads_two_bytes_and_strips_padding()
    {
        FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X");
        FocasWireProtocol.ReadNameRecord("XY"u8.ToArray()).ShouldBe("XY");
        FocasWireProtocol.ReadNameRecord(new byte[] { 0x58 }).ShouldBe(string.Empty); // < 2 bytes
    }

    // ---- helpers ----

    /// <summary>Build a response body in the 0x21 response-block shape ParseResponseBlocks expects.</summary>
    private static byte[] BuildResponseBody(params (ushort command, short rc, byte[] payload)[] blocks)
    {
        var encoded = blocks.Select(b =>
        {
            var blockLen = 0x10 + b.payload.Length;
            var block = new byte[blockLen];
            BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(0, 2), (ushort)blockLen);
            BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(6, 2), b.command);
            BinaryPrimitives.WriteInt16BigEndian(block.AsSpan(8, 2), b.rc);
            BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(14, 2), (ushort)b.payload.Length);
            b.payload.CopyTo(block.AsSpan(16));
            return block;
        }).ToArray();

        var body = new byte[2 + encoded.Sum(b => b.Length)];
        BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), (ushort)encoded.Length);
        var offset = 2;
        foreach (var block in encoded)
        {
            block.CopyTo(body.AsSpan(offset));
            offset += block.Length;
        }
        return body;
    }

    private static async Task<(TcpClient client, TcpClient server)> ConnectedPairAsync()
    {
        var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
        listener.Start();
        try
        {
            var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
            var client = new TcpClient();
            var connect = client.ConnectAsync(System.Net.IPAddress.Loopback, port);
            var server = await listener.AcceptTcpClientAsync();
            await connect;
            return (client, server);
        }
        finally { listener.Stop(); }
    }
}
