using System.Net;
using System.Text;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;

namespace DodoSSH.Api.Tests;

/// <summary>
/// What the server answers when a request body cannot be read at all.
/// </summary>
/// <remarks>
/// <para>
/// This is the one error the endpoint framework produces rather than a handler, so it is the one place
/// the problem-document contract could drift without any other test noticing. The strict
/// unmapped-member rule in particular has never had a test: it is configured in
/// <see cref="DodoSshJsonContext"/>, applied by <c>AddDodoJson</c>, and until now nothing asserted the
/// server actually enforced it.
/// </para>
/// <para>
/// Enrollment is the subject only because it is a POST with a body that an authenticated caller can
/// reach without being enrolled. Nothing here is about enrollment.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class RequestBindingTests(ApiFixture fixture)
{
    private const string EnrollUrl = "/api/v1/me/enrollment";

    [Fact]
    public async Task AnUnknownJsonMemberIsRejected()
    {
        // A misspelled or renamed client property must be a 400, not a silently missing value that
        // shows up later as data loss.
        var problem = await PostRawAsync("""{"statement":null,"thisMemberDoesNotExist":1}""");

        problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
    }

    [Fact]
    public async Task MalformedJsonIsRejected()
    {
        var problem = await PostRawAsync("""{"statement":""" + " ");

        problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
    }

    [Fact]
    public async Task ANumberSentAsAStringIsRejected()
    {
        // JsonNumberHandling.Strict. The web defaults would read "1" as 1, which is how two
        // implementations end up disagreeing about what a number is.
        //
        // keyGeneration is reached through statement on purpose: it is a member of KeyStatement, not of
        // EnrollmentRequest, so posting it at the top level is rejected as an unmapped member and never
        // reaches a number-parsing decision at all. This test asserted nothing until that was spotted.
        var problem = await PostRawAsync("""{"statement":{"keyGeneration":"1"}}""");

        problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
    }

    [Fact]
    public async Task TheQueryStringCannotSupplyRequestFields()
    {
        // FastEndpoints' default binder writes query-string values over the deserialised body; minimal
        // APIs read the body alone. Left on, that would let ?identityProviderToken=... put an ID token
        // in a URL, and from there into every proxy log and browser history on the path.
        //
        // Asserted on the cursor because it is the one field where the two behaviours give visibly
        // different answers: a body cursor of null means "start from the beginning" and succeeds, while
        // the garbage in the query string fails its integrity tag. A test on enrollment could not tell
        // the difference — every malformed enrollment is rejected either way.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var response = await client.PostContractAsync(
            $"/api/v1/vaults/{vaultId}/sync/pull?cursor=bm90LWEtcmVhbC1jdXJzb3I",
            new SyncPullRequest(Cursor: null, Limit: null, EntityTypes: null));

        response.StatusCode.ShouldBe(HttpStatusCode.OK);
    }

    [Fact]
    public async Task TheRejectionIsARfc9457ProblemDocument()
    {
        // The framework's own binding-failure body is neither a problem document nor free of .NET type
        // names, despite being served as application/problem+json. Asserted in full here because this
        // response is assembled by hand rather than by TypedResults.Problem, so nothing else keeps the
        // two shapes in step.
        var client = fixture.CreateClientFor(subject: $"binding-{Guid.CreateVersion7()}");
        using var content = new StringContent(
            """{"nope":1}""",
            Encoding.UTF8,
            "application/json");

        var response = await client.PostAsync(
            new Uri(EnrollUrl, UriKind.Relative),
            content,
            TestContext.Current.CancellationToken);

        response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
        response.Content.Headers.ContentType?.MediaType.ShouldBe("application/problem+json");

        var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
        body.ShouldNotContain("DodoSSH.Contracts", Case.Sensitive);

        var problem = await response.Content.ReadProblemAsync();
        problem.ShouldNotBeNull();
        problem.Type.ShouldBe(ProblemCodes.TypeBaseUri + ProblemCodes.MalformedRequest);
        problem.Detail.ShouldNotBeNullOrWhiteSpace();
    }

    /// <remarks>
    /// Seeded directly rather than driven through the enrollment endpoint, for the reason
    /// <see cref="Seed"/> gives: a failure here should mean the binder is wrong, not that enrollment is.
    /// </remarks>
    private async Task<(string Subject, Guid VaultId)> SeedUserWithVaultAsync()
    {
        var subject = $"binding-{Guid.CreateVersion7():N}";
        var now = TimeProvider.System.GetUtcNow();

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var user = new UserAccount
        {
            Id = Guid.CreateVersion7(),
            Issuer = fixture.IdentityProvider.Authority,
            Subject = subject,
            Status = UserStatus.Active,
            CreatedAtUtc = now,
            UpdatedAtUtc = now,
        };

        var vault = new Vault
        {
            Id = Guid.CreateVersion7(),
            Name = "Personal",
            OwnerKind = VaultOwnerKind.Personal,
            OwnerUserId = user.Id,
            KeyGeneration = 1,
            CreatedAtUtc = now,
            UpdatedAtUtc = now,
        };

        database.Users.Add(user);
        database.UserKeys.Add(Seed.CurrentKey(user.Id, now));
        database.Vaults.Add(vault);
        await database.SaveChangesAsync(TestContext.Current.CancellationToken);

        return (subject, vault.Id);
    }

    private async Task<JsonProblem> PostRawAsync(string json, string query = "")
    {
        var client = fixture.CreateClientFor(subject: $"binding-{Guid.CreateVersion7()}");
        using var content = new StringContent(json, Encoding.UTF8, "application/json");

        var response = await client.PostAsync(
            new Uri(EnrollUrl + query, UriKind.Relative),
            content,
            TestContext.Current.CancellationToken);

        response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);

        var problem = await response.Content.ReadProblemAsync();
        problem.ShouldNotBeNull();
        return problem;
    }
}
