using System.Collections.Specialized;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Auth.Tests;

/// <summary>
/// The authorization code flow end to end, and the rejections that make it safe.
/// </summary>
/// <remarks>
/// A loopback redirect is reachable by every process on the machine, so most of the value here is in
/// the negative cases: a response with the wrong state, a provider whose metadata disagrees with its
/// own authority, an ID token that binds a different key statement.
/// </remarks>
public sealed class OidcClientTests : IDisposable
{
    private readonly StubProvider provider = new();
    private readonly HttpClient http = new();

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

    // ---- The happy path ----

    [Fact]
    public async Task SignIn_CompletesTheFlowAndReturnsTokens()
    {
        provider.StubTokenResponse();
        var browser = new FakeBrowser();

        var tokens = await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken);

        tokens.AccessToken.ShouldBe("access-token");
        tokens.RefreshToken.ShouldBe("refresh-token");
        tokens.ExpiresAtUtc.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow());

        await browser.CallbackDelivery!;
    }

    [Fact]
    public async Task TheAuthorizationRequest_CarriesEverythingTheFlowNeeds()
    {
        // The client_id is asserted explicitly: an authorization request without it is rejected by
        // the provider with an opaque error, and it is easy to drop while refactoring the builder.
        provider.StubTokenResponse();
        var browser = new FakeBrowser();

        await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken);

        var parameters = browser.AuthorizeParameters;

        parameters["response_type"].ShouldBe("code");
        parameters["client_id"].ShouldBe("dodossh-desktop");
        parameters["code_challenge_method"].ShouldBe("S256");
        parameters["code_challenge"].ShouldNotBeNullOrWhiteSpace();
        parameters["state"].ShouldNotBeNullOrWhiteSpace();
        parameters["scope"].ShouldBe("openid profile email offline_access");
        parameters["redirect_uri"].ShouldNotBeNull();
        parameters["redirect_uri"]!.ShouldStartWith("http://127.0.0.1:");

        await browser.CallbackDelivery!;
    }

    [Fact]
    public async Task TheTokenExchange_SendsTheVerifierAndTheSameRedirectUri()
    {
        // The token endpoint compares redirect_uri byte-for-byte against what it saw at
        // authorization time, so a reconstructed value fails in a way that is hard to diagnose.
        provider.StubTokenResponse();
        var browser = new FakeBrowser();

        await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken);

        var form = provider.LastTokenRequestForm();

        form["grant_type"].ShouldBe("authorization_code");
        form["code"].ShouldBe("authorization-code");
        form["client_id"].ShouldBe("dodossh-desktop");
        form["code_verifier"].ShouldNotBeNullOrWhiteSpace();
        form["redirect_uri"].ShouldBe(browser.AuthorizeParameters["redirect_uri"]);

        await browser.CallbackDelivery!;
    }

    [Fact]
    public async Task TheVerifierSent_MatchesTheChallengeAdvertised()
    {
        // Otherwise PKCE is theatre: the provider would reject it, but only in production.
        provider.StubTokenResponse();
        var browser = new FakeBrowser();

        await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken);

        var verifier = provider.LastTokenRequestForm()["code_verifier"];
        var challenge = browser.AuthorizeParameters["code_challenge"];

        Recompute(verifier).ShouldBe(challenge);

        await browser.CallbackDelivery!;
    }

    // ---- Rejections ----

    [Fact]
    public async Task AResponseWithTheWrongState_IsRejected()
    {
        // The CSRF defence, and on loopback the defence against any local process racing to deliver
        // its own code to our listener.
        provider.StubTokenResponse();

        var browser = new FakeBrowser(_ => new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["code"] = "attacker-code",
            ["state"] = "not-the-state-we-sent",
        });

        var exception = await Should.ThrowAsync<OidcException>(async () =>
            await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));

        exception.Message.ShouldContain("state");
    }

    [Fact]
    public async Task AResponseWithTheWrongState_NeverReachesTheTokenEndpoint()
    {
        // A rejection that still redeemed the code would defeat the point.
        provider.StubTokenResponse();

        var browser = new FakeBrowser(_ => new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["code"] = "attacker-code",
            ["state"] = "wrong",
        });

        await Should.ThrowAsync<OidcException>(async () =>
            await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));

        Should.Throw<InvalidOperationException>(() => provider.LastTokenRequestForm());
    }

    [Fact]
    public async Task AProviderRefusal_SurfacesWithItsErrorCode()
    {
        var browser = new FakeBrowser(_ => new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["error"] = "access_denied",
            ["error_description"] = "The user said no",
        });

        var exception = await Should.ThrowAsync<OidcException>(async () =>
            await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));

        exception.ErrorCode.ShouldBe("access_denied");
        exception.Message.ShouldContain("The user said no");
    }

    [Fact]
    public async Task ACallbackWithNoCode_IsRejected()
    {
        var browser = new FakeBrowser(parameters =>
            new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["state"] = parameters["state"] ?? string.Empty,
            });

        await Should.ThrowAsync<OidcException>(async () =>
            await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));
    }

    [Fact]
    public async Task ATokenEndpointError_Surfaces()
    {
        provider.StubTokenError(400, "invalid_grant", "Code already redeemed");
        var browser = new FakeBrowser();

        var exception = await Should.ThrowAsync<OidcException>(async () =>
            await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));

        exception.ErrorCode.ShouldBe("invalid_grant");
    }

    [Fact]
    public async Task AProviderThatDoesNotOfferS256_IsRejected()
    {
        // Falling back to 'plain' is not an option for a public client, so this is fatal rather than
        // a silent downgrade.
        using var plainOnly = new StubProvider(advertiseS256: false);
        plainOnly.StubTokenResponse();

        var client = new OidcClient(
            http,
            new FakeBrowser(),
            TimeProvider.System,
            OptionsFor(plainOnly.Authority));

        var exception = await Should.ThrowAsync<OidcException>(async () =>
            await client.SignInAsync(TestContext.Current.CancellationToken));

        exception.Message.ShouldContain("S256");
    }

    [Fact]
    public async Task AProviderWhoseMetadataDeclaresAnotherIssuer_IsRejected()
    {
        // RFC 8414 §3.3. Without this check an attacker-controlled authority can serve metadata
        // pointing at its own token endpoint, and the client hands over the code believing it is
        // talking to the real provider.
        using var lying = new StubProvider(issuerOverride: "https://someone-else.example");

        var client = new OidcClient(
            http,
            new FakeBrowser(),
            TimeProvider.System,
            OptionsFor(lying.Authority));

        var exception = await Should.ThrowAsync<OidcException>(async () =>
            await client.SignInAsync(TestContext.Current.CancellationToken));

        exception.Message.ShouldContain("issuer");
    }

    [Fact]
    public async Task APlaintextAuthority_IsRejectedWhenHttpsIsRequired()
    {
        var client = new OidcClient(
            http,
            new FakeBrowser(),
            TimeProvider.System,
            new OidcClientOptions
            {
                Authority = provider.Authority,
                ClientId = "dodossh-desktop",
                RequireHttpsMetadata = true,
            });

        var exception = await Should.ThrowAsync<OidcException>(async () =>
            await client.SignInAsync(TestContext.Current.CancellationToken));

        exception.Message.ShouldContain("HTTPS");
    }

    // ---- Refresh ----

    [Fact]
    public async Task Refresh_ExchangesTheToken()
    {
        provider.StubTokenResponse(accessToken: "fresh-access", refreshToken: "rotated-refresh");

        var tokens = await CreateClient(new FakeBrowser())
            .RefreshAsync("old-refresh", TestContext.Current.CancellationToken);

        tokens.AccessToken.ShouldBe("fresh-access");
        tokens.RefreshToken.ShouldBe("rotated-refresh");

        var form = provider.LastTokenRequestForm();
        form["grant_type"].ShouldBe("refresh_token");
        form["refresh_token"].ShouldBe("old-refresh");
        form["client_id"].ShouldBe("dodossh-desktop");
    }

    [Fact]
    public async Task Refresh_CarriesForwardATokenTheProviderDidNotRotate()
    {
        // Many providers omit refresh_token when they do not rotate. Returning null there would make
        // the caller discard a token that is still valid and sign the user out on the next launch.
        provider.StubTokenResponse(refreshToken: null);

        var tokens = await CreateClient(new FakeBrowser())
            .RefreshAsync("still-good", TestContext.Current.CancellationToken);

        tokens.RefreshToken.ShouldBe("still-good");
    }

    [Fact]
    public async Task AnAbsentExpiresIn_YieldsAShortLifetimeRatherThanNone()
    {
        // Treating "unspecified" as "never expires" hands the caller a token it never refreshes.
        provider.StubTokenResponse(expiresInSeconds: null);

        var before = TimeProvider.System.GetUtcNow();

        var tokens = await CreateClient(new FakeBrowser())
            .RefreshAsync("refresh", TestContext.Current.CancellationToken);

        tokens.ExpiresAtUtc.ShouldBeGreaterThan(before);
        tokens.ExpiresAtUtc.ShouldBeLessThan(before.AddMinutes(10));
    }

    // ---- The key binding ----

    [Fact]
    public async Task AuthorizeKeyBinding_SendsTheNonceAndForcesAFreshLogin()
    {
        // The primary public-key trust anchor. prompt=login means the assertion attests to a user
        // present now, not to a session opened at some unknown earlier time.
        var nonce = NonceFor("alice");
        provider.StubTokenResponse(includeIdToken: true, idTokenNonce: nonce);

        var browser = new FakeBrowser();

        var idToken = await CreateClient(browser)
            .AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken);

        idToken.ShouldNotBeNullOrWhiteSpace();

        var parameters = browser.AuthorizeParameters;
        parameters["nonce"].ShouldBe(nonce);
        parameters["prompt"].ShouldBe("login");

        await browser.CallbackDelivery!;
    }

    [Fact]
    public async Task AuthorizeKeyBinding_RequestsOnlyOpenid()
    {
        // A second refresh token would be one more long-lived credential to store for no benefit.
        var nonce = NonceFor("alice");
        provider.StubTokenResponse(includeIdToken: true, idTokenNonce: nonce);

        var browser = new FakeBrowser();

        await CreateClient(browser).AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken);

        browser.AuthorizeParameters["scope"].ShouldBe("openid");

        await browser.CallbackDelivery!;
    }

    [Fact]
    public async Task AuthorizeKeyBinding_RejectsATokenBindingADifferentStatement()
    {
        // Accepting it would store evidence that verifies against keys we are not publishing, which
        // is precisely the fabrication the binding exists to prevent.
        var requested = NonceFor("alice");
        var somethingElse = NonceFor("bob");

        provider.StubTokenResponse(includeIdToken: true, idTokenNonce: somethingElse);

        var exception = await Should.ThrowAsync<OidcException>(async () =>
            await CreateClient(new FakeBrowser())
                .AuthorizeKeyBindingAsync(requested, TestContext.Current.CancellationToken));

        exception.Message.ShouldContain("nonce");
    }

    [Fact]
    public async Task AuthorizeKeyBinding_RejectsATokenWithNoNonceAtAll()
    {
        var nonce = NonceFor("alice");
        provider.StubTokenResponse(includeIdToken: true, idTokenNonce: null);

        await Should.ThrowAsync<OidcException>(async () =>
            await CreateClient(new FakeBrowser())
                .AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken));
    }

    [Fact]
    public async Task AuthorizeKeyBinding_RejectsAResponseWithNoIdToken()
    {
        var nonce = NonceFor("alice");
        provider.StubTokenResponse(includeIdToken: false);

        var exception = await Should.ThrowAsync<OidcException>(async () =>
            await CreateClient(new FakeBrowser())
                .AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken));

        exception.Message.ShouldContain("ID token");
    }

    // ---- Helpers ----

    private OidcClient CreateClient(IBrowserLauncher browser) =>
        new(http, browser, TimeProvider.System, OptionsFor(provider.Authority));

    private static OidcClientOptions OptionsFor(Uri authority) =>
        new()
        {
            Authority = authority,
            ClientId = "dodossh-desktop",

            // The stub serves plaintext on a loopback port.
            RequireHttpsMetadata = false,
        };

    /// <summary>A real binding nonce, from a real key statement.</summary>
    private static string NonceFor(string subject) =>
        KeyStatementCodec.ComputeNonce(new KeyStatementFields(
            Version: 1,
            Issuer: "https://idp.example",
            Subject: subject,
            Email: null,
            EncryptionPublicKey: [.. Enumerable.Range(0, 32).Select(i => (byte)(0x40 + i))],
            SigningPublicKey: [.. Enumerable.Range(0, 32).Select(i => (byte)(0x60 + i))],
            KeyGeneration: 1,
            CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123),
            DeviceName: "laptop"));

    /// <summary>Recomputes an S256 challenge from a verifier, independently of PkcePair.</summary>
    private static string Recompute(string? verifier) =>
        System.Buffers.Text.Base64Url.EncodeToString(
            System.Security.Cryptography.SHA256.HashData(
                System.Text.Encoding.ASCII.GetBytes(verifier ?? string.Empty)));
}
