using VRio.Core.Device;
using VRio.Core.Protocol;
using Xunit;

namespace VRio.Core.Tests;

public class VRioDeviceTests
{
    /// <summary>Captures the device's transmissions and re-frames them for asserts.</summary>
    private sealed class Wire
    {
        private readonly PacketParser _parser = new();
        public readonly List<RioPacket> Packets = new();
        public readonly List<byte> Controls = new();

        public Wire(VRioDevice device) => device.Transmit += OnBytes;

        private void OnBytes(byte[] data)
        {
            foreach (byte b in data)
            {
                if (!_parser.Feed(b, out RioRxEvent ev))
                    continue;
                if (ev.Kind == RioRxKind.Packet)
                {
                    Assert.True(ev.ChecksumValid, $"device sent a bad checksum on {ev.Packet}");
                    Packets.Add(ev.Packet);
                }
                else if (ev.Kind == RioRxKind.ControlByte)
                {
                    Controls.Add(ev.Byte);
                }
            }
        }

        public void Clear()
        {
            Packets.Clear();
            Controls.Clear();
        }
    }

    private static void Send(VRioDevice device, byte[] bytes) => device.OnReceived(bytes, bytes.Length);

    [Fact]
    public void AnalogRequest_returns_current_axes_and_acks()
    {
        var device = new VRioDevice();
        var wire = new Wire(device);
        device.SetAxis(RioAxis.Throttle, 1000);
        device.SetAxis(RioAxis.JoystickY, 3000);
        device.SetAxis(RioAxis.JoystickX, -5000);

        Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));

        Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
        RioPacket reply = Assert.Single(wire.Packets);
        Assert.Equal(RioCommand.AnalogReply, reply.Command);

        byte[] p = reply.Payload;
        Assert.Equal(1000, AnalogCodec.Combine(p[0], p[1]));   // throttle
        Assert.Equal(0, AnalogCodec.Combine(p[2], p[3]));      // left pedal
        Assert.Equal(0, AnalogCodec.Combine(p[4], p[5]));      // right pedal
        Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7]));  // joystick Y (up is negative on the wire)
        Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9]));  // joystick X
        Assert.Equal(1, device.AnalogRequests);
    }

    [Fact]
    public void Joystick_Y_is_negated_on_the_wire_but_not_locally()
    {
        var device = new VRioDevice();
        var wire = new Wire(device);
        device.SetAxis(RioAxis.JoystickY, 3000);

        Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));

        byte[] p = Assert.Single(wire.Packets).Payload;
        Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7]));
        Assert.Equal(3000, device.GetAxis(RioAxis.JoystickY));      // the panel's dot is unflipped
        Assert.Equal(-3000, device.GetWireAxis(RioAxis.JoystickY)); // the panel's readout matches the wire

        // Full negative deflection: -Min is one past Max, so it clamps rather than throws.
        device.SetAxis(RioAxis.JoystickY, AnalogCodec.Min);
        wire.Clear();
        Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
        p = Assert.Single(wire.Packets).Payload;
        Assert.Equal(AnalogCodec.Max, AnalogCodec.Combine(p[6], p[7]));
    }

    [Fact]
    public void InvertJoystickY_sends_the_physical_direction_instead()
    {
        var device = new VRioDevice { InvertJoystickY = true };
        var wire = new Wire(device);
        device.SetAxis(RioAxis.JoystickY, 3000);

        Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));

        byte[] p = Assert.Single(wire.Packets).Payload;
        Assert.Equal(3000, AnalogCodec.Combine(p[6], p[7]));
        Assert.Equal(3000, device.GetWireAxis(RioAxis.JoystickY));
    }

    [Fact]
    public void VersionRequest_reports_configured_firmware()
    {
        var device = new VRioDevice { VersionMajor = 2, VersionMinor = 7 };
        var wire = new Wire(device);

        Send(device, PacketBuilder.Build(RioCommand.VersionRequest));

        RioPacket reply = Assert.Single(wire.Packets);
        Assert.Equal(RioCommand.VersionReply, reply.Command);
        Assert.Equal(new byte[] { 2, 7 }, reply.Payload);
    }

    [Fact]
    public void CheckRequest_enters_test_mode_reports_boards_then_exits()
    {
        var device = new VRioDevice();
        var wire = new Wire(device);

        Send(device, PacketBuilder.Build(RioCommand.CheckRequest));

        // The init handshake: TestModeChange ENTER, one CheckReply per board,
        // TestModeChange EXIT. The game waits on both test-mode packets and
        // stays mute forever if the EXIT never arrives.
        Assert.Equal(RioAddressSpace.Boards.Count + 2, wire.Packets.Count);

        RioPacket enter = wire.Packets[0];
        Assert.Equal(RioCommand.TestModeChange, enter.Command);
        Assert.Equal(new byte[] { 1 }, enter.Payload);

        RioPacket exit = wire.Packets[wire.Packets.Count - 1];
        Assert.Equal(RioCommand.TestModeChange, exit.Command);
        Assert.Equal(new byte[] { 0 }, exit.Payload);

        var checks = wire.Packets.GetRange(1, RioAddressSpace.Boards.Count);
        Assert.All(checks, p =>
        {
            Assert.Equal(RioCommand.CheckReply, p.Command);
            Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
        });
        Assert.Equal(
            RioAddressSpace.Boards.Select(b => b.Number),
            checks.Select(p => p.Payload[1]));
    }

    [Fact]
    public void LampRequest_updates_lamp_state_and_raises_event()
    {
        var device = new VRioDevice();
        _ = new Wire(device);
        (int Address, byte State)? change = null;
        device.LampChanged += (a, s) => change = (a, s);

        Send(device, PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x05, RioLampState.SolidBright }));

        Assert.Equal((0x05, RioLampState.SolidBright), change);
        Assert.Equal(RioLampState.SolidBright, device.GetLamp(0x05));
    }

    [Fact]
    public void ResetRequest_zeroes_the_targeted_axis()
    {
        var device = new VRioDevice();
        _ = new Wire(device);
        device.SetAxis(RioAxis.Throttle, 4000);
        device.SetAxis(RioAxis.JoystickY, -3000);

        Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
        Assert.Equal(0, device.GetAxis(RioAxis.Throttle));
        Assert.Equal(-3000, device.GetAxis(RioAxis.JoystickY)); // untouched

        Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
        Assert.Equal(0, device.GetAxis(RioAxis.JoystickY));
    }

    [Fact]
    public void Bad_checksum_gets_nak_and_no_reply()
    {
        var device = new VRioDevice();
        var wire = new Wire(device);

        byte[] bad = PacketBuilder.Build(RioCommand.AnalogRequest);
        bad[bad.Length - 1] ^= 0x01; // corrupt the checksum
        Send(device, bad);

        Assert.Equal((byte)RioControl.Nak, Assert.Single(wire.Controls));
        Assert.Empty(wire.Packets);
        Assert.Equal(1, device.BadChecksums);
    }

    [Fact]
    public void Button_press_and_release_use_button_packets()
    {
        var device = new VRioDevice();
        var wire = new Wire(device);

        device.PressAddress(0x3D);   // Panic
        device.ReleaseAddress(0x3D);

        Assert.Equal(2, wire.Packets.Count);
        Assert.Equal(RioCommand.ButtonPressed, wire.Packets[0].Command);
        Assert.Equal(new byte[] { 0x3D }, wire.Packets[0].Payload);
        Assert.Equal(RioCommand.ButtonReleased, wire.Packets[1].Command);
    }

    [Theory]
    [InlineData(0x51, 0, 0x1)]  // internal keypad "1"
    [InlineData(0x5F, 0, 0xF)]  // internal keypad "F"
    [InlineData(0x60, 1, 0x0)]  // external keypad "0"
    [InlineData(0x6C, 1, 0xC)]  // external keypad "C"
    public void Keypad_press_uses_key_packets_with_pad_and_index(int address, byte pad, byte index)
    {
        var device = new VRioDevice();
        var wire = new Wire(device);

        device.PressAddress(address);

        RioPacket packet = Assert.Single(wire.Packets);
        Assert.Equal(RioCommand.KeyPressed, packet.Command);
        Assert.Equal(new[] { pad, index }, packet.Payload);
    }

    [Fact]
    public void Nak_resends_four_times_then_gives_up_with_restart()
    {
        var device = new VRioDevice();
        var wire = new Wire(device);

        device.PressAddress(0x00);
        Assert.Single(wire.Packets);

        // The v4.2 firmware retry budget: 4 re-sends, then RESTART and give up.
        for (int i = 0; i < 4; i++)
            Send(device, new[] { (byte)RioControl.Nak });
        Assert.Equal(5, wire.Packets.Count);
        Assert.All(wire.Packets, p => Assert.Equal(RioCommand.ButtonPressed, p.Command));
        Assert.Empty(wire.Controls);

        Send(device, new[] { (byte)RioControl.Nak }); // budget exhausted
        Assert.Equal(5, wire.Packets.Count);
        Assert.Equal((byte)RioControl.Restart, Assert.Single(wire.Controls));

        // An ACK clears the pending event; a following NAK re-sends nothing.
        wire.Clear();
        device.PressAddress(0x01);
        Send(device, new[] { (byte)RioControl.Ack });
        Send(device, new[] { (byte)RioControl.Nak });
        Assert.Single(wire.Packets);
        Assert.Empty(wire.Controls);
    }

    [Fact]
    public void Wedge_emulation_mutes_analog_until_host_reset()
    {
        var device = new VRioDevice { EmulateReplyWedge = true };
        var wire = new Wire(device);

        // Exhaust the retry budget to trip the latch leak.
        device.PressAddress(0x00);
        for (int i = 0; i < 5; i++)
            Send(device, new[] { (byte)RioControl.Nak });
        Assert.True(device.AnalogWedged);
        wire.Clear();

        // Wedged: the request is ACK'd (RX path alive) but no reply comes back.
        Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
        Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
        Assert.Empty(wire.Packets);
        Assert.Equal(1, device.AnalogDropped);

        // The host's recovery reset clears the latch; analog replies resume.
        Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
        Assert.False(device.AnalogWedged);
        wire.Clear();
        Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
        Assert.Equal(RioCommand.AnalogReply, Assert.Single(wire.Packets).Command);
    }

    [Fact]
    public void WedgeAnalogNow_wedges_without_the_emulation_flag()
    {
        var device = new VRioDevice();
        var wire = new Wire(device);

        device.WedgeAnalogNow();
        Assert.True(device.AnalogWedged);

        Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
        Assert.Empty(wire.Packets);

        Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
        Assert.False(device.AnalogWedged);
    }

    [Fact]
    public void Axis_values_clamp_to_wire_range()
    {
        var device = new VRioDevice();
        device.SetAxis(RioAxis.Throttle, 60000);
        Assert.Equal(AnalogCodec.Max, device.GetAxis(RioAxis.Throttle));
        device.SetAxis(RioAxis.Throttle, -60000);
        Assert.Equal(AnalogCodec.Min, device.GetAxis(RioAxis.Throttle));
    }
}
