using System.Buffers.Binary;
using System.Security.Cryptography;

namespace DodoSSH.Crypto;

/// <summary>
/// The hash chain over the append-only identity key log. See docs/crypto.md §7.2.
/// </summary>
/// <remarks>
/// <para>
/// Cheap key transparency. Every published key statement links to the hash of the one before it,
/// so for a server to show two clients divergent views of who holds which key it must maintain
/// both forks consistently across every later shared operation. Signed grants record the log head
/// their signer observed, and any two clients touching the same vault then surface the mismatch.
/// </para>
/// <para>
/// This converts an otherwise undetectable key-substitution attack into a detectable one. It does
/// not prevent it, and it is not a substitute for the identity-provider binding in
/// <see cref="KeyStatementCodec"/>. See ADR 0001.
/// </para>
/// <para>
/// The chain lives here rather than in the server so that clients can recompute it. A chain only
/// a server can compute is a claim, not evidence.
/// </para>
/// </remarks>
public static class KeyLogChain
{
    /// <summary>Domain-separating prefix of the entry hash input.</summary>
    public static ReadOnlySpan<byte> Label => "dsh1/keylog/v1"u8;

    /// <summary>
    /// Length of the hash input: label, previous hash, user id, generation, both public keys,
    /// the statement signature and the timestamp.
    /// </summary>
    private const int InputLength =
        14                              // Label
        + CryptoSpec.DigestSize         // previous hash
        + 16                            // user id
        + sizeof(uint)                  // generation
        + (CryptoSpec.PublicKeySize * 2)
        + CryptoSpec.SignatureSize
        + sizeof(long);                 // created at, Unix milliseconds

    /// <summary>
    /// The previous-hash value of the first entry in an empty log: 32 zero bytes.
    /// </summary>
    public static byte[] CreateGenesisPreviousHash() => new byte[CryptoSpec.DigestSize];

    /// <summary>
    /// Computes an entry's hash.
    /// </summary>
    /// <remarks>
    /// The database-assigned sequence is deliberately <em>not</em> an input. It is unknown until
    /// the insert executes, and the chain already fixes the order — deriving order from the hash
    /// links rather than from a sequence column means a renumbered or gapped sequence cannot
    /// silently reorder history.
    /// <para>
    /// <paramref name="createdAt"/> is truncated to milliseconds, matching the stored column after
    /// a round trip through PostgreSQL. Writing a timestamp with finer precision than the hash
    /// input would leave the chain unverifiable by anyone who read the row back.
    /// </para>
    /// </remarks>
    /// <param name="previousHash">Hash of the preceding entry, or <see cref="CreateGenesisPreviousHash"/>.</param>
    /// <param name="userId">The user whose key this is.</param>
    /// <param name="generation">Generation published.</param>
    /// <param name="encryptionPublicKey">X25519 public key, 32 bytes.</param>
    /// <param name="signingPublicKey">Ed25519 public key, 32 bytes.</param>
    /// <param name="statementSignature">Ed25519 self-signature over the key statement, 64 bytes.</param>
    /// <param name="createdAt">When the entry was appended.</param>
    public static byte[] ComputeEntryHash(
        ReadOnlySpan<byte> previousHash,
        Guid userId,
        int generation,
        ReadOnlySpan<byte> encryptionPublicKey,
        ReadOnlySpan<byte> signingPublicKey,
        ReadOnlySpan<byte> statementSignature,
        DateTimeOffset createdAt)
    {
        RequireLength(previousHash, CryptoSpec.DigestSize, nameof(previousHash));
        RequireLength(encryptionPublicKey, CryptoSpec.PublicKeySize, nameof(encryptionPublicKey));
        RequireLength(signingPublicKey, CryptoSpec.PublicKeySize, nameof(signingPublicKey));
        RequireLength(statementSignature, CryptoSpec.SignatureSize, nameof(statementSignature));
        ArgumentOutOfRangeException.ThrowIfLessThan(generation, 1);

        Span<byte> input = stackalloc byte[InputLength];
        input.Clear();

        Label.CopyTo(input);
        var offset = Label.Length;

        previousHash.CopyTo(input[offset..]);
        offset += CryptoSpec.DigestSize;

        // RFC 4122 big-endian order, as everywhere else in this specification.
        if (!userId.TryWriteBytes(input[offset..], bigEndian: true, out _))
        {
            throw new InvalidOperationException("Failed to write the user id.");
        }

        offset += 16;

        BinaryPrimitives.WriteUInt32BigEndian(input[offset..], (uint)generation);
        offset += sizeof(uint);

        encryptionPublicKey.CopyTo(input[offset..]);
        offset += CryptoSpec.PublicKeySize;

        signingPublicKey.CopyTo(input[offset..]);
        offset += CryptoSpec.PublicKeySize;

        statementSignature.CopyTo(input[offset..]);
        offset += CryptoSpec.SignatureSize;

        BinaryPrimitives.WriteInt64BigEndian(input[offset..], createdAt.ToUnixTimeMilliseconds());
        offset += sizeof(long);

        if (offset != InputLength)
        {
            throw new InvalidOperationException(
                $"Key log hash input wrote {offset} bytes but reserved {InputLength}.");
        }

        return SHA256.HashData(input);
    }

    /// <summary>
    /// Truncates a timestamp to the precision the chain hashes.
    /// </summary>
    /// <remarks>
    /// Callers must store the value this returns, not the one they started with, or the stored row
    /// will not reproduce its own hash.
    /// </remarks>
    public static DateTimeOffset TruncateTimestamp(DateTimeOffset value) =>
        DateTimeOffset.FromUnixTimeMilliseconds(value.ToUnixTimeMilliseconds()).ToOffset(TimeSpan.Zero);

    private static void RequireLength(ReadOnlySpan<byte> value, int expected, string parameterName)
    {
        if (value.Length != expected)
        {
            throw new ArgumentException(
                $"Expected {expected} bytes, got {value.Length}.",
                parameterName);
        }
    }
}
