using System.Diagnostics;
using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs;
using AxisReadout = RioJoy.Core.Calibration.AxisReadout;
using RioJoy.Core;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;

namespace RioJoy.Core.Tests;

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

    private static async Task WaitUntilAsync(Func<bool> condition)
    {
        var sw = Stopwatch.StartNew();
        while (!condition())
        {
            if (sw.Elapsed > Timeout)
                throw new TimeoutException("Condition not met in time.");
            await Task.Delay(10);
        }
    }

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

        var map = new RioInputMap { [0x05] = new RioMapEntry(0x1009) }; // joystick button 9
        using var runtime = new RioRuntime(link, map, recorder, recorder);
        runtime.Start();

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

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

        await WaitUntilAsync(() => recorder.Snapshot().Contains("Joy(9,True)"));

        cts.Cancel();
        await run;
    }

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

        using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
        runtime.Start();

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

        // All axes at zero → joystick X/Y centered.
        fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));

        await WaitUntilAsync(() => recorder.Snapshot().Count(e => e.StartsWith("Axis(")) == 6);

        string[] log = recorder.Snapshot();
        Assert.Contains("Axis(X,16383)", log);
        Assert.Contains("Axis(Y,16383)", log);

        cts.Cancel();
        await run;
    }

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

        using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
        AxisReadout? seen = null;
        runtime.AxesUpdated += axes => seen = axes;
        runtime.Start();

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

        // All raw axes at zero → joystick X/Y centered, same values the sink receives;
        // both pedals at rest → pedal readouts empty.
        fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));

        await WaitUntilAsync(() => seen is not null);
        Assert.Equal(AxisOutputs.Center, seen!.Value.Axes.X);
        Assert.Equal(AxisOutputs.Center, seen.Value.Axes.Y);
        Assert.Equal(0, seen.Value.LeftPedal);
        Assert.Equal(0, seen.Value.RightPedal);

        cts.Cancel();
        await run;
    }

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

        var map = new RioInputMap { [0x05] = new RioMapEntry(0x9001) }; // lit joystick button
        using var runtime = new RioRuntime(link, map, recorder, recorder);
        runtime.Start();

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

        // Nothing is sent on Start: lamp commands would be dropped during the board's
        // post-reset boot, so no write happens until the board first replies.
        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(150)));

        // A reply proves the board is alive → the lit lamp is dimmed.
        fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));

        bool dimmed = false;
        for (int i = 0; i < 8 && !dimmed; i++)
        {
            byte[] w = await fake.NextWriteAsync(TimeSpan.FromSeconds(2));
            dimmed = w.Length >= 3 && w[0] == (byte)RioCommand.LampRequest && w[1] == 0x05;
        }
        Assert.True(dimmed, "expected a LampRequest dimming address 0x05 after the first reply");

        cts.Cancel();
        await run;
    }

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

        using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); // empty map
        var activity = new List<(int Address, bool Pressed)>();
        runtime.ButtonActivity += (addr, pressed) => activity.Add((addr, pressed));
        runtime.Start();

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

        fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
        fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x05 }));

        await WaitUntilAsync(() => activity.Count == 2);

        Assert.Equal((0x05, true), activity[0]);
        Assert.Equal((0x05, false), activity[1]);
        Assert.Empty(recorder.Snapshot()); // unmapped → no output routed, but activity still fired

        cts.Cancel();
        await run;
    }

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

        // Keypad 1, index 2 → address 0x62; map a keyboard key there (VK 0x41 = 'A').
        var map = new RioInputMap { [0x62] = new RioMapEntry(0x0041) };
        using var runtime = new RioRuntime(link, map, recorder, recorder);
        runtime.Start();

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

        fake.Enqueue(PacketBuilder.Build(RioCommand.KeyPressed, new byte[] { 1, 2 }));

        await WaitUntilAsync(() => recorder.Snapshot().Contains("KeyDown(0x41,ext=False)"));

        cts.Cancel();
        await run;
    }
}
