using System.Buffers.Binary;
using System.Text;

namespace DodoSSH.Client.Import.Tests;

/// <summary>
/// Reading the private keys an <c>ssh_config</c> names, and telling the protected ones apart.
/// </summary>
/// <remarks>
/// <para>
/// The encryption check is what most of this is about, and it earns the attention: a key imported without a
/// passphrase it needs is a host that fails at connect time with a message about a malformed key, and
/// nothing in the interface would say which of the fifteen files it was. So the three formats are each
/// pinned, and so is the one that answers by decoding rather than by reading a header.
/// </para>
/// <para>
/// The rest is refusals. Every one of them is a row on a screen rather than an exception — a config carried
/// from another machine names keys that are not on this one, which is the ordinary case, and one bad path
/// must not stop the other thirty-nine entries being imported.
/// </para>
/// </remarks>
public sealed class SshIdentityFileTests
{
    [Fact]
    public void AKeyBesideItsPublicHalf_ComesBackWithBoth()
    {
        using var home = new TemporaryDirectory();

        var path = home.Write("id_ed25519", OpenSshKey("none"));
        home.Write("id_ed25519.pub", "ssh-ed25519 AAAAC3Nz nobody@example\n");

        var read = SshIdentityFiles.Read(path, home.Path);

        read.WasRead.ShouldBeTrue(read.Failure);
        read.PublicKey.ShouldBe("ssh-ed25519 AAAAC3Nz nobody@example");
        read.IsEncrypted.ShouldBeFalse();
        read.Failure.ShouldBeNull();
    }

    /// <remarks>
    /// A missing <c>.pub</c> costs the public half and nothing else. It is optional on
    /// <c>SshKeySecret</c> for the reason written there — it cannot be derived without parsing every format
    /// the key is stored verbatim in — so its absence is not a failure and must not read as one.
    /// </remarks>
    [Fact]
    public void AKeyWithNoPublicHalf_IsStillRead()
    {
        using var home = new TemporaryDirectory();

        var read = SshIdentityFiles.Read(home.Write("id_ed25519", OpenSshKey("none")), home.Path);

        read.WasRead.ShouldBeTrue(read.Failure);
        read.PublicKey.ShouldBeNull();
    }

    /// <remarks>
    /// A relative <c>IdentityFile</c> resolves against <c>~/.ssh</c>, which is OpenSSH's own rule and is why
    /// this call takes the directory at all. <c>SshConfigResolver</c> expands a tilde and leaves everything
    /// else alone, so a bare <c>id_rsa</c> arrives here exactly as it was written.
    /// </remarks>
    [Fact]
    public void ARelativePath_ResolvesAgainstTheSshDirectory()
    {
        using var home = new TemporaryDirectory();

        home.Write("id_rsa", OpenSshKey("none"));

        var read = SshIdentityFiles.Read("id_rsa", home.Path);

        read.WasRead.ShouldBeTrue(read.Failure);
        read.Path.ShouldBe(Path.Combine(home.Path, "id_rsa"));
    }

    /// <remarks>
    /// ◆ The one that decodes rather than reading a header. OpenSSH's own container says nothing about
    /// encryption in its armour — the cipher name is the first field inside the base64 — and it is what
    /// <c>ssh-keygen</c> has written by default for years, so it is the case that actually turns up.
    /// </remarks>
    [Theory]
    [InlineData("none", false)]
    [InlineData("aes256-ctr", true)]
    [InlineData("aes256-gcm@openssh.com", true)]
    public void AnOpenSshKey_IsJudgedByTheCipherInsideIt(string cipher, bool encrypted)
    {
        using var home = new TemporaryDirectory();

        var read = SshIdentityFiles.Read(home.Write("id_ed25519", OpenSshKey(cipher)), home.Path);

        read.WasRead.ShouldBeTrue(read.Failure);
        read.IsEncrypted.ShouldBe(encrypted);
    }

    /// <remarks>
    /// The two formats that do say so in the armour. Classic PEM carries a <c>Proc-Type</c> line above the
    /// base64; PKCS#8 puts it in the header outright.
    /// </remarks>
    [Theory]
    [InlineData("-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nQUJD\n-----END RSA PRIVATE KEY-----\n")]
    [InlineData("-----BEGIN ENCRYPTED PRIVATE KEY-----\nQUJD\n-----END ENCRYPTED PRIVATE KEY-----\n")]
    public void AnArmourThatSaysItIsEncrypted_IsTakenAtItsWord(string material)
    {
        using var home = new TemporaryDirectory();

        var read = SshIdentityFiles.Read(home.Write("id_rsa", material), home.Path);

        read.WasRead.ShouldBeTrue(read.Failure);
        read.IsEncrypted.ShouldBeTrue();
    }

    /// <remarks>
    /// <c>ssh</c> tolerates an <c>IdentityFile</c> naming the <c>.pub</c> — it looks for the private half
    /// beside it — so people write it. Importing the public one would produce a keychain entry that looks
    /// fine and fails at connect time saying nothing about which of two files whose names differ by four
    /// characters was chosen, which is the mistake <c>SshKeySecret.TryValidate</c> exists to catch.
    /// </remarks>
    [Fact]
    public void ThePublicHalfNamedAsTheIdentityFile_IsRefusedByName()
    {
        using var home = new TemporaryDirectory();

        var path = home.Write("id_ed25519.pub", "ssh-ed25519 AAAAC3Nz nobody@example\n");

        var read = SshIdentityFiles.Read(path, home.Path);

        read.WasRead.ShouldBeFalse();
        read.Failure.ShouldContain("public half");
    }

    [Fact]
    public void AFileThatIsNotAKeyAtAll_IsRefused()
    {
        using var home = new TemporaryDirectory();

        var read = SshIdentityFiles.Read(home.Write("notes.txt", "remember to rotate this"), home.Path);

        read.WasRead.ShouldBeFalse();
        read.Failure.ShouldContain("private key");
    }

    /// <remarks>
    /// The ordinary case for a config carried between machines, and the reason none of this throws: the
    /// path is still named, so the import screen can say which entry lost its key rather than reporting
    /// that something somewhere went wrong.
    /// </remarks>
    [Fact]
    public void APathThatIsNotThere_NamesItselfInTheRefusal()
    {
        using var home = new TemporaryDirectory();

        var read = SshIdentityFiles.Read("id_absent", home.Path);

        read.WasRead.ShouldBeFalse();
        read.Failure.ShouldContain("no such file");
        read.Path.ShouldContain("id_absent");
    }

    /// <summary>
    /// An armoured OpenSSH v1 private key whose cipher field says what is asked for.
    /// </summary>
    /// <remarks>
    /// Padded past 66 bytes because the reader decodes the first 88 base64 characters and needs the magic
    /// and the first field inside them — a container shorter than that is not a shape <c>ssh-keygen</c>
    /// produces, and treating one as unencrypted is the safe direction anyway.
    /// </remarks>
    private static string OpenSshKey(string cipher)
    {
        var body = new List<byte>();

        body.AddRange("openssh-key-v1\0"u8);

        foreach (var field in new[] { cipher, "none", string.Empty })
        {
            var length = new byte[4];
            BinaryPrimitives.WriteUInt32BigEndian(length, (uint)field.Length);

            body.AddRange(length);
            body.AddRange(Encoding.ASCII.GetBytes(field));
        }

        while (body.Count < 96)
        {
            body.Add(0x41);
        }

        var armour = Convert.ToBase64String(body.ToArray());

        return $"-----BEGIN OPENSSH PRIVATE KEY-----\n{armour}\n-----END OPENSSH PRIVATE KEY-----\n";
    }

    /// <summary>A directory standing in for <c>~/.ssh</c>, removed when the test finishes.</summary>
    private sealed class TemporaryDirectory : IDisposable
    {
        internal TemporaryDirectory()
        {
            Path = System.IO.Path.Combine(
                System.IO.Path.GetTempPath(), $"dodossh-identity-{Guid.CreateVersion7():N}");

            Directory.CreateDirectory(Path);
        }

        internal string Path { get; }

        internal string Write(string name, string content)
        {
            var path = System.IO.Path.Combine(Path, name);

            File.WriteAllText(path, content);

            return path;
        }

        public void Dispose()
        {
            try
            {
                Directory.Delete(Path, recursive: true);
            }
            catch (IOException)
            {
                // A test's leftovers in the system temp directory are not worth failing a run over.
            }
        }
    }
}
