using System.Buffers.Text;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;

namespace DodoSSH.Client.Terminal;

/// <summary>
/// Serves the renderer page and carries terminal frames, over one loopback socket.
/// </summary>
/// <remarks>
/// <para>
/// The data plane is a socket rather than the WebView's JavaScript bridge. The bridge is UI-thread
/// bound string evaluation with no backpressure signal, which at terminal throughput means thousands
/// of script evaluations per second on the thread that also has to paint. A socket gives ordering,
/// binary payloads and flow control for free.
/// </para>
/// <para>
/// The page is served from the same listener as the socket, which is what makes the <c>Origin</c>
/// header predictable — it is always <c>http://127.0.0.1:{port}</c>. Loading the page through a
/// WebView virtual-host mapping instead would produce a different origin on each backend and give
/// nothing to validate against.
/// </para>
/// <para>
/// <b>What the token and origin check actually defend against.</b> Not a hostile process running as
/// the same user: that process can already read this one's memory, so nothing here is a boundary
/// against it. They defend against a web page in the user's browser, which can and does attempt
/// WebSocket connections to loopback ports, and against a second copy of the application
/// accidentally attaching to the wrong terminal. Both are real; neither is stopped by the socket
/// being loopback alone.
/// </para>
/// </remarks>
public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
{
    /// <summary>Subprotocol the renderer must request.</summary>
    public const string SubProtocol = "dodossh.terminal.v1";

    /// <summary>Path the renderer page is served from.</summary>
    public const string PagePath = "/terminal";

    /// <summary>Path the WebSocket upgrade is accepted on.</summary>
    public const string SocketPath = "/socket";

    /// <summary>Placeholder in the page that is replaced with the connection token.</summary>
    public const string TokenPlaceholder = "__DODOSSH_TOKEN__";

    /// <summary>Placeholder in the page that is replaced with the socket URL.</summary>
    public const string SocketUrlPlaceholder = "__DODOSSH_SOCKET__";

    /// <summary>RFC 6455 §1.3: the fixed GUID mixed into the handshake response.</summary>
    private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

    private const int MaximumRequestBytes = 16 * 1024;
    private const int ReceiveBufferBytes = 64 * 1024;

    private readonly TcpListener listener;
    private readonly ITerminalAssetProvider assets;
    private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
    private readonly Lock pumpGate = new();
    private readonly SemaphoreSlim sendGate = new(1, 1);
    private readonly CancellationTokenSource lifetime = new();
    private readonly TaskCompletionSource rendererAttached =
        new(TaskCreationOptions.RunContinuationsAsynchronously);

    private WebSocket? socket;
    private int disposed;

    /// <param name="assets">Where the renderer's files come from.</param>
    public TerminalDataPlane(ITerminalAssetProvider assets)
    {
        ArgumentNullException.ThrowIfNull(assets);

        this.assets = assets;

        Token = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));

        listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();

        Port = ((IPEndPoint)listener.LocalEndpoint).Port;
    }

    /// <summary>The port the OS assigned.</summary>
    public int Port { get; }

    /// <summary>The single-use connection token embedded in the served page.</summary>
    public string Token { get; }

    /// <summary>Where the WebView should navigate.</summary>
    public Uri PageUrl => new(
        string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{PagePath}"),
        UriKind.Absolute);

    /// <summary>Completes once the renderer has attached its socket.</summary>
    public Task RendererAttached => rendererAttached.Task;

    /// <summary>Raised when the page asks for a different font size.</summary>
    /// <remarks>
    /// Raised on the socket's receive loop rather than any UI thread, so a handler that touches view models
    /// has to marshal. <see cref="TerminalWorkspace"/> forwards it as it arrives and leaves that to the
    /// shell, which is where the thread affinity is known.
    /// </remarks>
    public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;

    /// <summary>
    /// Raised after a socket attaches — the first one, and every later takeover.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Raised after <see cref="socket"/> has been swapped in but before <see cref="ReceiveLoopAsync"/> starts
    /// consuming it, on the socket-accept thread — the same thread that is in the middle of
    /// <see cref="UpgradeAsync"/> for this connection. <see cref="TerminalWorkspace"/> is this event's one
    /// subscriber, and it uses the ordering to replay session state before anything the fresh page sends
    /// (a resize, an early acknowledgement) can be dispatched; see its remark for why the two racing is
    /// harmless regardless.
    /// </para>
    /// <para>
    /// Unlike <see cref="RendererAttached"/>, which resolves once and answers "has a renderer ever attached"
    /// for <see cref="TerminalWorkspace.WaitForRendererAsync"/>, this fires every time — because a takeover
    /// is exactly the case <see cref="RendererAttached"/> was never meant to describe again.
    /// </para>
    /// </remarks>
    public event EventHandler? SocketAttached;

    /// <summary>Registers a session so inbound frames can be routed to it.</summary>
    public void Register(uint sessionId, TerminalSessionPump pump)
    {
        ArgumentNullException.ThrowIfNull(pump);

        lock (pumpGate)
        {
            pumps[sessionId] = pump;
        }
    }

    /// <summary>Forgets a session that has ended.</summary>
    public void Unregister(uint sessionId)
    {
        lock (pumpGate)
        {
            pumps.Remove(sessionId);
        }
    }

    /// <summary>Accepts connections until disposed.</summary>
    public async Task RunAsync(CancellationToken cancellationToken)
    {
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken,
            lifetime.Token);

        while (!linked.Token.IsCancellationRequested)
        {
            TcpClient client;
            try
            {
                client = await listener.AcceptTcpClientAsync(linked.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                return;
            }

            // Each connection on its own task, and deliberately not awaited. An upgraded WebSocket
            // lives for the whole session, so handling connections in sequence would leave the accept
            // loop parked inside the receive loop and every later request unanswered — the page's
            // script and stylesheet among them. Two upgrades racing each other need no coordination
            // here either, because the takeover in UpgradeAsync swaps the shared socket field with an
            // interlocked exchange rather than assuming it is the only writer.
            _ = HandleConnectionAsync(client, linked.Token);
        }
    }

    private async Task HandleConnectionAsync(TcpClient client, CancellationToken cancellationToken)
    {
        try
        {
            await HandleAsync(client, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception exception)
            when (exception is IOException or SocketException or WebSocketException
                or OperationCanceledException or ObjectDisposedException)
        {
            // A renderer that went away mid-handshake, or a shutdown in progress. Ordinary.
        }
        finally
        {
            client.Dispose();
        }
    }

    /// <inheritdoc />
    public async ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
    {
        var current = socket;
        if (current is null || current.State != WebSocketState.Open)
        {
            // Dropped rather than queued. A terminal whose renderer has gone has nothing to catch up
            // on, and buffering for one that may never return is the unbounded growth the credit
            // window exists to prevent.
            return;
        }

        // WebSocket forbids concurrent sends, and every session shares this one socket.
        await sendGate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            await current
                .SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
                .ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            // Not a cancellation despite the type: .NET's ManagedWebSocket wraps a send that fails because
            // the underlying connection is already gone — which is exactly what a killed renderer's socket
            // looks like — in an OperationCanceledException of its own manufacture, regardless of whether
            // anyone actually cancelled anything. The filter is what tells the two apart: if the caller's
            // own token were the cause, IsCancellationRequested would be true here and this catch does not
            // apply, so a real cancellation still propagates. Everything below about why this must not
            // fault the caller applies here exactly as it does to the exception types in the next catch.
        }
        catch (Exception exception)
            when (exception is WebSocketException or ObjectDisposedException
                or InvalidOperationException or IOException)
        {
            // The state check above is not atomic with the send, and a WebView renderer process killed by
            // Android leaves its socket reporting Open long after nobody is reading from the other end. This
            // has to read as "nobody listening" — the same as the no-socket case above — and never as a
            // fault: SendAsync is called from TerminalSessionPump.SendOutputAsync inside the flush loop, and
            // letting this exception escape would fault that loop. A faulted flush loop stops draining the
            // credit window, the reader blocks once it fills, and the SSH session behind it freezes for good
            // while LiveSessionCount still counts it as running. A dropped frame is recoverable — a frozen
            // session is not.
        }
        finally
        {
            sendGate.Release();
        }
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref disposed, 1) == 1)
        {
            return;
        }

        await lifetime.CancelAsync().ConfigureAwait(false);
        rendererAttached.TrySetCanceled();

        listener.Dispose();
        socket?.Dispose();
        sendGate.Dispose();
        lifetime.Dispose();
    }

    private async Task HandleAsync(TcpClient client, CancellationToken cancellationToken)
    {
        var stream = client.GetStream();
        await using var streamScope = stream.ConfigureAwait(false);

        var request = await ReadRequestAsync(stream, cancellationToken).ConfigureAwait(false);
        if (request is null)
        {
            return;
        }

        if (string.Equals(request.Path, SocketPath, StringComparison.Ordinal))
        {
            await UpgradeAsync(stream, request, cancellationToken).ConfigureAwait(false);
            return;
        }

        await ServeAssetAsync(stream, request.Path, cancellationToken).ConfigureAwait(false);
    }

    private async Task ServeAssetAsync(Stream stream, string path, CancellationToken cancellationToken)
    {
        var asset = assets.Find(path);

        if (asset is null)
        {
            await WriteResponseAsync(stream, "404 Not Found", "text/plain", "Not found"u8.ToArray(), cancellationToken)
                .ConfigureAwait(false);
            return;
        }

        var content = asset.Content;

        if (string.Equals(path, PagePath, StringComparison.Ordinal))
        {
            // The token and socket URL are substituted at serve time rather than being written into
            // the file, so the token never touches disk and never appears in a URL that could reach a
            // log or a browser history.
            var text = Encoding.UTF8.GetString(content)
                .Replace(TokenPlaceholder, Token, StringComparison.Ordinal)
                .Replace(
                    SocketUrlPlaceholder,
                    string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{Port}{SocketPath}"),
                    StringComparison.Ordinal);

            content = Encoding.UTF8.GetBytes(text);
        }

        await WriteResponseAsync(stream, "200 OK", asset.ContentType, content, cancellationToken)
            .ConfigureAwait(false);
    }

    /// <remarks>
    /// <para>
    /// <b>Takeover, not rejection.</b> A valid upgrade always wins the socket, even when one is already
    /// attached — the old socket is aborted and the newcomer takes its place. Refusing a second attach used
    /// to be the rule, on the theory that one renderer lives for the whole process. That is WebView2's
    /// truth and not Android's: the platform kills the WebView's renderer process under memory pressure or
    /// simply for being backgrounded, the page reloads, and the reload's socket is a second valid upgrade —
    /// refusing it left the terminal permanently unreachable with no way back short of restarting the app.
    /// </para>
    /// <para>
    /// Refusing protects nothing here anyway: only our own page knows the token (see the type-level remark
    /// on what the token defends against), so a second valid upgrade <em>is</em> our page, reattaching.
    /// Waiting for the old socket to notice it is dead and close on its own is not a safer alternative
    /// either — a killed renderer process sends no TCP FIN, so the old receive loop can sit unaware for the
    /// whole 30-second keepalive interval, and every reload landing in that window would still find the
    /// door held shut by a socket nobody is on the other end of. Taking over immediately is what makes a
    /// reload actually reattach.
    /// </para>
    /// </remarks>
    private async Task UpgradeAsync(
        Stream stream,
        HttpRequestLine request,
        CancellationToken cancellationToken)
    {
        if (!IsAcceptableUpgrade(request))
        {
            await WriteResponseAsync(
                    stream, "403 Forbidden", "text/plain", "Rejected"u8.ToArray(), cancellationToken)
                .ConfigureAwait(false);
            return;
        }

        var key = request.Headers.GetValueOrDefault("sec-websocket-key")!;
        var accept = ComputeHandshakeAccept(key);

        var handshake =
            "HTTP/1.1 101 Switching Protocols\r\n"
            + "Upgrade: websocket\r\n"
            + "Connection: Upgrade\r\n"
            + "Sec-WebSocket-Accept: " + accept + "\r\n"
            + "Sec-WebSocket-Protocol: " + SubProtocol + "\r\n\r\n";

        await stream.WriteAsync(Encoding.ASCII.GetBytes(handshake), cancellationToken).ConfigureAwait(false);
        await stream.FlushAsync(cancellationToken).ConfigureAwait(false);

        // The handshake is ours; the framing is the BCL's. Hand-rolling masking, fragmentation and
        // control frames would be a great deal of code for no gain.
        using var webSocket = WebSocket.CreateFromStream(
            stream,
            new WebSocketCreationOptions
            {
                IsServer = true,
                SubProtocol = SubProtocol,
                KeepAliveInterval = TimeSpan.FromSeconds(30),
            });

        // Whatever was attached before is displaced, not merely overwritten: Exchange hands back the old
        // reference so it can be aborted rather than left to linger as a socket nothing reads from again.
        // Abort rather than a graceful close — a close frame would wait on a peer that, per the remark
        // above, may never notice it should reply, and the newcomer already proved it is our page.
        var previous = Interlocked.Exchange(ref socket, webSocket);
        previous?.Abort();

        rendererAttached.TrySetResult();
        SocketAttached?.Invoke(this, EventArgs.Empty);

        try
        {
            await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
        }
        finally
        {
            // Cleared only if the field still holds this connection's own socket. A takeover has already
            // swapped in a newer one by the time an aborted receive loop unwinds to here, and clearing the
            // field regardless would race the newcomer: whichever of the two finished last would win, and
            // it must always be the newcomer, never this one going away.
            Interlocked.CompareExchange(ref socket, null, webSocket);
        }
    }

    /// <remarks>
    /// The origin is checked because a page in the user's browser can attempt a WebSocket connection to
    /// a loopback port, and the token because that page could otherwise simply guess the path. Neither
    /// defends against a process running as this user; that one can read our memory regardless.
    /// </remarks>
    private bool IsAcceptableUpgrade(HttpRequestLine request)
    {
        var origin = request.Headers.GetValueOrDefault("origin");
        var expectedOrigin = string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}");

        if (!string.Equals(origin, expectedOrigin, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        if (request.Headers.GetValueOrDefault("sec-websocket-key") is null)
        {
            return false;
        }

        var protocols = request.Headers.GetValueOrDefault("sec-websocket-protocol") ?? string.Empty;

        var offered = protocols
            .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);

        if (!offered.Contains(SubProtocol, StringComparer.Ordinal))
        {
            return false;
        }

        // Carried as a subprotocol rather than a query parameter, which keeps it out of anything that
        // logs URLs.
        var presented = offered.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal));

        return presented is not null
            && CryptographicOperations.FixedTimeEquals(
                Encoding.ASCII.GetBytes(presented["token.".Length..]),
                Encoding.ASCII.GetBytes(Token));
    }

    private async Task ReceiveLoopAsync(WebSocket webSocket, CancellationToken cancellationToken)
    {
        var buffer = new byte[ReceiveBufferBytes];

        while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
        {
            var result = await webSocket
                .ReceiveAsync(buffer.AsMemory(), cancellationToken)
                .ConfigureAwait(false);

            if (result.MessageType == WebSocketMessageType.Close)
            {
                return;
            }

            if (!result.EndOfMessage)
            {
                // A frame larger than the receive buffer. Terminal input and acknowledgements are tiny,
                // so this is either a bug or something hostile; dropping is the safe response.
                continue;
            }

            Dispatch(buffer.AsSpan(0, result.Count));
        }
    }

    private void Dispatch(ReadOnlySpan<byte> frame)
    {
        if (!TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload))
        {
            return;
        }

        // Answered before the session lookup, because it is the one frame that is not about a session. The
        // page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just
        // ended is still a request to make the text bigger.
        if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep)
        {
            if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
            {
                FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
            }

            return;
        }

        TerminalSessionPump? pump;
        lock (pumpGate)
        {
            pump = pumps.GetValueOrDefault(sessionId);
        }

        if (pump is null)
        {
            // A frame for a session that has already ended. Ordinary during teardown.
            return;
        }

        switch ((TerminalClientOpcode)opcode)
        {
            case TerminalClientOpcode.Input:
                // Fire and forget: a blocked write must not stall acknowledgements for other sessions
                // sharing this socket.
                _ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask();
                break;

            case TerminalClientOpcode.Acknowledge:
                if (TerminalFrame.TryReadAcknowledgement(payload, out var rendered))
                {
                    pump.Acknowledge(rendered);
                }

                break;

            case TerminalClientOpcode.Resize:
                if (TerminalFrame.TryReadResize(payload, out var size))
                {
                    pump.Resize(size);
                }

                break;

            default:
                // Unknown opcode from a newer page than this host. Ignored rather than fatal.
                break;
        }
    }

    /// <summary>
    /// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
    /// </summary>
    /// <remarks>
    /// <para>
    /// SHA-1 is mandated by the specification and is not doing security work here. The value proves
    /// only that the server understood the WebSocket handshake rather than being a plain HTTP server
    /// that echoed the request; it authenticates nothing and protects no data. Substituting SHA-256
    /// would simply make the handshake fail with every client in existence.
    /// </para>
    /// <para>
    /// The alternative that avoids SHA-1 in our own code is
    /// <c>HttpListener.AcceptWebSocketAsync</c>, which throws <c>PlatformNotSupportedException</c>
    /// off Windows — so it would trade a documented suppression for a platform restriction.
    /// </para>
    /// </remarks>
    [System.Diagnostics.CodeAnalysis.SuppressMessage(
        "Security",
        "CA5350:Do Not Use Weak Cryptographic Algorithms",
        Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")]
    [System.Diagnostics.CodeAnalysis.SuppressMessage(
        "ApiDesign",
        "RS0030:Do not use banned APIs",
        Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")]
    private static string ComputeHandshakeAccept(string key) =>
        Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + HandshakeGuid)));

    private static async Task WriteResponseAsync(
        Stream stream,
        string status,
        string contentType,
        byte[] body,
        CancellationToken cancellationToken)
    {
        var header =
            "HTTP/1.1 " + status + "\r\n"
            + "Content-Type: " + contentType + "\r\n"
            + "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n"

            // The page carries a connection token, so it must never be cached anywhere.
            + "Cache-Control: no-store\r\n"
            + "Connection: close\r\n\r\n";

        await stream.WriteAsync(Encoding.ASCII.GetBytes(header), cancellationToken).ConfigureAwait(false);
        await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
        await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
    }

    private static async Task<HttpRequestLine?> ReadRequestAsync(
        Stream stream,
        CancellationToken cancellationToken)
    {
        var buffer = new byte[MaximumRequestBytes];
        var count = 0;

        while (count < buffer.Length)
        {
            var read = await stream
                .ReadAsync(buffer.AsMemory(count), cancellationToken)
                .ConfigureAwait(false);

            if (read == 0)
            {
                break;
            }

            count += read;

            if (Encoding.ASCII.GetString(buffer, 0, count).Contains("\r\n\r\n", StringComparison.Ordinal))
            {
                break;
            }
        }

        var lines = Encoding.ASCII.GetString(buffer, 0, count).Split("\r\n");
        var parts = lines[0].Split(' ');

        if (parts.Length < 2 || !string.Equals(parts[0], "GET", StringComparison.Ordinal))
        {
            return null;
        }

        var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

        foreach (var line in lines.Skip(1))
        {
            if (line.Length == 0)
            {
                break;
            }

            var separator = line.IndexOf(':', StringComparison.Ordinal);
            if (separator > 0)
            {
                headers[line[..separator].Trim()] = line[(separator + 1)..].Trim();
            }
        }

        var path = parts[1];
        var query = path.IndexOf('?', StringComparison.Ordinal);

        return new HttpRequestLine(query < 0 ? path : path[..query], headers);
    }

    private sealed record HttpRequestLine(string Path, IReadOnlyDictionary<string, string> Headers);
}
