using System.Security.Cryptography;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Api;

/// <summary>Why a directory entry was or was not accepted.</summary>
public enum RecipientVerdict
{
    /// <summary>Not a legal value.</summary>
    Unspecified = 0,

    /// <summary>
    /// The key log verifies, and it introduces exactly the key the directory described.
    /// </summary>
    /// <remarks>
    /// This is the strongest statement a client can make without an out-of-band fingerprint check. It
    /// says the server has been consistent, not that the key is the right person's — see
    /// <see cref="VerifiedRecipient.Fingerprint"/> and ADR 0001.
    /// </remarks>
    Verified = 1,

    /// <summary>No account with that address, or none the caller may look up.</summary>
    NotFound = 2,

    /// <summary>The account exists but has published no identity key, so there is nothing to wrap to.</summary>
    NotEnrolled = 3,

    /// <summary>
    /// The key log's hash chain does not verify.
    /// </summary>
    /// <remarks>
    /// Either the log has been edited or this build and the server disagree about how an entry is
    /// hashed. Both are refusals: wrapping a vault key against a log that cannot be checked is the
    /// same as not checking one.
    /// </remarks>
    ChainBroken = 4,

    /// <summary>
    /// The log holds no entry matching the key the directory returned.
    /// </summary>
    /// <remarks>
    /// The exact case key transparency exists for. A server that wants to substitute a key it holds
    /// has to publish it in the append-only log to get past this, where every other client will see
    /// it.
    /// </remarks>
    NotInKeyLog = 5,

    /// <summary>The fingerprint does not match the keys it is supposed to be over.</summary>
    FingerprintMismatch = 6,

    /// <summary>
    /// The log introduces a newer generation for this user than the directory returned.
    /// </summary>
    /// <remarks>
    /// A rotation the directory has not caught up with, or a stale answer being served on purpose.
    /// Refused either way: a key wrapped to a superseded generation opens nothing, and the recipient
    /// reads that as corruption rather than as a race.
    /// </remarks>
    Superseded = 7,
}

/// <summary>
/// A recipient whose published key has been checked against the key log.
/// </summary>
/// <param name="Entry">The directory entry, as returned.</param>
/// <param name="KeyLogHead">
/// The log head observed while verifying, to be recorded in the grant. This is what makes a forked
/// view detectable: two clients handed different logs sign over different heads, and the mismatch
/// surfaces the next time either touches a vault the other can see.
/// </param>
/// <param name="Fingerprint">
/// The recipient's identity fingerprint, recomputed here rather than taken from the response.
/// <para>
/// <b>Show this to a human before sharing anything that matters.</b> Everything above proves the
/// server has been internally consistent; only somebody comparing this value with the recipient over
/// a channel the server does not control can prove it is the right person's key.
/// </para>
/// </param>
public sealed record VerifiedRecipient(
    DirectoryEntry Entry,
    byte[] KeyLogHead,
    byte[] Fingerprint);

/// <summary>The outcome of verifying a recipient.</summary>
/// <param name="Verdict">What happened.</param>
/// <param name="Recipient">The recipient, present only when verified.</param>
/// <param name="Message">One line for a person. Never contains key material.</param>
public sealed record RecipientVerification(
    RecipientVerdict Verdict,
    VerifiedRecipient? Recipient,
    string Message)
{
    /// <summary>Whether a key came back that is safe to wrap to.</summary>
    public bool IsVerified => Verdict == RecipientVerdict.Verified && Recipient is not null;
}

/// <summary>
/// Reads the whole key log, checks its hash chain, and decides whether a directory answer agrees
/// with it.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the check that makes sharing safe to offer at all.</b> A directory lookup is a claim by
/// the server about somebody else's public key; wrapping a vault key to an unverified claim hands the
/// vault to whoever made it, and no amount of transport security helps, because the server is inside
/// the threat model. See ADR 0001 and docs/crypto.md §7.2.
/// </para>
/// <para>
/// The whole log is read from the beginning, every time, rather than from a cached cursor. It is
/// small — one entry per identity key ever published, so a few hundred rows for a large deployment —
/// and a client that verified only the tail would accept a chain whose earlier links it had never
/// seen. Caching a verified prefix is a worthwhile optimisation and is deliberately not done yet:
/// it needs somewhere to keep the prefix that the server cannot influence, and the client's
/// preferences store does not exist.
/// </para>
/// <para>
/// What this cannot do is tell you the key belongs to the person you mean. A server that publishes a
/// substituted key in the log passes every check here — it is now on the record, which is the whole
/// mechanism: detectable, attributable, not prevented. The fingerprint comes back for a human to
/// compare out of band, which is the only step that closes it.
/// </para>
/// </remarks>
public static class KeyLogAudit
{
    /// <summary>Entries requested per page.</summary>
    private const int PageSize = 500;

    /// <summary>
    /// Pages the whole log with the chain checked link by link.
    /// </summary>
    /// <returns>The verified log, or a null <c>Entries</c> when a link did not hold.</returns>
    public static async Task<AuditedKeyLog> ReadAsync(
        IDirectoryApi directory,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(directory);

        var entries = new List<KeyLogRecord>();
        var previous = KeyLogChain.CreateGenesisPreviousHash();
        var after = 0L;
        var head = previous;

        while (true)
        {
            var page = await directory.ReadKeyLogAsync(after, PageSize, cancellationToken)
                .ConfigureAwait(false);

            foreach (var entry in page.Entries)
            {
                if (!Links(entry, previous))
                {
                    return new AuditedKeyLog(null, head);
                }

                entries.Add(entry);
                previous = entry.Hash;
                after = entry.Sequence;
            }

            head = page.Head;

            if (!page.HasMore)
            {
                break;
            }

            // A page that advanced nothing would loop for ever. It means the server is answering a
            // cursor it will not move past, which is a broken log from this side of the wire.
            if (page.Entries.Count == 0)
            {
                return new AuditedKeyLog(null, head);
            }
        }

        // The last link has to be the head the server claims, or the log served and the log
        // summarised are two different things.
        return entries.Count > 0 && !CryptographicOperations.FixedTimeEquals(previous, head)
            ? new AuditedKeyLog(null, head)
            : new AuditedKeyLog(entries, head);
    }

    /// <summary>Decides whether a directory entry agrees with a verified log.</summary>
    public static RecipientVerification Verify(AuditedKeyLog log, DirectoryEntry? entry)
    {
        ArgumentNullException.ThrowIfNull(log);

        if (log.Entries is null)
        {
            return new RecipientVerification(
                RecipientVerdict.ChainBroken,
                null,
                "The server's key log does not verify. Nothing will be shared with anyone until it "
                + "does — an unverifiable log is the same as no log.");
        }

        if (entry is null)
        {
            return new RecipientVerification(
                RecipientVerdict.NotFound,
                null,
                "No account here has that address. They have to sign in to this server once before "
                + "anything can be shared with them.");
        }

        var fingerprint = DshCrypto.ComputeFingerprint(
            entry.EncryptionPublicKey, entry.SigningPublicKey);

        if (!CryptographicOperations.FixedTimeEquals(fingerprint, entry.Fingerprint))
        {
            return new RecipientVerification(
                RecipientVerdict.FingerprintMismatch,
                null,
                "The fingerprint the directory returned is not the fingerprint of the keys it "
                + "returned with it.");
        }

        return Compare(log, entry, fingerprint);
    }

    /// <summary>Compares one directory entry with the log entries for that account.</summary>
    private static RecipientVerification Compare(
        AuditedKeyLog log,
        DirectoryEntry entry,
        byte[] fingerprint)
    {
        var forUser = log.Entries!.Where(e => e.UserId == entry.UserId).ToList();

        if (forUser.Count == 0)
        {
            return new RecipientVerification(
                RecipientVerdict.NotEnrolled,
                null,
                "That account has published no identity key, so there is nothing to wrap a vault key "
                + "to.");
        }

        var latest = forUser.Max(e => e.Generation);

        if (latest > entry.KeyGeneration)
        {
            return new RecipientVerification(
                RecipientVerdict.Superseded,
                null,
                $"The key log has generation {latest} for that account and the directory offered "
                + $"{entry.KeyGeneration}. Wrapping to a superseded key would open nothing.");
        }

        var matching = forUser.Find(e =>
            e.Generation == entry.KeyGeneration
            && e.EncryptionPublicKey.AsSpan().SequenceEqual(entry.EncryptionPublicKey)
            && e.SigningPublicKey.AsSpan().SequenceEqual(entry.SigningPublicKey));

        if (matching is null)
        {
            return new RecipientVerification(
                RecipientVerdict.NotInKeyLog,
                null,
                "The key the directory returned does not appear in the append-only key log. This is "
                + "exactly the substitution the log exists to catch; do not share anything with this "
                + "account until it is explained.");
        }

        return new RecipientVerification(
            RecipientVerdict.Verified,
            new VerifiedRecipient(entry, log.Head, fingerprint),
            "Verified against the key log. Compare the fingerprint with them out of band before "
            + "sharing anything that matters.");
    }

    /// <remarks>
    /// Recomputed rather than compared: the point is that this client derives the hash from the
    /// entry's own contents, so a server that edited a field cannot hand over a hash that covers the
    /// original.
    /// </remarks>
    private static bool Links(KeyLogRecord entry, byte[] previous)
    {
        if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash, previous))
        {
            return false;
        }

        byte[] computed;

        try
        {
            computed = KeyLogChain.ComputeEntryHash(
                entry.PreviousHash,
                entry.UserId,
                entry.Generation,
                entry.EncryptionPublicKey,
                entry.SigningPublicKey,
                entry.StatementSignature,
                entry.CreatedAt);
        }
        catch (ArgumentException)
        {
            // A key or signature of the wrong length. Malformed rather than merely mismatched, and a
            // refusal either way.
            return false;
        }

        return CryptographicOperations.FixedTimeEquals(computed, entry.Hash);
    }
}

/// <summary>A key log that has been read, with its chain checked.</summary>
/// <param name="Entries">
/// Every entry in order, or <see langword="null"/> when a link did not hold. Null is the only
/// signal a caller needs: a partially verified log is not a weaker answer, it is no answer.
/// </param>
/// <param name="Head">The head the server reported, for recording in a grant.</param>
public sealed record AuditedKeyLog(IReadOnlyList<KeyLogRecord>? Entries, byte[] Head);
