using System.Net;
using DodoSSH.Contracts;

namespace DodoSSH.Client.Api.Tests;

/// <summary>
/// The wire contract: what goes out, what comes back, and how failures surface.
/// </summary>
/// <remarks>
/// The problem-code assertions matter most. Those codes are how a client decides what to do next —
/// <c>enrollment-required</c> means enroll, <c>vault-conflict</c> means merge and retry — so losing one
/// while parsing an error turns an actionable failure into an opaque one.
/// </remarks>
public sealed class DodoSshApiClientTests : IDisposable
{
    private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");

    private readonly StubServer server = new();
    private readonly HttpClient http = new();

    public DodoSshApiClientTests() => http.BaseAddress = server.BaseUrl;

    /// <inheritdoc />
    public void Dispose()
    {
        http.Dispose();
        server.Dispose();
    }

    [Fact]
    public async Task Meta_IsFetchedWithoutAToken()
    {
        // A client has to be able to ask what a server supports before it can authenticate, so this
        // one must not require a bearer token.
        server.StubMeta(new MetaResponse(
            ServerVersion: "1.2.3",
            ApiVersions: [1],
            SyncProtocolVersion: 1,
            CryptoSpecVersion: 1,
            Features: ["teams"],
            MinClientVersion: null,
            MaxOperationsPerPush: 500,
            MaxPayloadBytes: 8 * 1024 * 1024,
            MaxItemPayloadBytes: 256 * 1024));

        var meta = await Client().GetMetaAsync(TestContext.Current.CancellationToken);

        meta.ServerVersion.ShouldBe("1.2.3");
        meta.MaxOperationsPerPush.ShouldBe(500);

        server.LastAuthorization("/api/v1/meta").ShouldBeNull();
    }

    [Fact]
    public async Task Me_CarriesTheBearerToken()
    {
        server.StubMe(UnenrolledMe());

        var me = await Client().GetMeAsync(TestContext.Current.CancellationToken);

        me.EnrollmentRequired.ShouldBeTrue();
        server.LastAuthorization("/api/v1/me").ShouldBe("Bearer test-access-token");
    }

    [Fact]
    public async Task Me_RoundTripsVaultSummaries()
    {
        // Byte arrays through the source-generated serialiser, which is the one thing most likely to
        // go wrong silently between the two sides.
        var wrappedKey = new byte[] { 1, 2, 3, 4, 5 };

        server.StubMe(UnenrolledMe() with
        {
            EnrollmentRequired = false,
            KeyGeneration = 1,
            WrappedPrivateKey = [9, 8, 7],
            KdfParameters = new KdfParameters("argon2id", [1, 2, 3], 262144, 4, 1),
            Vaults =
            [
                new VaultSummary(
                    VaultId: VaultId,
                    Name: "Personal",
                    IsPersonal: true,
                    TeamId: null,
                    KeyGeneration: 1,
                    Permissions: 31,
                    WrappedVaultKey: wrappedKey,
                    RekeyRequired: false),
            ],
        });

        var me = await Client().GetMeAsync(TestContext.Current.CancellationToken);

        var vault = me.Vaults.ShouldHaveSingleItem();
        vault.WrappedVaultKey.ShouldBe(wrappedKey);
        vault.KeyGeneration.ShouldBe(1u);
        me.WrappedPrivateKey.ShouldBe([9, 8, 7]);
        me.KdfParameters!.MemoryKibibytes.ShouldBe(262144);
    }

    [Fact]
    public async Task AProblemResponse_KeepsItsCode()
    {
        server.StubProblem(
            "/api/v1/me", "GET", 403, ProblemCodes.EnrollmentRequired, "Publish an identity key first.");

        var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
            await Client().GetMeAsync(TestContext.Current.CancellationToken));

        exception.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
        exception.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
        exception.Message.ShouldContain("Publish an identity key first.");
    }

    [Fact]
    public async Task ANonJsonError_StillReportsItsStatus()
    {
        // A reverse proxy in front of a dead server returns HTML. Losing the status code while trying
        // to parse that as ProblemDetails would replace a diagnosable failure with a parse error.
        server.StubGatewayError("/api/v1/me", "GET");

        var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
            await Client().GetMeAsync(TestContext.Current.CancellationToken));

        exception.StatusCode.ShouldBe(HttpStatusCode.BadGateway);
        exception.Code.ShouldBeNull();
    }

    [Fact]
    public async Task Push_SendsTheBatchAndReturnsPerOperationStatus()
    {
        // A push succeeds with mixed results on purpose, so one stale item cannot block everything
        // else a client queued while offline. Callers must read the statuses rather than trusting
        // the 200.
        var applied = Guid.CreateVersion7();
        var conflicted = Guid.CreateVersion7();

        server.StubPush(VaultId, new SyncPushResponse(
            Results:
            [
                new SyncPushResult(applied, SyncOperationStatus.Applied, 1, 10, null, null),
                new SyncPushResult(conflicted, SyncOperationStatus.Conflict, 2, 11, null, null),
            ],
            Cursor: "next-cursor"));

        var request = new SyncPushRequest(
        [
            new SyncPushOperation(
                applied,
                SyncEntityType.Host,
                Guid.CreateVersion7(),
                SyncOperation.Upsert,
                null,
                new EncryptedPayload([1, 2, 3], [7, 7], Guid.CreateVersion7(), 1, 1),
                new SyncPlaintextFields()),
        ]);

        var response = await Client().SyncPushAsync(
            VaultId, request, TestContext.Current.CancellationToken);

        response.Results.Count.ShouldBe(2);
        response.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
        response.Results[1].Status.ShouldBe(SyncOperationStatus.Conflict);
        response.Cursor.ShouldBe("next-cursor");

        var body = server.LastBody($"/api/v1/vaults/{VaultId}/sync/push");
        body.ShouldContain("operations");
        body.ShouldContain("Upsert");
    }

    [Fact]
    public async Task Pull_RoundTripsChangesAndTheCursor()
    {
        var entityId = Guid.CreateVersion7();

        server.StubPull(VaultId, new SyncPullResponse(
            Changes:
            [
                new SyncChange(
                    SyncEntityType.Host,
                    entityId,
                    SyncOperation.Upsert,
                    Version: 1,
                    ChangeSequence: 5,
                    Payload: new EncryptedPayload([4, 5, 6], [8, 8], Guid.CreateVersion7(), 1, 1),
                    PlaintextFields: new SyncPlaintextFields(RelayEnabled: false),
                    UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)),
            ],
            NextCursor: "cursor-2",
            HasMore: false,
            ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_001),
            CurrentKeyGeneration: 1));

        var response = await Client().SyncPullAsync(
            VaultId,
            new SyncPullRequest("cursor-1", null, null),
            TestContext.Current.CancellationToken);

        var change = response.Changes.ShouldHaveSingleItem();
        change.EntityId.ShouldBe(entityId);
        change.Payload!.Envelope.ShouldBe([4, 5, 6]);
        response.NextCursor.ShouldBe("cursor-2");
        response.HasMore.ShouldBeFalse();

        server.LastBody($"/api/v1/vaults/{VaultId}/sync/pull").ShouldContain("cursor-1");
    }

    [Fact]
    public async Task AConflictOnPush_SurfacesItsCode()
    {
        server.StubProblem(
            $"/api/v1/vaults/{VaultId}/sync/push",
            "POST",
            409,
            ProblemCodes.VaultConflict,
            "Stale version.");

        var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
            await Client().SyncPushAsync(
                VaultId, new SyncPushRequest([]), TestContext.Current.CancellationToken));

        exception.Code.ShouldBe(ProblemCodes.VaultConflict);
    }

    private DodoSshApiClient Client() => new(http, new StubTokenProvider());

    private static MeResponse UnenrolledMe() =>
        new(
            UserId: Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc"),
            Issuer: "https://idp.example",
            Subject: "alice",
            Email: "alice@example.com",
            DisplayName: "Alice",
            EnrollmentRequired: true,
            KeyGeneration: null,
            WrappedPrivateKey: null,
            KdfParameters: null,
            Vaults: []);
}
