using System.Text;
using Renci.SshNet;

namespace DodoSSH.Client.Ssh.Tests;

/// <summary>
/// Whether a key this client generates is a key anything else recognises.
/// </summary>
/// <remarks>
/// <para>
/// The container is hand-written — see <see cref="OpenSshKeyWriter"/> for why there was no alternative —
/// so the only question that matters is whether a real parser accepts it. SSH.NET's
/// <c>PrivateKeyFile</c> is the parser this application actually hands the key to, which makes it the right
/// oracle: a file that satisfies a test of my own encoding and then fails there would be a test agreeing
/// with the bug.
/// </para>
/// <para>
/// The stronger proof is in <c>KeyAuthenticationTests</c>, which installs a generated public line on a real
/// sshd in a container and connects with it. This class is what fails first and reads clearly when the
/// encoding is wrong.
/// </para>
/// </remarks>
public sealed class SshKeyGeneratorTests
{
    [Theory]
    [InlineData(SshKeyAlgorithm.Ed25519)]
    [InlineData(SshKeyAlgorithm.Rsa4096)]
    public void AGeneratedKey_IsOneSshNetCanLoad(SshKeyAlgorithm algorithm)
    {
        var generated = SshKeyGenerator.Generate(algorithm, "dodossh@test");

        using var armour = new MemoryStream(Encoding.ASCII.GetBytes(generated.PrivateKeyArmour));

        // No passphrase, which is this writer's whole stated limitation. A throw here is the encoding being
        // wrong, not the key.
        var parsed = new PrivateKeyFile(armour);

        parsed.HostKeyAlgorithms.ShouldNotBeEmpty();
    }

    /// <remarks>
    /// <c>SshKeySecret.TryValidate</c> refuses anything that does not begin <c>-----BEGIN</c>, and refuses
    /// anything beginning <c>ssh-</c> outright because that is a pasted <c>.pub</c> file — the mistake
    /// people actually make. Asserting it here in those terms rather than by constructing the secret: this
    /// project deliberately has no reference to <c>DodoSSH.Client.Domain</c>, which is what keeps the SSH
    /// layer testable without a cache or a keychain, and one convenience is not worth spending it.
    /// </remarks>
    [Theory]
    [InlineData(SshKeyAlgorithm.Ed25519)]
    [InlineData(SshKeyAlgorithm.Rsa4096)]
    public void AGeneratedKey_IsOneTheKeychainWillStore(SshKeyAlgorithm algorithm)
    {
        var generated = SshKeyGenerator.Generate(algorithm, "dodossh@test");

        generated.PrivateKeyArmour.TrimStart().ShouldStartWith("-----BEGIN");
        generated.PrivateKeyArmour.TrimStart().ShouldNotStartWith("ssh-");
        generated.PublicKeyLine.ShouldStartWith("ssh-", Case.Sensitive);
    }

    [Fact]
    public void TheEd25519PublicLine_IsWhatOpenSshWrites()
    {
        var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test");

        var parts = generated.PublicKeyLine.Split(' ');

        parts.Length.ShouldBe(3);
        parts[0].ShouldBe("ssh-ed25519");
        parts[2].ShouldBe("dodossh@test");

        // 4-byte length + "ssh-ed25519" + 4-byte length + 32-byte point.
        Convert.FromBase64String(parts[1]).Length.ShouldBe(4 + 11 + 4 + 32);
    }

    [Fact]
    public void TheFingerprint_IsTheOneSshKeygenWouldPrint()
    {
        var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test");

        generated.Fingerprint.ShouldStartWith("SHA256:");
        generated.Fingerprint.ShouldNotEndWith("=", Case.Sensitive);

        // Taken over the same blob the public line carries, which is the whole definition of an OpenSSH
        // fingerprint. Computing it over anything else — the armour, the file, the raw point — produces a
        // string that looks right and never matches what a server reports.
        var blob = Convert.FromBase64String(generated.PublicKeyLine.Split(' ')[1]);
        generated.Fingerprint.ShouldBe(SshHostKeyFingerprint.Format(blob));
    }

    /// <remarks>
    /// The classic <c>openssh-key-v1</c> bug, and the reason it is worth twenty cases rather than one: the
    /// private section is padded to a multiple of eight, and the comment is the last thing in it. So whether
    /// the padding is right depends on how long the comment happens to be — a name that lands on a boundary
    /// produces a file every parser loads, and one character more produces one that none of them do.
    /// </remarks>
    [Fact]
    public void TheContainerIsPaddedCorrectlyForACommentOfAnyLength()
    {
        for (var length = 0; length < 20; length++)
        {
            var comment = new string('c', length);
            var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, comment);

            using var armour = new MemoryStream(Encoding.ASCII.GetBytes(generated.PrivateKeyArmour));

            Should.NotThrow(
                () => new PrivateKeyFile(armour),
                $"a comment of {length} characters must not change whether the key parses");
        }
    }

    [Fact]
    public void TwoGeneratedKeys_AreDifferent()
    {
        var first = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test");
        var second = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test");

        string.Equals(first.Fingerprint, second.Fingerprint, StringComparison.Ordinal)
            .ShouldBeFalse("two generated keys must not share a fingerprint");

        string.Equals(first.PrivateKeyArmour, second.PrivateKeyArmour, StringComparison.Ordinal)
            .ShouldBeFalse("nor any of their material");
    }
}
