using System.Buffers.Binary;

namespace DodoSSH.Client.Terminal;

/// <summary>Frames the host sends to the renderer.</summary>
public enum TerminalServerOpcode : byte
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>Terminal output. Payload is raw bytes for <c>term.write</c>.</summary>
    Output = 1,

    /// <summary>A session has been created; the renderer should attach a terminal to it.</summary>
    SessionOpened = 2,

    /// <summary>A session has ended. Payload is a UTF-8 reason for the user.</summary>
    SessionClosed = 3,

    /// <summary>
    /// This session's pane is the one to show. No payload.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Every session gets a pane and all but one are hidden, so something has to say which. The page picked
    /// the newest on its own, which is right exactly once — at the moment a session opens — and wrong for
    /// every tab switch afterwards, because the page has no idea a tab strip exists. It is the host that
    /// knows which tab is selected, so it is the host that says.
    /// </para>
    /// <para>
    /// An opcode rather than script evaluation through the WebView bridge, which would have been the smaller
    /// change. The bridge is UI-thread-bound and unordered with respect to this socket: a switch sent that
    /// way could arrive before the <see cref="SessionOpened"/> frame for the pane it names, and the page
    /// would activate a session that does not exist yet. On this socket the two are ordered by construction.
    /// </para>
    /// </remarks>
    SessionActivated = 4,

    /// <summary>
    /// This session is gone for good; the renderer should destroy its pane. No payload.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Deliberately not the same thing as <see cref="SessionClosed"/>. That one means the shell ended and
    /// the pane must <em>stay</em> — the user was probably reading the last thing the remote said, and that
    /// is usually why it ended. This one means the user closed the tab, so there is nothing left to read.
    /// </para>
    /// <para>
    /// It exists because a pane is expensive and the page never reclaimed one. Every closed tab used to
    /// leave an xterm instance, its scrollback and a WebGL context behind — and WebGL contexts are a hard
    /// browser limit of about sixteen, so a working day of opening and closing terminals ends with panes
    /// that cannot get a renderer. Nothing surfaced that, because the leak is inside the WebView.
    /// </para>
    /// </remarks>
    SessionRemoved = 5,

    /// <summary>
    /// Text to insert into this session, as if it had been pasted.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Payload is one flag byte — non-zero to press Enter after the text — followed by UTF-8.
    /// </para>
    /// <para>
    /// <b>Through the renderer rather than straight into the input stream, and that is the whole reason this
    /// opcode exists.</b> Writing the bytes to the pump would have been fewer lines and is wrong: xterm.js
    /// watches the remote for <c>\e[?2004h</c> and wraps pasted text in bracketed-paste markers when the mode
    /// is on, which is what makes a shell treat embedded newlines as text instead of as "run this". The host
    /// process cannot do that — <c>TerminalDataPlane</c> moves opaque bytes and never parses output — so it
    /// would have to guess, and guessing wrong executes every line of a multi-line snippet.
    /// </para>
    /// <para>
    /// The flag is separate from the text for the same reason. A trailing newline inside the payload would be
    /// wrapped along with everything else and arrive at the shell as a literal character; the Enter has to go
    /// through <c>term.input</c>, <em>outside</em> the wrapper, or nothing runs.
    /// </para>
    /// </remarks>
    Paste = 6,

    /// <summary>
    /// What size every pane should draw at. Payload is one byte, the size in CSS pixels.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Not per session, although the frame carries a session id as every frame does: a font size is a
    /// preference about reading rather than a property of one remote, and somebody who makes the text
    /// bigger means all of it. The id is sent as zero and ignored.
    /// </para>
    /// <para>
    /// <b>Changing it reflows the remote, and that is the point.</b> The page refits after applying, which
    /// changes the column and row count and sends <see cref="TerminalClientOpcode.Resize"/> back — so a
    /// larger font is a terminal with fewer columns, and the far end is told about it. That is why this is
    /// a font size rather than a zoom: zoom scales pixels and leaves the remote wrapping to a width that
    /// is no longer on screen, which is what made the phone's pinch-zoom worse than useless.
    /// </para>
    /// </remarks>
    FontSize = 7,
}

/// <summary>Frames the renderer sends to the host.</summary>
public enum TerminalClientOpcode : byte
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>Keystrokes. Payload is raw bytes for the remote.</summary>
    Input = 1,

    /// <summary>
    /// Bytes actually rendered. Payload is a big-endian <see cref="uint"/>, returning credit.
    /// </summary>
    Acknowledge = 2,

    /// <summary>The terminal was resized. Payload is four big-endian <see cref="ushort"/> values.</summary>
    Resize = 3,

    /// <summary>
    /// The page asking for a different font size. Payload is one signed byte: a step, or zero to reset.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A request rather than a statement, and the direction matters. The page owns the keyboard whenever a
    /// terminal has focus — the host's window sees no key events at all then — so Ctrl+plus and Ctrl+minus
    /// can only be <em>heard</em> there. But the size has to be remembered across launches and shown in a
    /// control the page knows nothing about, so it is <em>decided</em> here. The page asks, the host
    /// clamps, stores and answers with <see cref="TerminalServerOpcode.FontSize"/>.
    /// </para>
    /// <para>
    /// Which also means one path, not two: the phone's buttons and the desktop's chords arrive at the same
    /// place, and a size set by either is the size both remember.
    /// </para>
    /// </remarks>
    FontSizeStep = 4,
}

/// <summary>
/// The wire format between the host process and the renderer page.
/// </summary>
/// <remarks>
/// <para>
/// Binary frames over a loopback WebSocket, not the WebView's JavaScript bridge. The official bridge
/// is UI-thread-bound string evaluation: at 10 MB/s in 4 KiB chunks that is roughly 2,500 script
/// evaluations per second on the thread that also has to paint, with base64's 33% overhead on top and
/// — decisively — no backpressure signal at all. A socket gives flow control for free.
/// </para>
/// <para>
/// Every frame carries a session id because one WebView hosts every terminal. A WebView2 instance is
/// a separate browser process, so one per tab would mean twenty renderer processes and hundreds of
/// megabytes for a normal working set of tabs.
/// </para>
/// <para>
/// Fixed 5-byte header, big-endian, no length prefix: WebSocket already delimits messages, so adding
/// our own length would be a second source of truth about where a frame ends.
/// </para>
/// </remarks>
public static class TerminalFrame
{
    /// <summary>Opcode plus session id.</summary>
    public const int HeaderLength = 1 + sizeof(uint);

    /// <summary>Writes a frame into <paramref name="destination"/> and returns its length.</summary>
    public static int Write(
        Span<byte> destination,
        byte opcode,
        uint sessionId,
        ReadOnlySpan<byte> payload)
    {
        var total = HeaderLength + payload.Length;

        if (destination.Length < total)
        {
            throw new ArgumentException(
                $"Need {total} bytes for the frame, got {destination.Length}.",
                nameof(destination));
        }

        destination[0] = opcode;
        BinaryPrimitives.WriteUInt32BigEndian(destination[1..], sessionId);
        payload.CopyTo(destination[HeaderLength..]);

        return total;
    }

    /// <summary>Allocates and writes a frame.</summary>
    public static byte[] Create(byte opcode, uint sessionId, ReadOnlySpan<byte> payload)
    {
        var frame = new byte[HeaderLength + payload.Length];
        Write(frame, opcode, sessionId, payload);

        return frame;
    }

    /// <summary>
    /// Reads a frame's header and payload.
    /// </summary>
    /// <remarks>
    /// Returns false rather than throwing on anything malformed. These frames arrive from a WebView
    /// page — a different process running code we shipped but do not control at runtime — so a bad
    /// frame is untrusted input to be dropped, not an exceptional condition.
    /// </remarks>
    public static bool TryRead(
        ReadOnlySpan<byte> frame,
        out byte opcode,
        out uint sessionId,
        out ReadOnlySpan<byte> payload)
    {
        opcode = 0;
        sessionId = 0;
        payload = default;

        if (frame.Length < HeaderLength)
        {
            return false;
        }

        opcode = frame[0];
        sessionId = BinaryPrimitives.ReadUInt32BigEndian(frame[1..]);
        payload = frame[HeaderLength..];

        return true;
    }

    /// <summary>Reads an <see cref="TerminalClientOpcode.Acknowledge"/> payload.</summary>
    public static bool TryReadAcknowledgement(ReadOnlySpan<byte> payload, out uint rendered)
    {
        rendered = 0;

        if (payload.Length != sizeof(uint))
        {
            return false;
        }

        rendered = BinaryPrimitives.ReadUInt32BigEndian(payload);

        return true;
    }

    /// <summary>Reads a <see cref="TerminalClientOpcode.Resize"/> payload.</summary>
    public static bool TryReadResize(ReadOnlySpan<byte> payload, out DodoSSH.Client.Ssh.TerminalSize size)
    {
        size = default;

        if (payload.Length != sizeof(ushort) * 4)
        {
            return false;
        }

        size = new DodoSSH.Client.Ssh.TerminalSize(
            BinaryPrimitives.ReadUInt16BigEndian(payload),
            BinaryPrimitives.ReadUInt16BigEndian(payload[2..]),
            BinaryPrimitives.ReadUInt16BigEndian(payload[4..]),
            BinaryPrimitives.ReadUInt16BigEndian(payload[6..]));

        return true;
    }

    /// <summary>Writes a <see cref="TerminalClientOpcode.Resize"/> payload. Used by tests and tooling.</summary>
    public static byte[] CreateResizePayload(DodoSSH.Client.Ssh.TerminalSize size)
    {
        var payload = new byte[sizeof(ushort) * 4];

        BinaryPrimitives.WriteUInt16BigEndian(payload, size.Columns);
        BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(2), size.Rows);
        BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(4), size.PixelWidth);
        BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(6), size.PixelHeight);

        return payload;
    }

    /// <summary>Reads a <see cref="TerminalClientOpcode.FontSizeStep"/> payload.</summary>
    /// <remarks>
    /// Signed, because the one byte carries a direction as well as a magnitude — and zero means reset,
    /// which is why this cannot be a flag pair.
    /// </remarks>
    public static bool TryReadFontSizeStep(ReadOnlySpan<byte> payload, out int step)
    {
        step = 0;

        if (payload.Length != 1)
        {
            return false;
        }

        step = (sbyte)payload[0];

        return true;
    }

    /// <summary>Writes a <see cref="TerminalClientOpcode.FontSizeStep"/> payload.</summary>
    public static byte[] CreateFontSizeStepPayload(int step) => [(byte)(sbyte)Math.Clamp(step, -128, 127)];

    /// <summary>Writes a <see cref="TerminalServerOpcode.FontSize"/> payload.</summary>
    public static byte[] CreateFontSizePayload(int pixels) => [(byte)Math.Clamp(pixels, 1, 255)];

    /// <summary>Writes an <see cref="TerminalClientOpcode.Acknowledge"/> payload.</summary>
    public static byte[] CreateAcknowledgementPayload(uint rendered)
    {
        var payload = new byte[sizeof(uint)];
        BinaryPrimitives.WriteUInt32BigEndian(payload, rendered);

        return payload;
    }
}
