using System.Globalization;

namespace DodoSSH.Client.Ssh;

/// <summary>A host key as the server presented it during the handshake.</summary>
/// <param name="Host">Host as dialled.</param>
/// <param name="Port">Port as dialled.</param>
/// <param name="Algorithm">Key algorithm, e.g. <c>ssh-ed25519</c>.</param>
/// <param name="Fingerprint">OpenSSH-style fingerprint, from <see cref="SshHostKeyFingerprint"/>.</param>
public sealed record HostKeyPresentation(string Host, int Port, string Algorithm, string Fingerprint);

/// <summary>
/// What makes two pins the same pin.
/// </summary>
/// <remarks>
/// <para>
/// Shared by every <see cref="IKnownHostStore"/> rather than written per implementation, because the two that
/// exist have to agree: one is what ships and the other is what this project's own SSH tests run against, and
/// an identity rule that differed between them would mean the tested behaviour was not the shipped behaviour.
/// </para>
/// <para>
/// <b>Case-insensitive, and the host is stored verbatim anyway.</b> DNS names are case-insensitive, so
/// <c>DB.internal</c> and <c>db.internal</c> are one machine and must be one pin — a store that treated them
/// as two would ask the user to approve the same server twice. Comparing case-insensitively rather than
/// lower-casing what gets stored keeps the stored value the one that was actually dialled, which matters for
/// an internationalised name or an IPv6 literal with a zone id: rewriting those means rewriting a value this
/// layer does not fully understand. Algorithm names are compared the same way for the same reason, one step
/// weaker — they are ASCII tokens, and no server has been seen to vary their case.
/// </para>
/// </remarks>
public static class KnownHostIdentity
{
    /// <summary>The comparer an identity must be compared with.</summary>
    public static StringComparer Comparer => StringComparer.OrdinalIgnoreCase;

    /// <summary>The identity of one pin, as a single comparable value.</summary>
    public static string For(string host, int port, string algorithm) =>
        string.Create(CultureInfo.InvariantCulture, $"{host}:{port}/{algorithm}");
}

/// <summary>
/// The trusted host keys a user has accumulated.
/// </summary>
/// <remarks>
/// Known hosts live in the end-to-end encrypted vault as a synced entity, not in a local file. Trust
/// then follows the user to every device, and the server cannot tamper with it — which matters,
/// because a server that could silently drop a pin could downgrade every connection to first-use.
/// </remarks>
public interface IKnownHostStore
{
    /// <summary>Returns the pinned fingerprint for a host and key algorithm, if there is one.</summary>
    /// <remarks>
    /// <para>
    /// Keyed on algorithm as well as host, because a server legitimately offers several host keys and
    /// which one is negotiated can change between connections. Pinning only one and rejecting the
    /// others would make a normal server look hostile.
    /// </para>
    /// <para>
    /// <b>Implementations must answer this without I/O.</b> It is called from inside SSH.NET's synchronous
    /// host key event — see <c>SshNetConnectionFactory</c> — so the caller has no choice but to block the
    /// thread completing the key exchange on it. Asynchronous in signature because a store may need to be
    /// asynchronous to <em>fill</em> itself; not because this call may go and look something up.
    /// </para>
    /// </remarks>
    ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);

    /// <summary>Records a host key as trusted.</summary>
    ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);

    /// <summary>
    /// Withdraws trust from every key pinned for one endpoint, whatever the algorithm.
    /// </summary>
    /// <remarks>
    /// The counterpart to a pin that now outlives the process, and not an optional extra: a server that is
    /// legitimately rebuilt gets a new host key, <see cref="SshHostKeyMismatchException"/> is a hard refusal
    /// with no way past it, and without this the host would be unreachable for ever. Every algorithm goes at
    /// once because the user's decision is about the machine, not about one of the keys it happens to offer.
    /// </remarks>
    /// <returns>How many pins were removed, so a caller can say whether there was anything to forget.</returns>
    ValueTask<int> ForgetAsync(string host, int port, CancellationToken cancellationToken);
}

/// <summary>
/// The host has never been seen, so there is nothing to compare against.
/// </summary>
/// <remarks>
/// A distinct exception rather than a prompt inside the handshake, and that is a deliberate design
/// choice. SSH.NET raises host key verification as a synchronous event, so consulting the user from
/// inside it would mean blocking the handshake thread on a UI round trip — sync-over-async, and a
/// deadlock the first time the prompt needs the UI thread. Failing the connection and letting the
/// caller prompt keeps everything asynchronous, at the cost of a second TCP connection the first
/// time a host is used.
/// </remarks>
public sealed class SshHostKeyUnknownException(HostKeyPresentation presentation)
    : Exception($"The host key for {presentation.Host}:{presentation.Port} is not trusted yet.")
{
    /// <summary>The key the server offered, to show the user before they trust it.</summary>
    public HostKeyPresentation Presentation { get; } = presentation;
}

/// <summary>
/// The host presented a different key from the one pinned for it.
/// </summary>
/// <remarks>
/// This must stay a hard block with no "continue anyway" in the connect path. A dialog offering to
/// proceed is how users are trained to click through the one warning that actually indicates an
/// interception. A legitimate key change — a rebuilt server — is handled by explicitly forgetting the
/// pin in the host's settings, which is a deliberate act performed away from the moment of
/// connecting. See <see cref="IKnownHostStore.ForgetAsync"/>.
/// </remarks>
public sealed class SshHostKeyMismatchException(HostKeyPresentation presentation, string pinnedFingerprint)
    : Exception(
        $"The host key for {presentation.Host}:{presentation.Port} has changed. "
        + $"Pinned {pinnedFingerprint}, but the server offered {presentation.Fingerprint}.")
{
    /// <summary>The key the server offered.</summary>
    public HostKeyPresentation Presentation { get; } = presentation;

    /// <summary>The key previously trusted for this host.</summary>
    public string PinnedFingerprint { get; } = pinnedFingerprint;
}

/// <summary>
/// A known-host store held in memory.
/// </summary>
/// <remarks>
/// <para>
/// <b>Not the one that ships.</b> <c>VaultKnownHostStore</c> is: it keeps trust in the vault, so a
/// fingerprint approved once is approved on every device and survives a restart. This is what a store looks
/// like with nothing behind it, and it exists because this project deliberately has no project references at
/// all — that is the seam which keeps connections, authentication and PTY handling testable without a cache,
/// a keyring or a server, and those tests still need somewhere to put a pin.
/// </para>
/// <para>
/// Trust is lost when the process exits, so a user of this store would be asked about every host on every
/// launch. Nothing in the application composes it.
/// </para>
/// </remarks>
public sealed class InMemoryKnownHostStore : IKnownHostStore
{
    private readonly Dictionary<string, HostKeyPresentation> pins = new(KnownHostIdentity.Comparer);
    private readonly Lock gate = new();

    /// <inheritdoc />
    public ValueTask<string?> FindAsync(
        string host,
        int port,
        string algorithm,
        CancellationToken cancellationToken)
    {
        lock (gate)
        {
            var key = KnownHostIdentity.For(host, port, algorithm);

            return ValueTask.FromResult(pins.GetValueOrDefault(key)?.Fingerprint);
        }
    }

    /// <inheritdoc />
    public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(presentation);

        lock (gate)
        {
            var key = KnownHostIdentity.For(
                presentation.Host, presentation.Port, presentation.Algorithm);

            pins[key] = presentation;
        }

        return ValueTask.CompletedTask;
    }

    /// <inheritdoc />
    public ValueTask<int> ForgetAsync(string host, int port, CancellationToken cancellationToken)
    {
        lock (gate)
        {
            // Matched on the stored values rather than by picking apart the composite key. The key exists to
            // be looked up whole; taking it back apart to find an endpoint would be one parser too many, and
            // a host that is an IPv6 literal is full of colons.
            var doomed = pins
                .Where(pin =>
                    KnownHostIdentity.Comparer.Equals(pin.Value.Host, host) && pin.Value.Port == port)
                .Select(pin => pin.Key)
                .ToArray();

            foreach (var key in doomed)
            {
                pins.Remove(key);
            }

            return ValueTask.FromResult(doomed.Length);
        }
    }
}
