using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
using Xunit;

namespace RioJoy.Core.Tests.Serial;

public class RioSerialLinkTests
{
    private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);

    [Fact]
    public async Task AnalogReply_DecodesAndAcks()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });

        var gotAnalog = new TaskCompletionSource<AnalogReport>(TaskCreationOptions.RunContinuationsAsynchronously);
        link.AnalogReceived += r => gotAnalog.TrySetResult(r);

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        byte[] frame = PacketBuilder.Build(RioCommand.AnalogReply, new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 });
        fake.Enqueue(frame);

        AnalogReport report = await gotAnalog.Task.WaitAsync(Timeout);
        Assert.Equal(1, report.Throttle);
        Assert.Equal(5, report.JoystickX);

        // The link should have ACK'd the packet.
        byte[] reply = await fake.NextWriteAsync();
        Assert.Equal(new byte[] { (byte)RioControl.Ack }, reply);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task BadChecksumButton_IsNakd_WhenVerificationEnabled()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, new RioSerialLinkOptions
        {
            AutoPollAnalog = false,
            VerifyInboundChecksum = true,
        });

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
        frame[^1] ^= 0x01; // corrupt checksum
        fake.Enqueue(frame);

        byte[] reply = await fake.NextWriteAsync();
        Assert.Equal(new byte[] { (byte)RioControl.Nak }, reply);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task BadChecksumButton_IsAckd_WhenVerificationDisabled()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
        frame[^1] ^= 0x01;
        fake.Enqueue(frame);

        byte[] reply = await fake.NextWriteAsync();
        Assert.Equal(new byte[] { (byte)RioControl.Ack }, reply);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task VersionReply_DecodesAndRaisesEvent()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });

        var gotVersion = new TaskCompletionSource<VersionInfo>(TaskCreationOptions.RunContinuationsAsynchronously);
        link.VersionReceived += v => gotVersion.TrySetResult(v);

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        fake.Enqueue(PacketBuilder.Build(RioCommand.VersionReply, new byte[] { 0, 3 }));

        VersionInfo version = await gotVersion.Task.WaitAsync(Timeout);
        Assert.Equal(0, version.Major);
        Assert.Equal(3, version.Minor);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task ControlByte_RaisesControlReceived()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });

        var gotControl = new TaskCompletionSource<byte>(TaskCreationOptions.RunContinuationsAsynchronously);
        link.ControlReceived += b => gotControl.TrySetResult(b);

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        fake.Enqueue((byte)RioControl.Ack);

        byte control = await gotControl.Task.WaitAsync(Timeout);
        Assert.Equal((byte)RioControl.Ack, control);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task AutoPoll_SendsAnalogRequest()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, new RioSerialLinkOptions
        {
            AutoPollAnalog = true,
            AnalogPollInterval = TimeSpan.FromMilliseconds(20),
        });

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        byte[] firstWrite = await fake.NextWriteAsync();
        Assert.Equal(PacketBuilder.AnalogRequest(), firstWrite);

        cts.Cancel();
        await run;
    }

    // --- stop-and-wait command retransmit (CommandRetransmitLimit) -----------

    private static RioSerialLinkOptions StopAndWait(int limit = 2, int timeoutMs = 150) => new()
    {
        AutoPollAnalog = false,
        CommandRetransmitLimit = limit,
        AckTimeout = TimeSpan.FromMilliseconds(timeoutMs),
    };

    [Fact]
    public async Task Nak_RetransmitsTheSamePacket()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, StopAndWait());

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
        Task send = link.SendAsync(lamp);

        Assert.Equal(lamp, await fake.NextWriteAsync());
        fake.Enqueue((byte)RioControl.Nak);      // board: "that arrived corrupt"
        Assert.Equal(lamp, await fake.NextWriteAsync()); // exact same packet again
        fake.Enqueue((byte)RioControl.Ack);      // second copy lands

        await send.WaitAsync(Timeout);
        Assert.Equal(1, link.Retransmits);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task Timeout_RetransmitsWhenBoardNeverResponds()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, StopAndWait(limit: 2, timeoutMs: 80));

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
        Task send = link.SendAsync(lamp);

        // Original + 2 timeout-driven retries, then the command is dropped.
        Assert.Equal(lamp, await fake.NextWriteAsync());
        Assert.Equal(lamp, await fake.NextWriteAsync());
        Assert.Equal(lamp, await fake.NextWriteAsync());
        await send.WaitAsync(Timeout); // completes (dropped), doesn't hang
        Assert.Equal(2, link.Retransmits);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task Ack_CompletesWithoutRetransmit()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, StopAndWait());

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
        await fake.NextWriteAsync();
        fake.Enqueue((byte)RioControl.Ack);

        await send.WaitAsync(Timeout);
        Assert.Equal(0, link.Retransmits);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task Commands_AreSerialized_SecondWaitsForFirst()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, StopAndWait());

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        byte[] first = PacketBuilder.LampRequest(0x05, 0x02);
        byte[] second = PacketBuilder.LampRequest(0x06, 0x01);
        Task sendA = link.SendAsync(first);
        Task sendB = link.SendAsync(second);

        // Only the first is on the wire until it resolves.
        Assert.Equal(first, await fake.NextWriteAsync());
        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(100)));

        fake.Enqueue((byte)RioControl.Ack);
        Assert.Equal(second, await fake.NextWriteAsync()); // now B goes out
        fake.Enqueue((byte)RioControl.Ack);

        await sendA.WaitAsync(Timeout);
        await sendB.WaitAsync(Timeout);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task RequestReply_ResolvesTheInFlightCommand_WithoutExplicitAck()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, StopAndWait());

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        // An analog request answered by the reply alone (no ACK byte) must not
        // burn the ACK timeout — the reply proves the request landed.
        Task send = link.RequestAnalogAsync();
        await fake.NextWriteAsync();
        fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));

        await send.WaitAsync(TimeSpan.FromMilliseconds(120)); // well under AckTimeout
        Assert.Equal(0, link.Retransmits);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task AnalogReply_DoesNotResolve_PendingLampCommand()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, StopAndWait(limit: 1, timeoutMs: 250));

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        // Lamp command in flight; a straggler analog reply (from an earlier
        // poll) arrives. It must NOT confirm the lamp — the lamp keeps waiting
        // and is resolved only by its own ACK.
        Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
        await fake.NextWriteAsync();
        fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));

        await Task.Delay(100);
        Assert.False(send.IsCompleted, "stray analog reply must not resolve a lamp command");

        fake.Enqueue((byte)RioControl.Ack);
        await send.WaitAsync(Timeout);
        Assert.Equal(0, link.Retransmits);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task UnsolicitedNak_WithNothingPending_IsIgnored()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, StopAndWait());

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        // Inbound button packet -> our 1-byte ACK reply (bypasses the command
        // gate). A stray NAK afterwards must not resend anything.
        fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
        Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync());

        fake.Enqueue((byte)RioControl.Nak);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
        Assert.Equal(0, link.Retransmits);

        cts.Cancel();
        await run;
    }

    [Fact]
    public async Task Retransmit_Disabled_IsFireAndForget()
    {
        var fake = new FakeTransport();
        var link = new RioSerialLink(fake, StopAndWait(limit: 0));

        using var cts = new CancellationTokenSource();
        Task run = link.RunAsync(cts.Token);

        // Completes immediately (no ACK wait), and a NAK triggers nothing.
        await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)).WaitAsync(Timeout);
        await fake.NextWriteAsync();
        fake.Enqueue((byte)RioControl.Nak);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
        Assert.Equal(0, link.Retransmits);

        cts.Cancel();
        await run;
    }
}
