using System.Buffers.Text;
using System.Text;

namespace DodoSSH.Crypto.Tests;

/// <summary>
/// The canonical key statement encoding and the nonce derived from it. See docs/crypto.md §7.1.
/// </summary>
/// <remarks>
/// These properties are what make the identity-provider binding checkable at all. The failure mode
/// they guard against is nasty: a client and a server that encode differently produce different
/// nonces, so every enrollment is rejected against a real provider while every local test passes.
/// </remarks>
public sealed class KeyStatementCodecTests
{
    private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);

    [Fact]
    public void Encode_IsDeterministic()
    {
        var first = KeyStatementCodec.Encode(Statement());
        var second = KeyStatementCodec.Encode(Statement());

        first.ShouldBe(second);
    }

    [Fact]
    public void Encode_BeginsWithTheDomainLabel()
    {
        var encoding = KeyStatementCodec.Encode(Statement());

        encoding.AsSpan(0, KeyStatementCodec.Label.Length)
            .SequenceEqual(KeyStatementCodec.Label)
            .ShouldBeTrue();
    }

    [Fact]
    public void AnAbsentEmail_EncodesDifferentlyFromAnEmptyOne()
    {
        // The presence byte exists for exactly this. Without it the encoding is not injective and
        // two genuinely different statements share a binding.
        var absent = KeyStatementCodec.Encode(Statement(email: null));
        var empty = KeyStatementCodec.Encode(Statement(email: string.Empty));

        absent.ShouldNotBe(empty);
    }

    [Fact]
    public void TheSameInstantInDifferentOffsets_EncodesIdentically()
    {
        // The timezone a client happens to hold must not change the hash the provider signs.
        var utc = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt));
        var shifted = KeyStatementCodec.Encode(
            Statement(createdAt: CreatedAt.ToOffset(TimeSpan.FromHours(-7))));

        utc.ShouldBe(shifted);
    }

    [Fact]
    public void SubMillisecondPrecision_IsTruncatedAway()
    {
        // PostgreSQL stores microseconds. A statement that has been through the database must still
        // hash to what the client hashed before sending it.
        var exact = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt));
        var noisy = KeyStatementCodec.Encode(Statement(createdAt: CreatedAt.AddTicks(9_999)));

        exact.ShouldBe(noisy);
    }

    [Theory]
    [InlineData("issuer")]
    [InlineData("subject")]
    [InlineData("email")]
    [InlineData("deviceName")]
    [InlineData("version")]
    [InlineData("keyGeneration")]
    [InlineData("createdAt")]
    [InlineData("encryptionPublicKey")]
    [InlineData("signingPublicKey")]
    public void ChangingAnyField_ChangesTheBinding(string field)
    {
        var baseline = KeyStatementCodec.ComputeBinding(Statement());
        var altered = KeyStatementCodec.ComputeBinding(Mutate(field));

        altered.ShouldNotBe(baseline);
    }

    [Fact]
    public void MovingACharacterAcrossAFieldBoundary_ChangesTheBinding()
    {
        // The property length prefixes buy: no field value can forge a boundary. A delimited
        // encoding would give these two the same bytes.
        var left = KeyStatementCodec.ComputeBinding(
            Statement(issuer: "https://idp.example/a", subject: "bcd"));

        var right = KeyStatementCodec.ComputeBinding(
            Statement(issuer: "https://idp.example/ab", subject: "cd"));

        left.ShouldNotBe(right);
    }

    [Fact]
    public void StringLengths_AreCountedInBytesNotCharacters()
    {
        // A device name whose UTF-8 length exceeds its character count must still round-trip, and
        // must not collide with a shorter one. A length prefix counting characters truncates here.
        var multiByte = Statement(deviceName: "büro — ThinkPad");
        var encoding = KeyStatementCodec.Encode(multiByte);

        var expectedNameBytes = Encoding.UTF8.GetByteCount(multiByte.DeviceName);
        expectedNameBytes.ShouldBeGreaterThan(multiByte.DeviceName.Length);

        // The name is the last field, so it occupies the tail of the encoding.
        Encoding.UTF8.GetString(encoding.AsSpan(encoding.Length - expectedNameBytes))
            .ShouldBe(multiByte.DeviceName);
    }

    [Fact]
    public void Nonce_IsUnpaddedBase64UrlOfTheBinding()
    {
        var statement = Statement();
        var binding = KeyStatementCodec.ComputeBinding(statement);
        var nonce = KeyStatementCodec.ComputeNonce(statement);

        nonce.Length.ShouldBe(KeyStatementCodec.NonceLength);
        nonce.ShouldNotContain("=");
        nonce.ShouldNotContain("+");
        nonce.ShouldNotContain("/");

        Base64Url.DecodeFromChars(nonce).ShouldBe(binding);
    }

    [Fact]
    public void ComputeBinding_AgreesBetweenBothOverloads()
    {
        var statement = Statement();

        KeyStatementCodec.ComputeBinding(KeyStatementCodec.Encode(statement))
            .ShouldBe(KeyStatementCodec.ComputeBinding(statement));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(31)]
    [InlineData(33)]
    [InlineData(64)]
    public void Encode_RejectsAPublicKeyOfTheWrongLength(int length)
    {
        var statement = Statement() with { EncryptionPublicKey = new byte[length] };

        Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(65536)]
    public void Encode_RejectsAnUnusableVersion(int version)
    {
        var statement = Statement() with { Version = version };

        Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
    }

    [Fact]
    public void Encode_RejectsAGenerationBelowOne()
    {
        var statement = Statement() with { KeyGeneration = 0 };

        Should.Throw<ArgumentOutOfRangeException>(() => KeyStatementCodec.Encode(statement));
    }

    [Fact]
    public void ToNonce_RejectsSomethingThatIsNotADigest()
    {
        Should.Throw<ArgumentException>(() => KeyStatementCodec.ToNonce(new byte[16]));
    }

    private static KeyStatementFields Statement(
        string issuer = "https://idp.example/realms/dodossh",
        string subject = "alice-subject",
        string? email = "alice@example.com",
        string deviceName = "alice-laptop",
        int keyGeneration = 1,
        DateTimeOffset? createdAt = null) =>
        new(
            Version: 1,
            Issuer: issuer,
            Subject: subject,
            Email: email,
            EncryptionPublicKey: TestKeys.Encryption,
            SigningPublicKey: TestKeys.Signing,
            KeyGeneration: keyGeneration,
            CreatedAt: createdAt ?? CreatedAt,
            DeviceName: deviceName);

    private static KeyStatementFields Mutate(string field) => field switch
    {
        "issuer" => Statement(issuer: "https://idp.example/realms/other"),
        "subject" => Statement(subject: "bob-subject"),
        "email" => Statement(email: "bob@example.com"),
        "deviceName" => Statement(deviceName: "bob-desktop"),
        "version" => Statement() with { Version = 2 },
        "keyGeneration" => Statement(keyGeneration: 2),
        "createdAt" => Statement(createdAt: CreatedAt.AddSeconds(1)),
        "encryptionPublicKey" => Statement() with { EncryptionPublicKey = TestKeys.Alternate },
        "signingPublicKey" => Statement() with { SigningPublicKey = TestKeys.Alternate },
        _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."),
    };
}

/// <summary>Fixed public-key bytes, so encodings are reproducible.</summary>
internal static class TestKeys
{
    internal static byte[] Encryption { get; } =
        [.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i))];

    internal static byte[] Signing { get; } =
        [.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i))];

    internal static byte[] Alternate { get; } =
        [.. Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0xA0 + i))];
}
