package oidc

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sync/atomic"
	"testing"

	"github.com/stretchr/testify/require"
)

func newOIDCTestServer(t *testing.T) (*httptest.Server, *Provider, *url.Values) {
	t.Helper()
	var tokenForm url.Values
	var discoveryRequests atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/.well-known/openid-configuration":
			discoveryRequests.Add(1)
			require.Equal(t, http.MethodGet, r.Method)
			_ = json.NewEncoder(w).Encode(Discovery{
				Issuer:                "issuer",
				AuthorizationEndpoint: serverURL(r) + "/authorize",
				TokenEndpoint:         serverURL(r) + "/token",
				UserInfoEndpoint:      serverURL(r) + "/userinfo",
			})
		case "/token":
			require.Equal(t, http.MethodPost, r.Method)
			require.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
			require.NoError(t, r.ParseForm())
			copied := make(url.Values, len(r.PostForm))
			for key, values := range r.PostForm {
				copied[key] = append([]string(nil), values...)
			}
			tokenForm = copied
			_ = json.NewEncoder(w).Encode(TokenResponse{AccessToken: "access-token", TokenType: "Bearer", ExpiresIn: 3600, IDToken: "id-token"})
		case "/userinfo":
			require.Equal(t, "Bearer access-token", r.Header.Get("Authorization"))
			_ = json.NewEncoder(w).Encode(UserInfo{OpenID: "openid", DisplayName: "Ada", Email: "ada@example.com", EmailVerified: true, AvatarURL: "https://avatar"})
		default:
			http.NotFound(w, r)
		}
	}))
	provider := NewProvider(server.URL+"/", "client-id", "client-secret", "https://app/callback", []string{"openid", "email"})
	provider.HTTPClient = server.Client()
	t.Cleanup(func() {
		require.Equal(t, int32(1), discoveryRequests.Load())
	})
	return server, provider, &tokenForm
}

func serverURL(r *http.Request) string {
	return "http://" + r.Host
}

func TestProviderDiscoveryAuthorizationTokenAndUserInfo(t *testing.T) {
	t.Parallel()

	server, provider, tokenForm := newOIDCTestServer(t)
	defer server.Close()
	require.Equal(t, server.URL, provider.Issuer)

	authorizationURL, err := provider.BuildAuthorizationURL("state", "nonce", "challenge")
	require.NoError(t, err)
	parsed, err := url.Parse(authorizationURL)
	require.NoError(t, err)
	require.Equal(t, "/authorize", parsed.Path)
	require.Equal(t, "client-id", parsed.Query().Get("client_id"))
	require.Equal(t, "https://app/callback", parsed.Query().Get("redirect_uri"))
	require.Equal(t, "openid email", parsed.Query().Get("scope"))
	require.Equal(t, "challenge", parsed.Query().Get("code_challenge"))
	require.Equal(t, "S256", parsed.Query().Get("code_challenge_method"))

	token, err := provider.ExchangeCodeForToken(context.Background(), "auth-code", "verifier")
	require.NoError(t, err)
	require.Equal(t, "access-token", token.AccessToken)
	require.Equal(t, "auth-code", tokenForm.Get("code"))
	require.Equal(t, "client-secret", tokenForm.Get("client_secret"))
	require.Equal(t, "verifier", tokenForm.Get("code_verifier"))

	userinfo, err := provider.GetUserInfo(context.Background(), "access-token")
	require.NoError(t, err)
	require.Equal(t, "openid", userinfo.OpenID)
	require.True(t, userinfo.EmailVerified)
}

func TestPKCEAndRandomValuesHaveURLSafeShape(t *testing.T) {
	t.Parallel()

	verifier, err := GenerateCodeVerifier()
	require.NoError(t, err)
	decoded, err := base64.RawURLEncoding.DecodeString(verifier)
	require.NoError(t, err)
	require.Len(t, decoded, 32)
	require.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", GenerateCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"))

	state, err := GenerateRandomState()
	require.NoError(t, err)
	decoded, err = base64.RawURLEncoding.DecodeString(state)
	require.NoError(t, err)
	require.Len(t, decoded, 16)

	nonce, err := GenerateRandomNonce()
	require.NoError(t, err)
	decoded, err = base64.RawURLEncoding.DecodeString(nonce)
	require.NoError(t, err)
	require.Len(t, decoded, 16)
}

func TestProviderReturnsEndpointFailures(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "nope", http.StatusBadGateway)
	}))
	defer server.Close()
	provider := NewProvider(server.URL, "client", "", "https://app/callback", nil)
	provider.HTTPClient = server.Client()

	_, err := provider.Discover(context.Background())
	require.ErrorContains(t, err, "discovery endpoint returned status 502")
}

func TestProviderDoesNotCacheFailedDiscovery(t *testing.T) {
	t.Parallel()

	var discoveryRequests atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/.well-known/openid-configuration", r.URL.Path)
		if discoveryRequests.Add(1) == 1 {
			http.Error(w, "temporary failure", http.StatusBadGateway)
			return
		}
		_ = json.NewEncoder(w).Encode(Discovery{
			Issuer:                serverURL(r),
			AuthorizationEndpoint: serverURL(r) + "/authorize",
			TokenEndpoint:         serverURL(r) + "/token",
			UserInfoEndpoint:      serverURL(r) + "/userinfo",
		})
	}))
	defer server.Close()

	provider := NewProvider(server.URL, "client", "", "https://app/callback", nil)
	provider.HTTPClient = server.Client()

	_, err := provider.Discover(context.Background())
	require.ErrorContains(t, err, "discovery endpoint returned status 502")

	discovery, err := provider.Discover(context.Background())
	require.NoError(t, err)
	require.Equal(t, server.URL+"/authorize", discovery.AuthorizationEndpoint)
	require.Equal(t, int32(2), discoveryRequests.Load())
}

func TestProviderTokenFailureOmitsResponseBody(t *testing.T) {
	t.Parallel()

	const sensitiveBody = "token=secret-token user@example.com"
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		switch request.URL.Path {
		case "/.well-known/openid-configuration":
			_ = json.NewEncoder(writer).Encode(Discovery{TokenEndpoint: serverURL(request) + "/token"})
		case "/token":
			writer.WriteHeader(http.StatusUnauthorized)
			_, _ = writer.Write([]byte(sensitiveBody))
		default:
			http.NotFound(writer, request)
		}
	}))
	defer server.Close()
	provider := NewProvider(server.URL, "client", "secret", "https://app/callback", nil, WithHTTPClient(server.Client()))

	_, err := provider.ExchangeCodeForToken(context.Background(), "code", "verifier")
	require.ErrorContains(t, err, "token endpoint returned status 401")
	require.NotContains(t, err.Error(), sensitiveBody)
	require.NotContains(t, err.Error(), "secret-token")
	require.NotContains(t, err.Error(), "user@example.com")
}
