using System.Diagnostics.CodeAnalysis;

namespace DodoSSH.Client.Import;

/// <summary>
/// A private key file an <c>ssh_config</c> named, as the import screen has to describe it.
/// </summary>
/// <remarks>
/// A record rather than a thrown exception per failure, because every outcome here is a row on a screen
/// rather than an error: a path that does not exist is the ordinary shape of a config carried between
/// machines, and a config with forty entries will have several. The import goes ahead without them and says
/// which.
/// </remarks>
/// <param name="Path">The file this is about, resolved. What a message names, so it is never null.</param>
/// <param name="PrivateKeyPem">The armour, verbatim, or null when it could not be read.</param>
/// <param name="PublicKey">
/// The <c>.pub</c> beside it, if there is one. Optional for the reason <c>SshKeySecret.PublicKey</c> is
/// optional — it cannot be derived without parsing every format this stores verbatim — and worth taking
/// while it is right there, because installing a key on a host needs exactly that line.
/// </param>
/// <param name="IsEncrypted">
/// Whether the file is passphrase-protected. Not a failure: the key is worth importing either way, and the
/// passphrase is the one thing on disk that cannot be read off it. The import says so and the keychain's
/// editor is where it gets added.
/// </param>
/// <param name="Failure">Why there is no key, or null when there is one.</param>
public sealed record SshIdentityFile(
    string Path,
    string? PrivateKeyPem,
    string? PublicKey,
    bool IsEncrypted,
    string? Failure)
{
    /// <summary>Whether there is key material to store.</summary>
    [MemberNotNullWhen(true, nameof(PrivateKeyPem))]
    public bool WasRead => PrivateKeyPem is { Length: > 0 };
}

/// <summary>
/// Reading the private keys an <c>ssh_config</c> points at, and telling encrypted ones apart.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing in here runs unless somebody ticked a box.</b> Pulling a private key out of a home directory
/// is the act this product exists to make deliberate, so it is opt-in on the import screen, off by default,
/// and said out loud beside the tick. What this type is responsible for is doing it honestly once asked:
/// reading exactly the files the config named, saying which it could not, and never guessing at a path the
/// config did not give.
/// </para>
/// <para>
/// It does not parse key material. <c>SshKeySecret</c> stores whatever armour it is handed and declines to
/// re-encode it, for reasons written there, and this keeps to the same rule — the one thing it looks
/// <em>into</em> a key for is whether it is encrypted, because that decides what the screen has to say next.
/// </para>
/// </remarks>
public static class SshIdentityFiles
{
    /// <summary>The OpenSSH v1 container's magic, which is what an armoured OPENSSH PRIVATE KEY holds.</summary>
    private static readonly byte[] OpenSshMagic = "openssh-key-v1\0"u8.ToArray();

    /// <summary>
    /// Reads the key at a path an <c>ssh_config</c> gave.
    /// </summary>
    /// <param name="path">
    /// The <c>IdentityFile</c> value. Already tilde-expanded by <see cref="SshConfigResolver"/>; a relative
    /// one is resolved against <paramref name="sshDirectory"/>, which is OpenSSH's own rule.
    /// </param>
    /// <param name="sshDirectory">Where <c>~/.ssh</c> is for this run.</param>
    /// <remarks>
    /// <para>
    /// The <c>.pub</c> is read as a side dish and never as a requirement: a missing one costs the public
    /// half and nothing else, so it is silently absent rather than a failure. A <em>present</em> one is
    /// worth having, and the mistake it heads off is the one <c>SshKeySecret.TryValidate</c> exists to
    /// catch — somebody later pasting the wrong one of two files whose names differ by four characters.
    /// </para>
    /// <para>
    /// Every failure is caught and returned rather than thrown. A config that names keys on a machine it
    /// was copied from is the ordinary case, and one unreadable path must not stop the other thirty-nine
    /// entries being imported.
    /// </para>
    /// </remarks>
    public static SshIdentityFile Read(string path, string sshDirectory)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(path);
        ArgumentNullException.ThrowIfNull(sshDirectory);

        var resolved = System.IO.Path.IsPathRooted(path)
            ? path
            : System.IO.Path.Combine(sshDirectory, path);

        try
        {
            if (!File.Exists(resolved))
            {
                return Refused(resolved, "there is no such file on this machine");
            }

            var material = File.ReadAllText(resolved);

            if (material.TrimStart().StartsWith("ssh-", StringComparison.Ordinal)
                || material.TrimStart().StartsWith("ecdsa-", StringComparison.Ordinal))
            {
                // The .pub, named as the IdentityFile. ssh itself tolerates this — it looks for the private
                // half beside it — and importing the public one would produce a keychain entry that looks
                // fine and fails at connect time saying nothing about which file was chosen.
                return Refused(resolved, "that is the public half; the private key is the file without .pub");
            }

            if (!material.TrimStart().StartsWith("-----BEGIN", StringComparison.Ordinal))
            {
                return Refused(resolved, "it does not look like a private key");
            }

            return new SshIdentityFile(
                resolved, material, ReadPublicHalf(resolved), IsEncrypted(material), Failure: null);
        }
        catch (IOException failure)
        {
            return Refused(resolved, failure.Message);
        }
        catch (UnauthorizedAccessException failure)
        {
            return Refused(resolved, failure.Message);
        }
    }

    /// <summary>
    /// Whether the armour is passphrase-protected.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Three formats and three tells. PKCS#8 says so in the header outright. Classic PEM carries a
    /// <c>Proc-Type: 4,ENCRYPTED</c> line above the base64. OpenSSH's own container says nothing in the
    /// armour at all — the cipher name is the first field <em>inside</em> the base64 — which is why this
    /// decodes rather than reading headers, and it is the format <c>ssh-keygen</c> has written by default
    /// for years, so it is the case that actually turns up.
    /// </para>
    /// <para>
    /// Wrong in the safe direction when it cannot tell: anything it fails to decode is reported as
    /// unencrypted, which means the key is imported with no warning and fails at connect time with SSH.NET's
    /// own message. The opposite default would put a warning on every key, and a warning everybody sees is
    /// one nobody reads.
    /// </para>
    /// </remarks>
    internal static bool IsEncrypted(string material)
    {
        if (material.Contains("BEGIN ENCRYPTED PRIVATE KEY", StringComparison.Ordinal))
        {
            return true;
        }

        if (material.Contains("Proc-Type:", StringComparison.Ordinal)
            && material.Contains("ENCRYPTED", StringComparison.Ordinal))
        {
            return true;
        }

        return TryReadOpenSshCipher(material, out var cipher)
            && !string.Equals(cipher, "none", StringComparison.Ordinal);
    }

    /// <summary>
    /// Reads the cipher name out of an OpenSSH v1 private key container.
    /// </summary>
    /// <remarks>
    /// The container is <c>openssh-key-v1\0</c> followed by SSH string fields, each a big-endian length and
    /// then that many bytes. The first of them is the cipher, and <c>none</c> is what an unprotected key
    /// carries. Only that first field is read: everything after it is key material, and this type does not
    /// look at key material.
    /// </remarks>
    private static bool TryReadOpenSshCipher(string material, [NotNullWhen(true)] out string? cipher)
    {
        cipher = null;

        const string header = "-----BEGIN OPENSSH PRIVATE KEY-----";
        const string footer = "-----END OPENSSH PRIVATE KEY-----";

        var start = material.IndexOf(header, StringComparison.Ordinal);
        var end = material.IndexOf(footer, StringComparison.Ordinal);

        if (start < 0 || end <= start)
        {
            return false;
        }

        var body = material[(start + header.Length)..end];

        // 88 base64 characters decode to 66 bytes, so the destination is 72 rather than 64 — a span too
        // small makes Convert.TryFromBase64Chars answer false, which this would read as "not an OpenSSH
        // container" and report as unencrypted. Measured: every protected key came back unprotected.
        Span<byte> decoded = stackalloc byte[72];

        // Only the head of the container is needed and the whole of it may be megabytes, so just enough of
        // the base64 is decoded for the magic and the first field.
        var bytes = TryDecodeHead(body, decoded, out var written) ? decoded[..written] : default;

        if (bytes.Length < OpenSshMagic.Length + 4)
        {
            return false;
        }

        if (!bytes[..OpenSshMagic.Length].SequenceEqual(OpenSshMagic))
        {
            return false;
        }

        var rest = bytes[OpenSshMagic.Length..];
        var length = (rest[0] << 24) | (rest[1] << 16) | (rest[2] << 8) | rest[3];

        if (length is < 1 or > 64 || rest.Length < 4 + length)
        {
            return false;
        }

        cipher = System.Text.Encoding.ASCII.GetString(rest.Slice(4, length));

        return true;
    }

    /// <summary>Decodes the first few base64 characters of an armoured body.</summary>
    /// <remarks>
    /// The armour is wrapped at 70 characters and the destination is 64 bytes, so this takes the first
    /// non-whitespace 88 characters — a multiple of four, decoding to 66 — and stops. Trailing padding never
    /// enters into it because the slice never reaches the end of a real key.
    /// </remarks>
    private static bool TryDecodeHead(ReadOnlySpan<char> body, Span<byte> destination, out int written)
    {
        Span<char> head = stackalloc char[88];
        var taken = 0;

        foreach (var character in body)
        {
            if (char.IsWhiteSpace(character))
            {
                continue;
            }

            head[taken++] = character;

            if (taken == head.Length)
            {
                break;
            }
        }

        if (taken < head.Length)
        {
            written = 0;
            return false;
        }

        return Convert.TryFromBase64Chars(head, destination, out written);
    }

    /// <remarks>
    /// Absent is not a failure, so this answers null rather than a reason. Read with the same swallow the
    /// private half uses: a <c>.pub</c> that cannot be read costs the public line and nothing else.
    /// </remarks>
    private static string? ReadPublicHalf(string privatePath)
    {
        try
        {
            var pub = privatePath + ".pub";

            return File.Exists(pub) ? File.ReadAllText(pub).Trim() : null;
        }
        catch (IOException)
        {
            return null;
        }
        catch (UnauthorizedAccessException)
        {
            return null;
        }
    }

    private static SshIdentityFile Refused(string path, string reason) =>
        new(path, PrivateKeyPem: null, PublicKey: null, IsEncrypted: false, Failure: reason);
}
