using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;

namespace DodoSSH.Client.Ssh;

/// <summary>
/// Writes the two files <c>ssh-keygen</c> writes.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this is hand-rolled.</b> Neither the BCL nor NSec can write an OpenSSH private key. .NET has no
/// Ed25519 at all — that is why NSec is here in the first place — and the <c>openssh-key-v1</c> container is
/// an SSH-specific framing that no general-purpose library emits. The one alternative was PKCS#8 with the
/// Ed25519 OID <c>1.3.101.112</c>, which would also have had to be hand-encoded and which SSH.NET 2025.1.0
/// is not confirmed to parse — its PKCS#8 path historically switches on RSA, DSA and EC OIDs only. This
/// format is the one SSH.NET definitely reads and the one every other tool reads.
/// </para>
/// <para>
/// <b>The container is written unencrypted, on purpose.</b> Encrypting one needs <c>bcrypt_pbkdf</c> —
/// Blowfish with a byte-swizzling quirk — plus AES-256-CTR. .NET has no Blowfish, <c>Rfc2898DeriveBytes</c>
/// is in <c>BannedSymbols.txt</c> and is the wrong primitive anyway, and the only oracle for a hand-written
/// implementation is <c>ssh-keygen</c> itself. That is a standing crypto maintenance cost for a defence the
/// product does not need: a passphrase protects a key file sitting on a disk, and a key generated here goes
/// straight into an end-to-end encrypted keychain and never touches one. See
/// <c>SshKeySecret.Passphrase</c>, which makes the same argument at more length.
/// </para>
/// <para>
/// Everything below is length-prefixed big-endian, which is the whole of the SSH wire format. Getting a
/// prefix wrong yields a file that parses far enough to look plausible and then fails authentication with
/// an error that says nothing about the encoding.
/// </para>
/// </remarks>
internal static class OpenSshKeyWriter
{
    private const string Magic = "openssh-key-v1\0";

    private const string Ed25519Algorithm = "ssh-ed25519";

    private const string RsaAlgorithm = "ssh-rsa";

    /// <summary>
    /// How wide the base64 body is wrapped.
    /// </summary>
    /// <remarks>
    /// OpenSSH writes 70. Nothing parses by line length — but a key that diffs against one <c>ssh-keygen</c>
    /// produced, in a repository or a paste, should differ in its bytes and not in its wrapping.
    /// </remarks>
    private const int WrapAt = 70;

    /// <summary>
    /// The armoured private key for an Ed25519 pair, in <c>openssh-key-v1</c> form.
    /// </summary>
    /// <param name="seed">The 32-byte private scalar seed, as NSec exports it.</param>
    /// <param name="publicKey">The 32-byte public point.</param>
    /// <param name="comment">The trailing comment, which OpenSSH stores inside the private section.</param>
    internal static string WriteEd25519PrivateKey(
        ReadOnlySpan<byte> seed,
        ReadOnlySpan<byte> publicKey,
        string comment)
    {
        var publicBlob = Ed25519PublicBlob(publicKey);

        using var privateSection = new MemoryStream();

        // Two copies of the same random value. OpenSSH uses them as a decryption check: after decrypting an
        // encrypted key it compares them, and a mismatch is a wrong passphrase. Nothing here is encrypted,
        // so nothing checks them — they are written because the format says so, and a parser is entitled to
        // insist.
        var check = RandomNumberGenerator.GetBytes(4);
        privateSection.Write(check);
        privateSection.Write(check);

        WriteString(privateSection, Ed25519Algorithm);
        WriteString(privateSection, publicKey);

        // The private field of an Ed25519 OpenSSH key is the seed followed by the public point, 64 bytes,
        // not the 32-byte seed alone. A file carrying only the seed loads and then signs with a key whose
        // public half nobody agrees on.
        Span<byte> expanded = stackalloc byte[64];
        seed.CopyTo(expanded);
        publicKey.CopyTo(expanded[32..]);
        WriteString(privateSection, expanded);

        WriteString(privateSection, comment);

        Pad(privateSection);

        using var container = new MemoryStream();
        container.Write(Encoding.ASCII.GetBytes(Magic));
        WriteString(container, "none");
        WriteString(container, "none");
        WriteString(container, ReadOnlySpan<byte>.Empty);
        WriteUInt32(container, 1);
        WriteString(container, publicBlob);
        WriteString(container, privateSection.ToArray());

        return Armour("OPENSSH PRIVATE KEY", container.ToArray());
    }

    /// <summary>The <c>authorized_keys</c> line for an Ed25519 public point.</summary>
    internal static string WriteEd25519PublicKey(ReadOnlySpan<byte> publicKey, string comment) =>
        PublicLine(Ed25519Algorithm, Ed25519PublicBlob(publicKey), comment);

    /// <summary>The <c>authorized_keys</c> line for an RSA key.</summary>
    internal static string WriteRsaPublicKey(RSA rsa, string comment) =>
        PublicLine(RsaAlgorithm, RsaPublicBlob(rsa), comment);

    /// <summary>The raw public key blob, which is what a fingerprint is taken over.</summary>
    internal static byte[] Ed25519PublicBlob(ReadOnlySpan<byte> publicKey)
    {
        using var blob = new MemoryStream();
        WriteString(blob, Ed25519Algorithm);
        WriteString(blob, publicKey);

        return blob.ToArray();
    }

    /// <inheritdoc cref="Ed25519PublicBlob" />
    internal static byte[] RsaPublicBlob(RSA rsa)
    {
        var parameters = rsa.ExportParameters(includePrivateParameters: false);

        using var blob = new MemoryStream();
        WriteString(blob, RsaAlgorithm);
        WriteMpint(blob, parameters.Exponent!);
        WriteMpint(blob, parameters.Modulus!);

        return blob.ToArray();
    }

    private static string PublicLine(string algorithm, byte[] blob, string comment)
    {
        var line = $"{algorithm} {Convert.ToBase64String(blob)}";

        return string.IsNullOrWhiteSpace(comment) ? line : $"{line} {comment.Trim()}";
    }

    /// <remarks>
    /// To a multiple of eight, with the bytes 1, 2, 3… — the block size of the "none" cipher, which OpenSSH
    /// applies even though nothing is being blocked. This is the classic place to get an
    /// <c>openssh-key-v1</c> writer wrong, because whether it is wrong depends on the length of the comment:
    /// a name that happens to land on a boundary produces a file that loads everywhere, and one character
    /// more produces one that does not.
    /// </remarks>
    private static void Pad(Stream destination)
    {
        var remainder = (int)(destination.Length % 8);

        if (remainder == 0)
        {
            return;
        }

        for (var i = 1; i <= 8 - remainder; i++)
        {
            destination.WriteByte((byte)i);
        }
    }

    private static string Armour(string label, byte[] body)
    {
        var builder = new StringBuilder();
        builder.Append("-----BEGIN ").Append(label).Append("-----\n");

        var base64 = Convert.ToBase64String(body);

        for (var offset = 0; offset < base64.Length; offset += WrapAt)
        {
            builder.Append(base64.AsSpan(offset, Math.Min(WrapAt, base64.Length - offset))).Append('\n');
        }

        builder.Append("-----END ").Append(label).Append("-----\n");

        return builder.ToString();
    }

    private static void WriteString(Stream destination, string value) =>
        WriteString(destination, Encoding.UTF8.GetBytes(value));

    private static void WriteString(Stream destination, ReadOnlySpan<byte> value)
    {
        WriteUInt32(destination, (uint)value.Length);
        destination.Write(value);
    }

    private static void WriteUInt32(Stream destination, uint value)
    {
        Span<byte> encoded = stackalloc byte[4];
        BinaryPrimitives.WriteUInt32BigEndian(encoded, value);
        destination.Write(encoded);
    }

    /// <remarks>
    /// Signed big-endian, so a leading byte with its high bit set needs a zero in front of it or it reads as
    /// a negative number. An RSA modulus has that bit set roughly half the time, which is what makes this
    /// the kind of bug that ships.
    /// </remarks>
    private static void WriteMpint(Stream destination, byte[] value)
    {
        if (value.Length > 0 && (value[0] & 0x80) != 0)
        {
            var padded = new byte[value.Length + 1];
            value.CopyTo(padded, 1);
            WriteString(destination, padded);
            return;
        }

        WriteString(destination, value);
    }
}
