using System.Globalization;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Ssh;

namespace DodoSSH.Client.Terminal.Tests;

/// <summary>
/// The loopback transport end to end, with a real WebSocket client.
/// </summary>
/// <remarks>
/// Uses <see cref="ClientWebSocket"/> against the real listener rather than a stubbed transport,
/// because the hand-rolled HTTP upgrade is the part that could be subtly wrong — and a handshake that
/// no real client accepts would pass any test that skipped it.
/// </remarks>
public sealed class TerminalDataPlaneTests : IAsyncDisposable
{
    private const uint SessionId = 3;

    private static readonly byte[] PageTemplate = Encoding.UTF8.GetBytes(
        $"<html><body data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
        + $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></body></html>");

    private readonly TerminalDataPlane plane = new(new InMemoryTerminalAssetProvider(
        new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
        {
            [TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", PageTemplate),
            ["/xterm.js"] = new("text/javascript; charset=utf-8", "console.log(1)"u8.ToArray()),
        }));

    private readonly CancellationTokenSource lifetime = new();
    private Task? server;

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        await lifetime.CancelAsync();
        await plane.DisposeAsync();

        if (server is not null)
        {
            try
            {
                await server;
            }
            catch (OperationCanceledException)
            {
                // Expected: the accept loop is stopped by cancelling it.
            }
        }

        lifetime.Dispose();
    }

    // ---- Serving the page ----

    [Fact]
    public async Task ThePage_IsServedWithTheTokenAndSocketUrlSubstituted()
    {
        // Substituted at serve time rather than written into the file, so the token never touches disk
        // and never appears in a URL that a log or a browser history could keep.
        Start();

        using var client = new HttpClient();
        var body = await client.GetStringAsync(plane.PageUrl, TestContext.Current.CancellationToken);

        body.ShouldContain(plane.Token);
        body.ShouldContain(
            string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{plane.Port}/socket"));

        body.ShouldNotContain(TerminalDataPlane.TokenPlaceholder);
        body.ShouldNotContain(TerminalDataPlane.SocketUrlPlaceholder);
    }

    [Fact]
    public async Task ThePage_IsNotCached()
    {
        // It carries a connection token.
        Start();

        using var client = new HttpClient();
        using var response = await client.GetAsync(plane.PageUrl, TestContext.Current.CancellationToken);

        response.Headers.CacheControl!.NoStore.ShouldBeTrue();
    }

    [Fact]
    public async Task AnUnknownPath_Is404()
    {
        Start();

        using var client = new HttpClient();
        using var response = await client.GetAsync(
            new Uri($"http://127.0.0.1:{plane.Port}/nope"),
            TestContext.Current.CancellationToken);

        response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
    }

    // ---- Attaching ----

    [Fact]
    public async Task TheRenderer_Attaches()
    {
        Start();

        using var socket = await ConnectAsync();

        socket.State.ShouldBe(WebSocketState.Open);
        socket.SubProtocol.ShouldBe(TerminalDataPlane.SubProtocol);

        await plane.RendererAttached;
    }

    [Fact]
    public async Task AConnectionWithoutTheToken_IsRejected()
    {
        Start();

        await Should.ThrowAsync<WebSocketException>(async () =>
            await ConnectAsync(token: null));
    }

    [Fact]
    public async Task AConnectionWithTheWrongToken_IsRejected()
    {
        Start();

        await Should.ThrowAsync<WebSocketException>(async () =>
            await ConnectAsync(token: "not-the-token"));
    }

    [Fact]
    public async Task AConnectionFromAnotherOrigin_IsRejected()
    {
        // The threat this actually addresses: a page open in the user's browser can attempt WebSocket
        // connections to loopback ports, and would otherwise reach a terminal.
        Start();

        await Should.ThrowAsync<WebSocketException>(async () =>
            await ConnectAsync(origin: "https://evil.example"));
    }

    [Fact]
    public async Task ASecondRenderer_IsRejected()
    {
        Start();

        using var first = await ConnectAsync();
        first.State.ShouldBe(WebSocketState.Open);

        await Should.ThrowAsync<WebSocketException>(async () => await ConnectAsync());
    }

    // ---- Frames ----

    [Fact]
    public async Task Output_ReachesTheRenderer()
    {
        Start();

        using var socket = await ConnectAsync();

        await using var session = new FakeShellSession(bytesToProduce: 128);
        await using var pump = CreatePump(session);
        plane.Register(SessionId, pump);

        var run = pump.RunAsync(TestContext.Current.CancellationToken);

        // SessionOpened first, then the output.
        var opened = await ReceiveAsync(socket);
        opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
        opened.SessionId.ShouldBe(SessionId);

        var output = await ReceiveAsync(socket);
        output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
        output.Payload.Length.ShouldBe(128);

        // Acknowledge it, exactly as the page does from term.write's callback.
        await SendAsync(
            socket,
            (byte)TerminalClientOpcode.Acknowledge,
            TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));

        await run;
    }

    [Fact]
    public async Task Input_ReachesTheRemote()
    {
        Start();

        using var socket = await ConnectAsync();

        await using var session = new FakeShellSession();
        await using var pump = CreatePump(session);
        plane.Register(SessionId, pump);

        await SendAsync(socket, (byte)TerminalClientOpcode.Input, "whoami\r"u8.ToArray());

        await WaitUntilAsync(() => session.Written.Length > 0);

        Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r");
    }

    [Fact]
    public async Task AResize_ReachesTheRemote()
    {
        Start();

        using var socket = await ConnectAsync();

        await using var session = new FakeShellSession();
        await using var pump = CreatePump(session);
        plane.Register(SessionId, pump);

        await SendAsync(
            socket,
            (byte)TerminalClientOpcode.Resize,
            TerminalFrame.CreateResizePayload(new TerminalSize(132, 43, 1320, 1075)));

        await WaitUntilAsync(() => session.ResizeCount > 0);

        session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075));
    }

    [Fact]
    public async Task AnAcknowledgement_ReturnsCredit()
    {
        Start();

        using var socket = await ConnectAsync();

        await using var session = new FakeShellSession();
        await using var pump = CreatePump(session);
        plane.Register(SessionId, pump);

        pump.Credits.TryReserve(1024);
        pump.Credits.Outstanding.ShouldBe(1024);

        await SendAsync(
            socket,
            (byte)TerminalClientOpcode.Acknowledge,
            TerminalFrame.CreateAcknowledgementPayload(1024));

        await WaitUntilAsync(() => pump.Credits.Outstanding == 0);
    }

    [Fact]
    public async Task AFrameForAnUnregisteredSession_IsIgnored()
    {
        // Ordinary during teardown: the page can still have frames in flight for a session that just
        // ended. Dropping them must not disturb anything else.
        Start();

        using var socket = await ConnectAsync();

        await using var session = new FakeShellSession();
        await using var pump = CreatePump(session);
        plane.Register(SessionId, pump);

        // A session id that is never registered. Using the registered one and relying on ordering
        // would be a race: frames are dispatched on the receive loop, so the orphan can land after
        // registration and legitimately be delivered.
        await SendAsync(socket, (byte)TerminalClientOpcode.Input, "orphan"u8.ToArray(), sessionId: 999);

        await SendAsync(socket, (byte)TerminalClientOpcode.Input, "real"u8.ToArray());
        await WaitUntilAsync(() => session.Written.Length > 0);

        Encoding.UTF8.GetString(session.Written).ShouldBe("real");
    }

    [Fact]
    public async Task AMalformedFrame_IsIgnored()
    {
        Start();

        using var socket = await ConnectAsync();

        // Shorter than the header.
        await socket.SendAsync(
            new byte[] { 1, 2 },
            WebSocketMessageType.Binary,
            endOfMessage: true,
            TestContext.Current.CancellationToken);

        await using var session = new FakeShellSession();
        await using var pump = CreatePump(session);
        plane.Register(SessionId, pump);

        await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray());
        await WaitUntilAsync(() => session.Written.Length > 0);

        Encoding.UTF8.GetString(session.Written).ShouldBe("still here");
    }

    // ---- Helpers ----

    private void Start() => server = plane.RunAsync(lifetime.Token);

    private TerminalSessionPump CreatePump(FakeShellSession session) =>
        new(
            SessionId,
            session,
            plane,
            TimeProvider.System,
            new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });

    private async Task<ClientWebSocket> ConnectAsync(
        string? token = "",
        string? origin = null)
    {
        var socket = new ClientWebSocket();

        socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);

        if (token is not null)
        {
            socket.Options.AddSubProtocol($"token.{(token.Length == 0 ? plane.Token : token)}");
        }

        socket.Options.SetRequestHeader(
            "Origin",
            origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}"));

        try
        {
            await socket.ConnectAsync(
                new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"),
                TestContext.Current.CancellationToken);
        }
        catch
        {
            socket.Dispose();
            throw;
        }

        return socket;
    }

    private static async Task SendAsync(
        ClientWebSocket socket,
        byte opcode,
        byte[] payload,
        uint sessionId = SessionId) =>
        await socket.SendAsync(
            TerminalFrame.Create(opcode, sessionId, payload),
            WebSocketMessageType.Binary,
            endOfMessage: true,
            TestContext.Current.CancellationToken);

    private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync(
        ClientWebSocket socket)
    {
        var buffer = new byte[64 * 1024];

        var result = await socket.ReceiveAsync(
            buffer.AsMemory(),
            TestContext.Current.CancellationToken);

        TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
            .ShouldBeTrue();

        return (opcode, sessionId, payload.ToArray());
    }

    /// <remarks>
    /// Polls rather than awaiting a signal: inbound frames are dispatched on the transport's receive
    /// loop, which has no completion for the caller to await. The bound keeps a genuine failure a
    /// failure rather than a hang.
    /// </remarks>
    private static async Task WaitUntilAsync(Func<bool> condition)
    {
        var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(5);

        while (TimeProvider.System.GetUtcNow() < deadline)
        {
            if (condition())
            {
                return;
            }

            await Task.Delay(10, TestContext.Current.CancellationToken);
        }

        throw new TimeoutException("The expected state was not reached within 5 seconds.");
    }
}
