using System.Text.Json;
using DodoSSH.Contracts;

namespace DodoSSH.Contracts.Tests;

/// <summary>
/// Wire-format behaviour of the shared contracts.
/// </summary>
/// <remarks>
/// These assert the properties the client depends on and that a compile-time reference cannot
/// guarantee: camelCase naming, enums as strings, base64 for binary, and strict rejection of
/// unmapped members on inbound requests.
/// </remarks>
public sealed class SerializationTests
{
    // Deliberately the shared options, not a hand-rolled instance: constructing options
    // separately is exactly the mistake these contracts must not permit.
    private static JsonSerializerOptions Options => DodoSshJsonContext.ResponseOptions;

    [Fact]
    public void Properties_AreCamelCase()
    {
        var payload = new EncryptedPayload(
            [1, 2, 3],
            WrappedDataKey: [4, 5],
            DataKeyId: Guid.CreateVersion7(),
            KeyGeneration: 4,
            AadVersion: 1);

        var json = JsonSerializer.Serialize(payload, Options);

        json.ShouldContain("\"envelope\"");
        json.ShouldContain("\"wrappedDataKey\"");
        json.ShouldContain("\"dataKeyId\"");
        json.ShouldContain("\"keyGeneration\"");
        json.ShouldContain("\"aadVersion\"");
    }

    [Fact]
    public void ByteArrays_AreBase64()
    {
        var payload = new EncryptedPayload(
            [0xDE, 0xAD, 0xBE, 0xEF], [0xC0, 0xFF, 0xEE], Guid.CreateVersion7(), 1, 1);

        var json = JsonSerializer.Serialize(payload, Options);

        json.ShouldContain(Convert.ToBase64String([0xDE, 0xAD, 0xBE, 0xEF]));

        // The data key wrap is a second envelope and must travel the same way. A string here
        // instead would mean a client silently storing an item nobody can ever open.
        json.ShouldContain(Convert.ToBase64String([0xC0, 0xFF, 0xEE]));
    }

    [Fact]
    public void Enums_AreSerialisedAsStrings()
    {
        // Integers across the wire would make a reordered enum silently reinterpret data, and
        // would make captured payloads unreadable without the matching build.
        var change = new SyncChange(
            SyncEntityType.Host,
            Guid.CreateVersion7(),
            SyncOperation.Upsert,
            Version: 1,
            ChangeSequence: 42,
            Payload: null,
            PlaintextFields: null,
            UpdatedAt: DateTimeOffset.UnixEpoch);

        var json = JsonSerializer.Serialize(change, Options);

        json.ShouldContain("\"Host\"");
        json.ShouldContain("\"Upsert\"");
        json.ShouldNotContain("\"entityType\":1");
    }

    [Fact]
    public void EncryptedPayload_RoundTrips()
    {
        var original = new EncryptedPayload(
            [9, 8, 7, 6, 5], [1, 2, 3, 4], Guid.CreateVersion7(), 12, 1);

        var restored = JsonSerializer.Deserialize<EncryptedPayload>(
            JsonSerializer.Serialize(original, Options), Options);

        restored.ShouldNotBeNull();
        restored.Envelope.ShouldBe(original.Envelope);
        restored.WrappedDataKey.ShouldBe(original.WrappedDataKey);
        restored.DataKeyId.ShouldBe(original.DataKeyId);
        restored.KeyGeneration.ShouldBe(original.KeyGeneration);
        restored.AadVersion.ShouldBe(original.AadVersion);
    }

    [Fact]
    public void SyncPushRequest_RoundTripsWithAllFields()
    {
        var original = new SyncPushRequest(
        [
            new SyncPushOperation(
                Guid.CreateVersion7(),
                SyncEntityType.Host,
                Guid.CreateVersion7(),
                SyncOperation.Upsert,
                ExpectedVersion: 3,
                Payload: new EncryptedPayload([1, 2, 3], [4, 5], Guid.CreateVersion7(), 2, 1),
                PlaintextFields: new SyncPlaintextFields(
                    RelayEnabled: true,
                    Hostname: "bastion.internal",
                    Port: 22)),
            new SyncPushOperation(
                Guid.CreateVersion7(),
                SyncEntityType.Credential,
                Guid.CreateVersion7(),
                SyncOperation.Delete,
                ExpectedVersion: 7,
                Payload: null,
                PlaintextFields: null),
        ]);

        var restored = JsonSerializer.Deserialize<SyncPushRequest>(
            JsonSerializer.Serialize(original, Options), Options);

        restored.ShouldNotBeNull();
        restored.Operations.Count.ShouldBe(2);
        restored.Operations[0].PlaintextFields!.Hostname.ShouldBe("bastion.internal");
        restored.Operations[0].PlaintextFields!.RelayEnabled.ShouldBeTrue();
        restored.Operations[1].Payload.ShouldBeNull();
        restored.Operations[1].Operation.ShouldBe(SyncOperation.Delete);
    }

    [Fact]
    public void NullFields_AreOmitted()
    {
        var request = new SyncPullRequest(Cursor: null, Limit: null, EntityTypes: null);

        var json = JsonSerializer.Serialize(request, Options);

        json.ShouldNotContain("cursor");
        json.ShouldNotContain("limit");
    }

    [Fact]
    public void StrictRequestOptions_RejectUnmappedMembers()
    {
        // A renamed or misspelled client property must surface as a 400, not as a silently
        // missing value that later looks like data loss.
        const string Json = """
            {"cursor":null,"limit":50,"entityTypes":null,"unexpectedProperty":"surprise"}
            """;

        Should.Throw<JsonException>(() =>
            JsonSerializer.Deserialize<SyncPullRequest>(Json, DodoSshJsonContext.StrictRequestOptions));
    }

    [Fact]
    public void StrictRequestOptions_AcceptAWellFormedRequest()
    {
        const string Json = """
            {"cursor":"abc","limit":50}
            """;

        var request = JsonSerializer.Deserialize<SyncPullRequest>(
            Json, DodoSshJsonContext.StrictRequestOptions);

        request.ShouldNotBeNull();
        request.Cursor.ShouldBe("abc");
        request.Limit.ShouldBe(50);
    }

    [Fact]
    public void ResponseOptions_TolerateUnknownMembers()
    {
        // Forward compatibility: an older client must still read a newer server's response
        // rather than failing on a field it does not know about.
        const string Json = """
            {"envelope":"AQID","keyGeneration":1,"aadVersion":1,"futureField":true}
            """;

        var payload = JsonSerializer.Deserialize<EncryptedPayload>(Json, Options);

        payload.ShouldNotBeNull();
        payload.KeyGeneration.ShouldBe(1u);
    }

    [Fact]
    public void NumberHandling_IsStrict()
    {
        // A quoted number would let a sloppy client send "1" where 1 is meant, which then
        // diverges between implementations.
        const string Json = """
            {"envelope":"AQID","keyGeneration":"1","aadVersion":1}
            """;

        Should.Throw<JsonException>(() => JsonSerializer.Deserialize<EncryptedPayload>(Json, Options));
    }

    [Fact]
    public void MetaResponse_RoundTrips()
    {
        var original = new MetaResponse(
            ServerVersion: "0.1.0",
            ApiVersions: [1],
            SyncProtocolVersion: 1,
            CryptoSpecVersion: 1,
            Features: ["relay", "teams"],
            MinClientVersion: "0.1.0",
            MaxOperationsPerPush: 500,
            MaxPayloadBytes: 8 * 1024 * 1024,
            MaxItemPayloadBytes: 256 * 1024);

        var restored = JsonSerializer.Deserialize<MetaResponse>(
            JsonSerializer.Serialize(original, Options), Options);

        restored.ShouldNotBeNull();
        restored.Features.ShouldBe(["relay", "teams"]);
        restored.MaxPayloadBytes.ShouldBe(8 * 1024 * 1024);
    }

    [Fact]
    public void RelayTicketRequest_HasNoAddressField()
    {
        // Structural guard on ADR 0004: if a client could name its own target, the relay
        // would become an authenticated open TCP proxy into the operator's network.
        var properties = typeof(RelayTicketRequest)
            .GetProperties()
            .Select(p => p.Name)
            .ToList();

        properties.ShouldBe(["HostId", "PortForwardId"], ignoreOrder: true);
    }
}
