package oidc

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
	"github.com/lestrrat-go/jwx/v3/jwt"
	"github.com/ory/fosite"
	"github.com/stretchr/testify/require"

	"github.com/pocket-id/pocket-id/backend/internal/model"
	jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
	testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)

// TestIntrospectionHandlerBindsTokenToCallerClient verifies that an RFC 6750 bearer-auth
// caller (which omits client_id) can only introspect its own client's tokens. A client
// must not be able to introspect another client's token — doing so would leak the token's
// validity and the user PII it carries.
func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) {
	gin.SetMode(gin.TestMode)

	db := testutils.NewDatabaseForTest(t)
	require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-a"}, Name: "Client A"}).Error)
	require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-b"}, Name: "Client B"}).Error)

	signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	require.NoError(t, err)

	provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
		BaseURL:      "https://issuer.example.com",
		TokenBaseURL: "https://issuer.example.com",
		Secret:       []byte("test-secret"),
	})
	require.NoError(t, err)

	issueAccessToken := func(t *testing.T, requestID, clientID, subject string) string {
		t.Helper()
		session := NewEmptySession()
		session.Subject = subject
		session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))

		request := fosite.NewAccessRequest(session)
		request.ID = requestID
		request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
		request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
		request.RequestedScope = fosite.Arguments{"openid"}
		request.GrantedScope = fosite.Arguments{"openid"}
		request.RequestedAudience = fosite.Arguments{clientID}
		request.GrantedAudience = fosite.Arguments{clientID}

		response, err := provider.NewAccessResponse(t.Context(), request)
		require.NoError(t, err)
		return response.GetAccessToken()
	}

	clientAToken := issueAccessToken(t, "req-a", "client-a", "user-a")
	clientBToken := issueAccessToken(t, "req-b", "client-b", "user-b")
	clientBOtherToken := issueAccessToken(t, "req-b-2", "client-b", "user-b")

	handler := newIntrospectionHandler(provider, nil, "https://issuer.example.com")

	introspect := func(t *testing.T, bearer, token string) map[string]any {
		t.Helper()
		body := url.Values{"token": {token}}.Encode()
		req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Authorization", "Bearer "+bearer)

		rec := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(rec)
		c.Request = req

		handler.introspectToken(c)

		var out map[string]any
		require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
		return out
	}

	// A client using its own token as bearer must not learn about another client's token.
	cross := introspect(t, clientBToken, clientAToken)
	require.Equal(t, false, cross["active"])

	// A client may introspect another of its own tokens.
	same := introspect(t, clientBToken, clientBOtherToken)
	require.Equal(t, true, same["active"])
}

func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T) {
	gin.SetMode(gin.TestMode)

	const (
		baseURL  = "https://issuer.example.com"
		issuer   = "https://idp.example.com"
		clientID = "federated-client"
		subject  = "external-workload"
		audience = "api://pocket-id"
		jwksURL  = "https://idp.example.com/jwks.json"
	)

	signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
	require.NoError(t, err)
	signingAlg, ok := signingKey.Algorithm()
	require.True(t, ok)

	publicKey, err := signingKey.PublicKey()
	require.NoError(t, err)
	jwks := jwk.NewSet()
	require.NoError(t, jwks.AddKey(publicKey))

	db := testutils.NewDatabaseForTest(t)
	replayProtection := false
	require.NoError(t, db.Create(&model.OidcClient{
		Base: model.Base{ID: clientID},
		Name: "Federated Client",
		Credentials: model.OidcClientCredentials{
			FederatedIdentities: []model.OidcClientFederatedIdentity{{
				Issuer:           issuer,
				Subject:          subject,
				Audience:         audience,
				JWKS:             jwksURL,
				ReplayProtection: replayProtection,
			}},
		},
	}).Error)

	store := NewStore(db, nil)
	authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), baseURL)
	require.NoError(t, err)

	signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	require.NoError(t, err)
	provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
		BaseURL:      baseURL,
		TokenBaseURL: baseURL,
		Secret:       []byte("test-secret"),
	})
	require.NoError(t, err)

	session := NewEmptySession()
	session.Subject = "user-a"
	session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))

	accessRequest := fosite.NewAccessRequest(session)
	accessRequest.ID = "replay-test-request"
	accessRequest.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
	accessRequest.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
	accessRequest.RequestedScope = fosite.Arguments{"openid"}
	accessRequest.GrantedScope = fosite.Arguments{"openid"}
	accessRequest.RequestedAudience = fosite.Arguments{clientID}
	accessRequest.GrantedAudience = fosite.Arguments{clientID}

	accessResponse, err := provider.NewAccessResponse(t.Context(), accessRequest)
	require.NoError(t, err)

	assertionToken, err := jwt.NewBuilder().
		Issuer(issuer).
		Subject(subject).
		Audience([]string{audience}).
		IssuedAt(time.Now()).
		Expiration(time.Now().Add(5 * time.Minute)).
		JwtID("cached-provider-token").
		Build()
	require.NoError(t, err)
	signedAssertion, err := jwt.Sign(assertionToken, jwt.WithKey(signingAlg, signingKey))
	require.NoError(t, err)

	handler := newIntrospectionHandler(provider, authenticator, baseURL)
	introspect := func(t *testing.T) (int, map[string]any) {
		t.Helper()
		body := url.Values{
			"client_id": {clientID},
			"token":     {accessResponse.GetAccessToken()},
		}.Encode()
		req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		req.Header.Set("Authorization", "Bearer "+string(signedAssertion))

		rec := httptest.NewRecorder()
		c, _ := gin.CreateTestContext(rec)
		c.Request = req
		handler.introspectToken(c)

		var out map[string]any
		require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
		return rec.Code, out
	}

	status, body := introspect(t)
	require.Equal(t, http.StatusOK, status)
	require.Equal(t, true, body["active"])

	status, body = introspect(t)
	require.Equal(t, http.StatusOK, status)
	require.Equal(t, true, body["active"])
}
