using System.Diagnostics.CodeAnalysis;

namespace DodoSSH.Client.Domain;

/// <summary>
/// An SSH key pair as the user sees it, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// The private key is the most valuable thing this product stores, and it is held here as an ordinary
/// string, in managed memory, exactly like <see cref="HostSecret.Notes"/> and every passphrase in the
/// client. That is a deliberate choice rather than an oversight, and it is worth being plain about what it
/// does and does not give you.
/// </para>
/// <para>
/// It does not give you a zeroable buffer. A .NET string cannot be wiped, so the key material lives until
/// the garbage collector reuses the memory, and a process dump taken in the meantime contains it. The
/// alternative — libsodium's guarded memory, which <c>DodoSSH.Crypto</c> uses for the vault keys — was
/// considered and rejected here for one reason: the passphrase protecting this key, the password on the next
/// item, and the decoded JSON the codec produced are all strings on the same heap. Protecting one field
/// among them would be a measure that reads as security and buys nothing, and the honest place for that
/// effort is the threat this design actually addresses, which is the server and the disk rather than another
/// process running as the same user. See <c>LocalCacheProtector</c>, which says the same thing about the
/// cache.
/// </para>
/// <para>
/// What it does give you: the key never reaches the disk in plaintext and never reaches the server at all.
/// It is sealed under the vault key before it leaves this type, and SSH.NET is handed it through a
/// <c>MemoryStream</c> rather than a file, so there is no temporary key file to leak or forget.
/// </para>
/// </remarks>
public sealed record SshKeySecret : IVaultSecret
{
    private readonly string? passphrase;

    /// <summary>What the user calls this key.</summary>
    public required string Label { get; init; }

    /// <summary>
    /// The private key, in the armoured form <c>ssh-keygen</c> writes.
    /// </summary>
    /// <remarks>
    /// Stored verbatim, including its header and trailer. Not reformatted, not re-encoded, not normalised:
    /// OpenSSH, PKCS#1 and PKCS#8 all round-trip through here untouched, and a client that rewrote them
    /// would eventually rewrite one it did not fully understand.
    /// </remarks>
    public required string PrivateKeyPem { get; init; }

    /// <summary>
    /// The passphrase protecting the private key, when it has one.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Kept with the key rather than typed per connection, which is the entire point of a vault: the
    /// passphrase defends the key file on a disk, and inside a vault the key is not on a disk. Storing both
    /// together means the vault passphrase is what protects them, which is the guarantee this product is
    /// built to make. A user who wants the second factor can leave this null and be prompted.
    /// </para>
    /// <para>
    /// <b>An empty string is normalised to null, so there is exactly one way to say "no passphrase".</b>
    /// Two spellings of one state cost more than they look: two clients that agree about a key and disagree
    /// only about which spelling they used would produce different payload bytes for an identical key and a
    /// spurious field conflict out of the merge, and <c>Passphrase is not null</c> would stop being a
    /// reliable answer to "is this key protected?" — which is what the interface reads to describe a key.
    /// Normalising here rather than at each call site means every way one can arrive lands on the same
    /// value: an editor whose box was left blank, a codec decoding another client's <c>""</c>, a merge
    /// picking one side.
    /// </para>
    /// <para>
    /// It is <em>not</em> a defence against SSH.NET, which was the original reason given here and turned out
    /// to be false: a passphrase handed to <c>PrivateKeyFile</c> for a key that has none is ignored, not
    /// rejected, and the connection succeeds. See docs/platform-flags.md.
    /// </para>
    /// </remarks>
    public string? Passphrase
    {
        get => passphrase;
        init => passphrase = string.IsNullOrEmpty(value) ? null : value;
    }

    /// <summary>
    /// The public half, in <c>authorized_keys</c> form, when it is known.
    /// </summary>
    /// <remarks>
    /// Not derived from the private key, and deliberately optional. Deriving it means parsing every key
    /// format this type accepts verbatim, which is the thing above that it declines to do. It is worth
    /// keeping because installing a key on a host needs exactly this line, and a user who imported only a
    /// private key should be told the public half is missing rather than have one silently reconstructed.
    /// </remarks>
    public string? PublicKey { get; init; }

    /// <summary>Free text.</summary>
    public string? Notes { get; init; }

    /// <summary>
    /// Whether this key is storable, and why not if it is not.
    /// </summary>
    /// <remarks>
    /// The public-key mix-up is checked explicitly because it is the mistake a person actually makes:
    /// <c>ssh-keygen</c> writes two files whose names differ by four characters, and pasting the wrong one
    /// produces a vault item that looks fine and fails at connection time with an authentication error that
    /// says nothing about which file you chose.
    /// </remarks>
    public bool TryValidate([NotNullWhen(false)] out string? reason)
    {
        if (string.IsNullOrWhiteSpace(Label))
        {
            reason = "A key needs a name.";
            return false;
        }

        if (string.IsNullOrWhiteSpace(PrivateKeyPem))
        {
            reason = "A key needs its private key material.";
            return false;
        }

        var material = PrivateKeyPem.TrimStart();

        if (material.StartsWith("ssh-", StringComparison.Ordinal)
            || material.StartsWith("ecdsa-", StringComparison.Ordinal))
        {
            reason = "That is a public key. Paste the private key — the file without the .pub extension.";
            return false;
        }

        if (!material.StartsWith("-----BEGIN", StringComparison.Ordinal))
        {
            reason = "That does not look like a private key; it should begin with \"-----BEGIN\".";
            return false;
        }

        reason = null;
        return true;
    }
}
