package httpapi

import (
	"bytes"
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
	"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
	"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
	"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
	"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
)

func TestLocalCrossRepositoryMultiTenantGatewayFlow(t *testing.T) {
	databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
	onboardingContract := strings.TrimSpace(os.Getenv("AUTH_CENTER_ONBOARDING_CONTRACT"))
	tenantRuntimeContract := strings.TrimSpace(os.Getenv("AUTH_CENTER_TENANT_RUNTIME_CONTRACT"))
	if databaseURL == "" || onboardingContract == "" || tenantRuntimeContract == "" {
		t.Skip("set the local Gateway database and Auth Center contract paths to run the cross-repository E2E")
	}
	assertAuthCenterMultiTenantContracts(t, onboardingContract, tenantRuntimeContract)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	applyMigration(t, ctx, databaseURL)
	db, err := store.Connect(ctx, databaseURL)
	if err != nil {
		t.Fatalf("connect Gateway store: %v", err)
	}
	t.Cleanup(db.Close)
	breakGlassCreated := false
	var breakGlassUserID string
	hasBreakGlass, err := db.HasBreakGlassManager(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if !hasBreakGlass {
		manager, registerErr := db.RegisterLocalUser(ctx, store.LocalRegisterInput{
			Username: "multi-tenant-e2e-manager-" + uuid.NewString(),
			Password: uuid.NewString() + "-local-only",
		})
		if registerErr != nil {
			t.Fatalf("create local Break-glass Manager fixture: %v", registerErr)
		}
		breakGlassCreated, breakGlassUserID = true, manager.ID
	}

	applicationID := uuid.NewString()
	tenantA, tenantB := uuid.NewString(), uuid.NewString()
	subject := "shared-cross-repository-subject-" + uuid.NewString()
	browserClientID := "gateway-browser-" + uuid.NewString()
	machineClientID := "gateway-machine-" + uuid.NewString()
	machineSecret := "local-cross-repository-machine-secret"
	audience := "urn:easyai:gateway:" + applicationID

	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	var issuer, ssfIssuer, gatewayBaseURL string
	ssfAudience := "urn:easyai:ssf:receiver:" + applicationID
	var revokedRefreshTokens atomic.Int64
	type authorization struct {
		TenantID string
		Nonce    string
	}
	var authorizationMu sync.Mutex
	authorizations := map[string]authorization{}
	var ssfStreamMu sync.Mutex
	var ssfStream map[string]any
	tenantNames := map[string]string{tenantA: "本地租户 A", tenantB: "本地租户 B"}
	tenantSlugs := map[string]string{tenantA: "local-tenant-a", tenantB: "local-tenant-b"}

	authCenter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case r.URL.Path == "/.well-known/openid-configuration":
			writeTestJSON(w, map[string]any{
				"issuer": issuer, "jwks_uri": issuer + "/jwks",
				"authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token",
				"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
				"introspection_endpoint": issuer + "/introspect",
			})
		case r.URL.Path == "/.well-known/ssf-configuration/ssf":
			writeTestJSON(w, map[string]any{
				"spec_version": "1_0", "issuer": ssfIssuer, "jwks_uri": issuer + "/jwks",
				"configuration_endpoint":     issuer + "/ssf/streams",
				"status_endpoint":            issuer + "/ssf/status",
				"verification_endpoint":      issuer + "/ssf/v1/verify",
				"delivery_methods_supported": []string{"urn:ietf:rfc:8935"},
			})
		case r.URL.Path == "/jwks":
			writeTestJSON(w, map[string]any{"keys": []any{oidcJITECJWK("multi-tenant-key", &key.PublicKey)}})
		case r.URL.Path == "/authorize":
			tenantID := r.URL.Query().Get("tenant_hint")
			if tenantID != tenantA && tenantID != tenantB ||
				r.URL.Query().Get("client_id") != browserClientID ||
				r.URL.Query().Get("code_challenge_method") != "S256" {
				http.Error(w, "authorization request rejected", http.StatusBadRequest)
				return
			}
			code := uuid.NewString()
			authorizationMu.Lock()
			authorizations[code] = authorization{TenantID: tenantID, Nonce: r.URL.Query().Get("nonce")}
			authorizationMu.Unlock()
			callback := gatewayBaseURL + "/api/v1/auth/oidc/callback?code=" + url.QueryEscape(code) +
				"&state=" + url.QueryEscape(r.URL.Query().Get("state"))
			http.Redirect(w, r, callback, http.StatusSeeOther)
		case r.URL.Path == "/token":
			if err := r.ParseForm(); err != nil {
				http.Error(w, "invalid token request", http.StatusBadRequest)
				return
			}
			if r.Form.Get("grant_type") == "client_credentials" {
				clientID, secret, ok := r.BasicAuth()
				if !ok || clientID != machineClientID || secret != machineSecret || r.Form.Get("scope") == "" {
					http.Error(w, "machine token rejected", http.StatusUnauthorized)
					return
				}
				writeTestJSON(w, map[string]any{
					"access_token": "opaque-local-machine-token", "token_type": "Bearer", "expires_in": 300,
				})
				return
			}
			code := r.Form.Get("code")
			authorizationMu.Lock()
			requested, ok := authorizations[code]
			delete(authorizations, code)
			authorizationMu.Unlock()
			if !ok || r.Form.Get("grant_type") != "authorization_code" ||
				r.Form.Get("client_id") != browserClientID || r.Form.Get("client_secret") != "" {
				http.Error(w, "authorization code rejected", http.StatusBadRequest)
				return
			}
			accessToken := signedMultiTenantE2EToken(
				t, key, issuer, audience, browserClientID, applicationID, requested.TenantID, subject, "",
			)
			idToken := signedMultiTenantE2EToken(
				t, key, issuer, browserClientID, browserClientID, applicationID, requested.TenantID, subject, requested.Nonce,
			)
			writeTestJSON(w, map[string]any{
				"access_token": accessToken, "refresh_token": "opaque-refresh-" + requested.TenantID,
				"id_token": idToken, "token_type": "Bearer", "expires_in": 300,
			})
		case r.URL.Path == "/introspect":
			clientID, secret, ok := r.BasicAuth()
			if !ok || clientID != machineClientID || secret != machineSecret {
				http.Error(w, "introspection rejected", http.StatusUnauthorized)
				return
			}
			writeTestJSON(w, map[string]any{"active": true})
		case r.URL.Path == "/revoke":
			revokedRefreshTokens.Add(1)
			w.WriteHeader(http.StatusOK)
		case r.URL.Path == "/logout":
			http.Redirect(w, r, r.URL.Query().Get("post_logout_redirect_uri"), http.StatusSeeOther)
		case strings.HasPrefix(r.URL.Path, "/api/v1/runtime/tenants/"):
			tenantID := strings.TrimPrefix(r.URL.Path, "/api/v1/runtime/tenants/")
			if r.Header.Get("Authorization") != "Bearer opaque-local-machine-token" {
				http.Error(w, "tenant context unauthorized", http.StatusUnauthorized)
				return
			}
			displayName, ok := tenantNames[tenantID]
			if !ok {
				http.NotFound(w, r)
				return
			}
			etag := `"` + tenantID + `-v1"`
			if r.Header.Get("If-None-Match") == etag {
				w.WriteHeader(http.StatusNotModified)
				return
			}
			w.Header().Set("ETag", etag)
			writeTestJSON(w, identity.TenantContext{
				ApplicationID: applicationID, TenantID: tenantID, DisplayName: displayName, Slug: tenantSlugs[tenantID],
				TenantStatus: "active", TenantApplicationStatus: "active", Version: "1", UpdatedAt: time.Now().UTC(),
			})
		case r.URL.Path == "/ssf/streams":
			if r.Header.Get("Authorization") != "Bearer opaque-local-machine-token" {
				http.Error(w, "SSF management unauthorized", http.StatusUnauthorized)
				return
			}
			ssfStreamMu.Lock()
			defer ssfStreamMu.Unlock()
			if r.Method == http.MethodGet {
				if ssfStream == nil {
					writeTestJSON(w, []any{})
				} else {
					writeTestJSON(w, []any{ssfStream})
				}
				return
			}
			if r.Method != http.MethodPost {
				http.Error(w, "SSF method rejected", http.StatusMethodNotAllowed)
				return
			}
			var input struct {
				EventsRequested []string `json:"events_requested"`
				Description     string   `json:"description"`
				Delivery        struct {
					Method      string `json:"method"`
					EndpointURL string `json:"endpoint_url"`
				} `json:"delivery"`
			}
			if json.NewDecoder(r.Body).Decode(&input) != nil {
				http.Error(w, "SSF stream rejected", http.StatusBadRequest)
				return
			}
			ssfStream = map[string]any{
				"stream_id": uuid.NewString(), "iss": ssfIssuer, "aud": ssfAudience,
				"events_requested": input.EventsRequested, "description": input.Description,
				"delivery": map[string]any{"method": input.Delivery.Method, "endpoint_url": input.Delivery.EndpointURL},
			}
			writeTestJSON(w, ssfStream)
		case r.URL.Path == "/ssf/status":
			writeTestJSON(w, map[string]any{})
		case r.URL.Path == "/ssf/v1/verify":
			w.WriteHeader(http.StatusNoContent)
		default:
			http.NotFound(w, r)
		}
	}))
	defer authCenter.Close()
	issuer = authCenter.URL
	ssfIssuer = issuer + "/ssf"

	cfg := config.Config{
		AppEnv: "test", HTTPAddr: ":0", DatabaseURL: databaseURL, IdentityMode: "hybrid",
		JWTSecret: "local-cross-repository-jwt-secret", IdentitySecretStore: "file",
		IdentitySecretDir: t.TempDir(), LocalGeneratedStorageDir: t.TempDir(), LocalUploadedStorageDir: t.TempDir(),
		LocalTempAssetTTLHours: 1, CORSAllowedOrigin: "http://localhost:5178", TaskProgressCallbackEnabled: false,
	}
	previous, previousErr := db.ActiveIdentityConfigurationRevision(ctx)
	if previousErr != nil && !errors.Is(previousErr, identity.ErrRevisionNotFound) {
		t.Fatal(previousErr)
	}
	revision := prepareMultiTenantE2ERevision(
		t, ctx, db, cfg, issuer, ssfIssuer, ssfAudience,
		applicationID, audience, browserClientID, machineClientID, machineSecret,
	)
	revisionID := revision.ID
	t.Cleanup(func() {
		_, _ = db.Pool().Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE transmitter_issuer=$1`, ssfIssuer)
		_, _ = db.Pool().Exec(context.Background(), `DELETE FROM gateway_security_event_stream_state WHERE issuer=$1`, ssfIssuer)
		restoreOIDCJITIdentityRevision(t, context.Background(), db, previous, previousErr == nil, []string{revisionID})
		if breakGlassCreated {
			if err := db.DeleteGatewayUser(context.Background(), breakGlassUserID); err != nil {
				t.Errorf("delete local Break-glass Manager fixture: %v", err)
			}
		}
	})
	activeRevision, _, err := db.ActivateIdentityRevision(ctx, revision.ID, revision.Version, "multi-tenant-e2e", "multi-tenant-e2e")
	if err != nil {
		t.Fatalf("activate multi-tenant revision: %v", err)
	}
	revision = activeRevision
	gateway := httptest.NewServer(NewServerWithContext(ctx, cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
	defer gateway.Close()
	gatewayBaseURL = gateway.URL

	tokenA := signedMultiTenantE2EToken(t, key, issuer, audience, browserClientID, applicationID, tenantA, subject, "")
	tokenB := signedMultiTenantE2EToken(t, key, issuer, audience, browserClientID, applicationID, tenantB, subject, "")
	var meA, meB auth.User
	doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenA, nil, http.StatusOK, &meA)
	doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenB, nil, http.StatusOK, &meB)
	if meA.ID != subject || meB.ID != subject || meA.GatewayTenantID == meB.GatewayTenantID ||
		meA.GatewayUserID == meB.GatewayUserID || meA.TenantName != tenantNames[tenantA] || meB.TenantName != tenantNames[tenantB] {
		t.Fatalf("cross-tenant projection mismatch: A=%+v B=%+v", meA, meB)
	}

	var createdKey struct {
		APIKey struct {
			ID string `json:"id"`
		} `json:"apiKey"`
	}
	doOIDCJITJSON(t, gateway.URL, http.MethodPost, "/api/v1/api-keys", tokenA,
		map[string]any{"name": "multi-tenant-local-e2e"}, http.StatusCreated, &createdKey)
	var keysB struct {
		Items []struct {
			ID string `json:"id"`
		} `json:"items"`
	}
	doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/api-keys", tokenB, nil, http.StatusOK, &keysB)
	for _, item := range keysB.Items {
		if item.ID == createdKey.APIKey.ID {
			t.Fatal("Tenant A API Key crossed into Tenant B")
		}
	}

	switchJar := newMultiTenantE2ECookieJar(t)
	loginMultiTenantE2E(t, switchJar, gateway.URL, tenantA)
	loginMultiTenantE2E(t, switchJar, gateway.URL, tenantB)
	var switched auth.User
	doMultiTenantE2ECookieJSON(t, switchJar, gateway.URL+"/api/v1/me", http.StatusOK, &switched)
	if switched.TenantID != tenantB || revokedRefreshTokens.Load() != 1 {
		t.Fatalf("tenant switch did not replace A session: me=%+v revoked=%d", switched, revokedRefreshTokens.Load())
	}
	var switchedASessions int
	if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$1::uuid`, meA.GatewayTenantID).Scan(&switchedASessions); err != nil {
		t.Fatal(err)
	}
	if switchedASessions != 0 {
		t.Fatalf("tenant switch retained %d old Tenant A sessions", switchedASessions)
	}

	jarA, jarB := newMultiTenantE2ECookieJar(t), newMultiTenantE2ECookieJar(t)
	loginMultiTenantE2E(t, jarA, gateway.URL, tenantA)
	loginMultiTenantE2E(t, jarB, gateway.URL, tenantB)
	principalResult, err := db.ApplySessionRevoked(ctx, store.ApplySessionRevokedInput{
		Issuer: issuer + "/ssf", Audience: "urn:easyai:ssf:" + applicationID, JTI: uuid.NewString(),
		SubjectIssuer: issuer, ApplicationID: applicationID, SubjectType: "principal",
		TenantID: tenantA, Subject: subject, EventTimestamp: time.Now().UTC().Add(-2 * time.Second), InitiatingEntity: "local-e2e",
	})
	if err != nil || principalResult.SessionsDeleted != 1 {
		t.Fatalf("principal revocation result=%+v err=%v", principalResult, err)
	}
	doMultiTenantE2ECookieJSON(t, jarA, gateway.URL+"/api/v1/me", http.StatusUnauthorized, nil)
	doMultiTenantE2ECookieJSON(t, jarB, gateway.URL+"/api/v1/me", http.StatusOK, nil)

	loginMultiTenantE2E(t, jarA, gateway.URL, tenantA)
	tenantResult, err := db.ApplySessionRevoked(ctx, store.ApplySessionRevokedInput{
		Issuer: issuer + "/ssf", Audience: "urn:easyai:ssf:" + applicationID, JTI: uuid.NewString(),
		SubjectIssuer: issuer, ApplicationID: applicationID, SubjectType: "tenant",
		TenantID: tenantA, Subject: tenantA, EventTimestamp: time.Now().UTC().Add(-2 * time.Second), InitiatingEntity: "local-e2e",
	})
	if err != nil || tenantResult.SessionsDeleted != 1 {
		t.Fatalf("tenant revocation result=%+v err=%v", tenantResult, err)
	}
	doMultiTenantE2ECookieJSON(t, jarA, gateway.URL+"/api/v1/me", http.StatusUnauthorized, nil)
	doMultiTenantE2ECookieJSON(t, jarB, gateway.URL+"/api/v1/me", http.StatusOK, nil)

	var restored auth.User
	doOIDCJITJSON(t, gateway.URL, http.MethodGet, "/api/v1/me", tokenA, nil, http.StatusOK, &restored)
	if restored.GatewayTenantID != meA.GatewayTenantID || restored.GatewayUserID != meA.GatewayUserID {
		t.Fatalf("reassignment did not reuse projection: before=%+v after=%+v", meA, restored)
	}
	var survivingAPIKey int
	if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_api_keys WHERE id=$1::uuid`, createdKey.APIKey.ID).Scan(&survivingAPIKey); err != nil {
		t.Fatal(err)
	}
	if survivingAPIKey != 1 {
		t.Fatal("OIDC tenant revocation unexpectedly removed a Gateway API Key")
	}

	doOIDCJITJSON(t, gateway.URL, http.MethodPost, "/api/v1/auth/register", "",
		map[string]any{"username": "must-not-register", "password": "not-a-real-password"}, http.StatusForbidden, nil)
}

func assertAuthCenterMultiTenantContracts(t *testing.T, onboardingPath, runtimePath string) {
	t.Helper()
	for path, required := range map[string][]string{
		onboardingPath: {"schema_version", "multi_tenant", "tenant_context", "machine_to_machine"},
		runtimePath:    {"/api/v1/runtime/tenants/{tenantId}", "tenant.context.read", "If-None-Match", "ETag"},
	} {
		raw, err := os.ReadFile(filepath.Clean(path))
		if err != nil {
			t.Fatalf("read Auth Center contract %s: %v", filepath.Base(path), err)
		}
		for _, marker := range required {
			if !bytes.Contains(raw, []byte(marker)) {
				t.Fatalf("Auth Center contract %s is missing %q", filepath.Base(path), marker)
			}
		}
	}
}

func prepareMultiTenantE2ERevision(
	t *testing.T,
	ctx context.Context,
	db *store.Store,
	cfg config.Config,
	issuer, ssfIssuer, ssfAudience, applicationID, audience, browserClientID, machineClientID, machineSecret string,
) identity.Revision {
	t.Helper()
	draft, err := identity.NewDraft(identity.PairingInput{
		AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178",
		LegacyJWTEnabled: true,
	}, "test")
	if err != nil {
		t.Fatal(err)
	}
	draft, err = db.CreateIdentityConfigurationRevision(ctx, draft)
	if err != nil {
		t.Fatal(err)
	}
	secrets, err := identitySecretStore(cfg)
	if err != nil {
		t.Fatal(err)
	}
	machineRef, sessionRef := "multi-tenant-machine-"+draft.ID, "multi-tenant-session-"+draft.ID
	if err := secrets.Put(ctx, machineRef, []byte(machineSecret)); err != nil {
		t.Fatal(err)
	}
	if err := secrets.Put(ctx, sessionRef, bytes.Repeat([]byte{9}, 32)); err != nil {
		t.Fatal(err)
	}
	for _, ref := range []string{machineRef, sessionRef} {
		if err := db.QueueIdentitySecretCleanup(ctx, ref, time.Now().Add(10*time.Minute)); err != nil {
			t.Fatal(err)
		}
	}
	draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{
		Manifest: identity.ManifestV2{
			SchemaVersion: 2, TenantMode: "multi_tenant", Issuer: issuer, ApplicationID: applicationID,
			Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"},
			Audience:     audience, Scopes: []string{"gateway.access"},
			Clients: identity.ManifestClients{
				BrowserLogin:     &identity.ManifestClient{ClientID: browserClientID},
				MachineToMachine: &identity.ManifestClient{ClientID: machineClientID},
			},
			TenantContext: &identity.ManifestTenantContext{
				Endpoint: issuer + "/api/v1/runtime/tenants/{tenantId}",
				Audience: "urn:easyai:auth-center:tenant-context", Scope: "tenant.context.read",
			},
			SecurityEvents: &identity.ManifestSecurityEvents{
				TransmitterIssuer: ssfIssuer, ConfigurationEndpoint: issuer + "/ssf/streams", Audience: ssfAudience,
			},
		},
		MachineCredentialRef: machineRef, SessionEncryptionKeyRef: sessionRef,
		TraceID: "multi-tenant-local-e2e", AuditID: "multi-tenant-local-e2e", AppEnv: "test",
	})
	if err != nil {
		t.Fatal(err)
	}
	draft, err = db.MarkIdentityRevisionValidated(ctx, draft.ID, draft.Version, "multi-tenant-local-e2e", "multi-tenant-local-e2e")
	if err != nil {
		t.Fatal(err)
	}
	builder := identityruntime.NewRuntimeBuilder(ctx, db, secrets, identityruntime.RuntimeBuilderConfig{
		AppEnv: "test", HeartbeatInterval: time.Hour, StaleAfter: 3 * time.Hour, ClockSkew: time.Minute,
	}, nil)
	if err := builder.PrepareSecurityEvents(ctx, draft, []byte(machineSecret)); err != nil {
		t.Fatalf("prepare application-scoped SSF receiver: %v", err)
	}
	return draft
}

func signedMultiTenantE2EToken(
	t *testing.T,
	key *ecdsa.PrivateKey,
	issuer, audience, clientID, applicationID, tenantID, subject, nonce string,
) string {
	t.Helper()
	now := time.Now().UTC()
	claims := jwt.MapClaims{
		"iss": issuer, "aud": audience, "sub": subject, "tid": tenantID, "client_id": clientID,
		"application_id": applicationID, "preferred_username": "shared-local-user",
		"roles": []string{"gateway.user"}, "scope": "openid gateway.access",
		"iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": now.Add(time.Hour).Unix(),
	}
	if nonce != "" {
		claims["nonce"] = nonce
	}
	token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
	token.Header["kid"] = "multi-tenant-key"
	raw, err := token.SignedString(key)
	if err != nil {
		t.Fatal(err)
	}
	return raw
}

func newMultiTenantE2ECookieJar(t *testing.T) *cookiejar.Jar {
	t.Helper()
	jar, err := cookiejar.New(nil)
	if err != nil {
		t.Fatal(err)
	}
	return jar
}

func loginMultiTenantE2E(t *testing.T, jar *cookiejar.Jar, gatewayURL, tenantID string) {
	t.Helper()
	gateway, err := url.Parse(gatewayURL)
	if err != nil {
		t.Fatal(err)
	}
	client := &http.Client{Jar: jar, CheckRedirect: func(request *http.Request, via []*http.Request) error {
		if len(via) > 10 {
			return errors.New("too many redirects")
		}
		if request.URL.Host != gateway.Host && request.URL.Path != "/authorize" {
			return http.ErrUseLastResponse
		}
		return nil
	}}
	response, err := client.Get(gatewayURL + "/api/v1/auth/oidc/login?returnTo=%2F&tenantHint=" + url.QueryEscape(tenantID))
	if err != nil {
		t.Fatalf("complete tenant %s login: %v", tenantID[:8], err)
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusSeeOther && response.StatusCode != http.StatusOK {
		raw, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
		t.Fatalf("tenant %s login status=%d body=%s", tenantID[:8], response.StatusCode, raw)
	}
	for _, cookie := range jar.Cookies(gateway) {
		if cookie.Name == auth.OIDCSessionCookieName && cookie.Value != "" {
			return
		}
	}
	t.Fatalf("tenant %s login returned no opaque session", tenantID[:8])
}

func doMultiTenantE2ECookieJSON(t *testing.T, jar *cookiejar.Jar, requestURL string, expectedStatus int, output any) {
	t.Helper()
	client := &http.Client{Jar: jar}
	response, err := client.Get(requestURL)
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
	if err != nil {
		t.Fatal(err)
	}
	if response.StatusCode != expectedStatus {
		t.Fatalf("GET %s status=%d want=%d body=%s", requestURL, response.StatusCode, expectedStatus, raw)
	}
	if output != nil {
		if err := json.Unmarshal(raw, output); err != nil {
			t.Fatal(err)
		}
	}
}

func writeTestJSON(w http.ResponseWriter, value any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(value)
}
