using System.Diagnostics.CodeAnalysis;
using System.Globalization;

namespace DodoSSH.Client.Domain;

/// <summary>
/// One host key the user has decided to trust, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// Nothing in here is a secret — a public key fingerprint is published by operators on purpose — and it is
/// still a vault item rather than a local file, for two reasons that have nothing to do with confidentiality.
/// Trust follows the user to every device, so approving a host once means approving it once. And the server
/// cannot tamper with it: a server able to drop a pin could silently downgrade every connection to
/// first-use, which is the only interesting attack on a trust-on-first-use scheme. The <em>set</em> of hosts
/// a person reaches is worth keeping to themselves as well, and encrypting the item does that for free.
/// </para>
/// <para>
/// <b>One item per host, port and algorithm.</b> A server legitimately offers several host keys and which one
/// gets negotiated can change between connections, so a pin has to name the algorithm it is about — pinning
/// one and refusing the others would make an ordinary server look hostile. Three scalars and a fingerprint
/// also means the merge is four independent fields with no collection in sight, which is why adding this type
/// needed no new reconciliation logic.
/// </para>
/// <para>
/// <b>Keyed on the address as dialled, not on the host item.</b> The SSH layer knows only what it connected
/// to, and <c>IKnownHostStore</c> is asked in those terms. It also means changing a host's address makes its
/// old pin stop applying and the new address a first contact, which is correct rather than unfortunate: as
/// far as trust goes that is a different machine until somebody says otherwise.
/// </para>
/// </remarks>
public sealed record KnownHostSecret : IVaultSecret
{
    /// <summary>The host as it was dialled — a name or an address.</summary>
    /// <remarks>
    /// Stored verbatim, and compared case-insensitively by the store that reads it rather than normalised
    /// here. DNS is case-insensitive so the comparison has to be, but lower-casing what gets stored would
    /// mean rewriting a value this type does not fully understand — an internationalised name, an IPv6
    /// literal with a zone id — which is the same argument <see cref="SshKeySecret.PrivateKeyPem"/> makes for
    /// keeping key armour untouched.
    /// </remarks>
    public required string Host { get; init; }

    /// <summary>The port as it was dialled.</summary>
    /// <remarks>
    /// Required, with no default, unlike <see cref="HostSecret.Port"/>. A pin is a statement about one
    /// endpoint, and a defaulted 22 would quietly pin the wrong one for a host reached anywhere else.
    /// </remarks>
    public required int Port { get; init; }

    /// <summary>The host key algorithm, e.g. <c>ssh-ed25519</c>.</summary>
    public required string Algorithm { get; init; }

    /// <summary>The fingerprint that was approved, as <c>SHA256:base64</c>.</summary>
    public required string Fingerprint { get; init; }

    /// <summary>
    /// What this pin is called, derived from what it pins.
    /// </summary>
    /// <remarks>
    /// Computed rather than stored, which is the one place this type departs from the other three. A user
    /// never names a pin — they approve a fingerprint for a host — so a stored label would be a field that
    /// can disagree with the three fields it describes, and a merge taking the label from one side and the
    /// address from the other would produce exactly that. Being get-only also keeps it out of the record's
    /// equality, so two pins are equal when what they pin is equal.
    /// </remarks>
    public string Label =>
        string.Create(CultureInfo.InvariantCulture, $"{Host}:{Port} ({Algorithm})");

    /// <summary>Whether this is storable, and why not if it is not.</summary>
    /// <remarks>
    /// The whitespace checks are not pedantry. Neither of these values is typed by a person — the store
    /// builds them from what the handshake presented — but a payload written by another client is untrusted
    /// input, and a fingerprint carrying a stray space or a trailing comment would compare unequal to the
    /// same key for ever, which reads to the user as a permanently changed host key.
    /// </remarks>
    public bool TryValidate([NotNullWhen(false)] out string? reason)
    {
        if (string.IsNullOrWhiteSpace(Host))
        {
            reason = "A known host key needs the host it belongs to.";
            return false;
        }

        if (Port is < 1 or > 65535)
        {
            reason = $"Port must be between 1 and 65535, not {Port}.";
            return false;
        }

        if (string.IsNullOrWhiteSpace(Algorithm) || Algorithm.Any(char.IsWhiteSpace))
        {
            reason = "A known host key needs the key algorithm it was negotiated with.";
            return false;
        }

        if (string.IsNullOrWhiteSpace(Fingerprint) || Fingerprint.Any(char.IsWhiteSpace))
        {
            reason = "A known host key needs a fingerprint, with no spaces in it.";
            return false;
        }

        reason = null;
        return true;
    }
}
