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

namespace DodoSSH.Client.Ssh.Tests;

/// <summary>
/// A real SSH session, through the real data plane, to a stand-in renderer.
/// </summary>
/// <remarks>
/// <para>
/// Everything the desktop client does when a user opens a terminal, minus the pixels: a real
/// <c>sshd</c> in a container, a real pseudo-terminal, the real loopback WebSocket with its token and
/// origin checks, and a <see cref="ClientWebSocket"/> standing in for the page. If a shell prompt
/// arrives here and typed input round-trips, the only untested link left is xterm drawing bytes it was
/// handed.
/// </para>
/// <para>
/// Worth having because the alternative is driving a GUI. The WebView's own participation is
/// verifiable separately — it opens a TCP connection to this same port — but that says nothing about
/// whether an SSH session's output reaches it.
/// </para>
/// </remarks>
[Collection(SshCollection.Name)]
public sealed class TerminalEndToEndTests(SshServerFixture fixture)
{
    private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);

    /// <remarks>
    /// The real page is an Avalonia resource in the app project. This test stands in for the renderer
    /// itself, so a placeholder-bearing stub is all the transport needs.
    /// </remarks>
    private static InMemoryTerminalAssetProvider StubAssets() =>
        new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
        {
            [TerminalDataPlane.PagePath] = new(
                "text/html; charset=utf-8",
                Encoding.UTF8.GetBytes(
                    $"<html data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
                    + $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></html>")),
        });

    [Fact]
    public async Task AShellSessionReachesTheRenderer_AndInputReachesTheRemote()
    {
        var knownHosts = new InMemoryKnownHostStore();

        await using var workspace = new TerminalWorkspace(
            StubAssets(),
            new SshNetConnectionFactory(knownHosts),
            TimeProvider.System);

        workspace.Start();

        // The token comes from the served page, exactly as the real renderer obtains it.
        var token = await ReadTokenAsync(workspace.PageUrl);

        using var renderer = await AttachAsync(workspace.PageUrl, token);
        await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);

        var sessionId = await OpenTrustedSessionAsync(workspace, knownHosts);

        // SessionOpened tells the renderer to create a terminal before any output arrives for it.
        var opened = await ReceiveAsync(renderer);
        opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
        opened.SessionId.ShouldBe(sessionId);

        // The login banner and prompt arrive unprompted, acknowledged as the page does from
        // term.write's callback.
        var banner = await ReadOutputUntilAsync(renderer, sessionId, "$", acknowledge: true);
        banner.ShouldContain("OpenSSH");

        // Marker split so the PTY's echo of the command line does not satisfy the match.
        await SendAsync(
            renderer,
            sessionId,
            (byte)TerminalClientOpcode.Input,
            Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"; stty size\n"));

        var output = await ReadOutputUntilAsync(renderer, sessionId, "DODOSSH-OK", acknowledge: true);

        output.ShouldContain("DODOSSH-OK");

        // The size requested when the session opened is the size the remote sees, which means the
        // pty-req carried it rather than the terminal silently defaulting to 80x24.
        output.Replace('\r', '\n').ShouldContain("30 100");

        await workspace.CloseSessionAsync(sessionId);
    }

    // ---- Helpers ----

    /// <summary>
    /// Trusts the container's host key, then opens a session.
    /// </summary>
    /// <remarks>
    /// The refused first attempt is part of the assertion, not setup noise: a host with no pinned key
    /// must not connect, and the fingerprint the user would be shown has to be in the exception.
    /// </remarks>
    private async Task<uint> OpenTrustedSessionAsync(
        TerminalWorkspace workspace,
        InMemoryKnownHostStore knownHosts)
    {
        var request = new SshConnectionRequest(
            fixture.Host,
            fixture.Port,
            SshServerFixture.Username,
            new SshPasswordCredential(SshServerFixture.Password));

        var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
            await workspace.OpenSessionAsync(
                request, TerminalSize.Default, progress: null, TestContext.Current.CancellationToken));

        unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);

        await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);

        return await workspace.OpenSessionAsync(
            request,
            new TerminalSize(100, 30, 1000, 750),
            progress: null,
            TestContext.Current.CancellationToken);
    }

    private static async Task<string> ReadTokenAsync(Uri pageUrl)
    {
        using var client = new HttpClient();
        var page = await client.GetStringAsync(pageUrl, TestContext.Current.CancellationToken);

        const string Marker = "data-token=\"";
        var start = page.IndexOf(Marker, StringComparison.Ordinal) + Marker.Length;
        var end = page.IndexOf('"', start);

        return page[start..end];
    }

    private static async Task<ClientWebSocket> AttachAsync(Uri pageUrl, string token)
    {
        var socket = new ClientWebSocket();

        socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
        socket.Options.AddSubProtocol($"token.{token}");
        socket.Options.SetRequestHeader(
            "Origin",
            string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{pageUrl.Port}"));

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

        return socket;
    }

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

    /// <remarks>
    /// Bounded by its own timeout rather than relying on a caller's deadline. A blocking receive is
    /// where a missing frame turns into a hung test run instead of a failure with a message, and a hang
    /// tells you nothing about which frame never came.
    /// </remarks>
    private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync(
        ClientWebSocket socket)
    {
        var buffer = new byte[256 * 1024];

        using var deadline = new CancellationTokenSource(Timeout);
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(
            deadline.Token,
            TestContext.Current.CancellationToken);

        WebSocketReceiveResult result;
        try
        {
            result = await socket.ReceiveAsync(buffer, linked.Token);
        }
        catch (OperationCanceledException) when (deadline.IsCancellationRequested)
        {
            throw new TimeoutException($"No terminal frame arrived within {Timeout}.");
        }

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

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

    /// <summary>Reads output frames until the text appears, acknowledging each as the page does.</summary>
    private static async Task<string> ReadOutputUntilAsync(
        ClientWebSocket socket,
        uint sessionId,
        string expected,
        bool acknowledge)
    {
        var accumulated = new StringBuilder();
        var deadline = TimeProvider.System.GetUtcNow() + Timeout;

        while (TimeProvider.System.GetUtcNow() < deadline)
        {
            var frame = await ReceiveAsync(socket);

            if (frame.Opcode == (byte)TerminalServerOpcode.SessionClosed)
            {
                throw new InvalidOperationException(
                    $"The session closed before '{expected}' arrived: "
                    + $"{Encoding.UTF8.GetString(frame.Payload)}\nSeen so far:\n{accumulated}");
            }

            if (frame.Opcode != (byte)TerminalServerOpcode.Output)
            {
                continue;
            }

            if (acknowledge)
            {
                // Returning credit is what keeps the pump reading. Without it the session stalls at
                // the window size and this loop would time out on a working implementation.
                await SendAsync(
                    socket,
                    sessionId,
                    (byte)TerminalClientOpcode.Acknowledge,
                    TerminalFrame.CreateAcknowledgementPayload((uint)frame.Payload.Length));
            }

            accumulated.Append(Encoding.UTF8.GetString(frame.Payload));

            if (accumulated.ToString().Contains(expected, StringComparison.Ordinal))
            {
                return accumulated.ToString();
            }
        }

        throw new TimeoutException($"'{expected}' did not arrive within {Timeout}.\n{accumulated}");
    }
}
