// Copyright 2020-2026 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package testlib

import (
	"encoding/base64"
	"fmt"
	"net/url"
	"os"
	"sort"
	"strconv"
	"strings"
	"sync"
	"testing"

	"github.com/stretchr/testify/require"
	"sigs.k8s.io/yaml"

	authenticationv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
	"go.pinniped.dev/internal/testutil"
)

type Capability string
type KubeDistro string

const (
	ClusterSigningKeyIsAvailable                       Capability = "clusterSigningKeyIsAvailable"
	HasExternalLoadBalancerProvider                    Capability = "hasExternalLoadBalancerProvider"
	CanReachInternetLDAPPorts                          Capability = "canReachInternetLDAPPorts"
	AnonymousAuthenticationSupportedForHealthEndpoints Capability = "anonymousAuthenticationSupportedForHealthEndpoints"
	AnonymousAuthenticationSupportedForOtherEndpoints  Capability = "anonymousAuthenticationSupportedForOtherEndpoints"

	KindDistro KubeDistro = "Kind"
	GKEDistro  KubeDistro = "GKE"
	AKSDistro  KubeDistro = "AKS"
	EKSDistro  KubeDistro = "EKS"
	TKGSDistro KubeDistro = "TKGS"
)

type Capabilities struct {
	ClusterSigningKeyIsAvailable    bool `json:"clusterSigningKeyIsAvailable"`
	HasExternalLoadBalancerProvider bool `json:"hasExternalLoadBalancerProvider"`
	CanReachInternetLDAPPorts       bool `json:"canReachInternetLDAPPorts"`

	AnonymousAuthentication AnonymousAuthenticationCapabilities `json:"anonymousAuthentication"`
}

type AnonymousAuthenticationCapabilities struct {
	HealthEndpoints AnonymousAuthenticationEndpointCapability `json:"healthEndpoints"`
	OtherEndpoints  AnonymousAuthenticationEndpointCapability `json:"otherEndpoints"`
}

type AnonymousAuthenticationEndpointCapability struct {
	Allowed                          bool   `json:"allowed"`
	AllowedIfK8sMinorVersionLessThan string `json:"allowedIfK8sMinorVersionLessThan"`
}

// TestEnv captures all the external parameters consumed by our integration tests.
type TestEnv struct {
	t *testing.T

	skipPodRestartAssertions bool

	ToolsNamespace                 string                                          `json:"toolsNamespace"`
	ConciergeNamespace             string                                          `json:"conciergeNamespace"`
	SupervisorNamespace            string                                          `json:"supervisorNamespace"`
	ConciergeAppName               string                                          `json:"conciergeAppName"`
	SupervisorAppName              string                                          `json:"supervisorAppName"`
	SupervisorServiceName          string                                          `json:"supervisorServiceName"`
	SupervisorCustomLabels         map[string]string                               `json:"supervisorCustomLabels"`
	ConciergeCustomLabels          map[string]string                               `json:"conciergeCustomLabels"`
	KubernetesDistribution         KubeDistro                                      `json:"kubernetesDistribution"`
	Capabilities                   Capabilities                                    `json:"capabilities"`
	TestWebhook                    authenticationv1alpha1.WebhookAuthenticatorSpec `json:"testWebhook"`
	SupervisorHTTPSAddress         string                                          `json:"supervisorHttpsAddress"`
	SupervisorHTTPSIngressAddress  string                                          `json:"supervisorHttpsIngressAddress"`
	SupervisorHTTPSIngressCABundle string                                          `json:"supervisorHttpsIngressCABundle"`
	Proxy                          string                                          `json:"proxy"`
	APIGroupSuffix                 string                                          `json:"apiGroupSuffix"`
	ShellContainerImage            string                                          `json:"shellContainer"`

	TestUser struct {
		Token            string   `json:"token"`
		ExpectedUsername string   `json:"expectedUsername"`
		ExpectedGroups   []string `json:"expectedGroups"`
	} `json:"testUser"`

	CLIUpstreamOIDC                   TestOIDCUpstream   `json:"cliOIDCUpstream"`
	SupervisorUpstreamOIDC            TestOIDCUpstream   `json:"supervisorOIDCUpstream"`
	SupervisorUpstreamLDAP            TestLDAPUpstream   `json:"supervisorLDAPUpstream"`
	SupervisorUpstreamActiveDirectory TestLDAPUpstream   `json:"supervisorActiveDirectoryUpstream"`
	SupervisorUpstreamGithub          TestGithubUpstream `json:"supervisorGithubUpstream"`
}

type TestOIDCUpstream struct {
	Issuer           string   `json:"issuer"`
	CABundle         string   `json:"caBundle"`
	AdditionalScopes []string `json:"additionalScopes"`
	UsernameClaim    string   `json:"usernameClaim"`
	GroupsClaim      string   `json:"groupsClaim"`
	ClientID         string   `json:"clientID"`
	ClientSecret     string   `json:"clientSecret"`
	CallbackURL      string   `json:"callback"`
	Username         string   `json:"username"`
	Password         string   `json:"password"`
	ExpectedGroups   []string `json:"expectedGroups"`
}

// InferSupervisorIssuerURL infers the downstream issuer URL from the callback associated with the upstream test client registration.
func (e *TestEnv) InferSupervisorIssuerURL(t *testing.T) *SupervisorIssuer {
	t.Helper()
	supervisorIssuer := NewSupervisorIssuer(t, e.SupervisorUpstreamOIDC.CallbackURL)
	require.True(t, strings.HasSuffix(supervisorIssuer.issuerURL.Path, "/callback"))
	supervisorIssuer.issuerURL.Path = strings.TrimSuffix(supervisorIssuer.issuerURL.Path, "/callback")

	t.Logf("InferSupervisorIssuerURL(): testing with downstream issuer URL %s", supervisorIssuer.Issuer())

	return supervisorIssuer
}

func (e *TestEnv) DefaultTLSCertSecretName() string {
	return e.SupervisorAppName + "-default-tls-certificate"
}

type TestLDAPUpstream struct {
	Host                                            string   `json:"host"`
	Domain                                          string   `json:"domain"`
	StartTLSOnlyHost                                string   `json:"startTLSOnlyHost"`
	CABundle                                        string   `json:"caBundle"`
	BindUsername                                    string   `json:"bindUsername"`
	BindPassword                                    string   `json:"bindPassword"`
	UserSearchBase                                  string   `json:"userSearchBase"`
	DefaultNamingContextSearchBase                  string   `json:"defaultNamingContextSearchBase"`
	GroupSearchBase                                 string   `json:"groupSearchBase"`
	GroupSearchFilter                               string   `json:"groupSearchFilter"`
	TestUserDN                                      string   `json:"testUserDN"`
	TestUserCN                                      string   `json:"testUserCN"`
	TestUserPassword                                string   `json:"testUserPassword"`
	TestUserMailAttributeName                       string   `json:"testUserMailAttributeName"`
	TestUserMailAttributeValue                      string   `json:"testUserMailAttributeValue"`
	TestUserUniqueIDAttributeName                   string   `json:"testUserUniqueIDAttributeName"`
	TestUserUniqueIDAttributeValue                  string   `json:"testUserUniqueIDAttributeValue"`
	TestUserDirectGroupsCNs                         []string `json:"testUserDirectGroupsCNs"`
	TestUserDirectPosixGroupsCNs                    []string `json:"testUserDirectPosixGroupsCNs"`
	TestUserDirectGroupsDNs                         []string `json:"testUserDirectGroupsDNs"`
	TestUserSAMAccountNameValue                     string   `json:"testUserSAMAccountNameValue"`
	TestUserPrincipalNameValue                      string   `json:"testUserPrincipalNameValue"`
	TestUserIndirectGroupsSAMAccountNames           []string `json:"TestUserIndirectGroupsSAMAccountNames"`
	TestUserIndirectGroupsSAMAccountPlusDomainNames []string `json:"TestUserIndirectGroupsSAMAccountPlusDomainNames"`
	TestDeactivatedUserSAMAccountNameValue          string   `json:"TestDeactivatedUserSAMAccountNameValue"`
	TestDeactivatedUserPassword                     string   `json:"TestDeactivatedUserPassword"`
}

type TestGithubUpstream struct {
	GithubAppClientID                string   `json:"githubAppClientId"` // GitHub's new-style GitHub App
	GithubAppClientSecret            string   `json:"githubAppClientSecret"`
	GithubOAuthAppClientID           string   `json:"githubOAuthAppClientId"` // GitHub's old-style OAuth App
	GithubOAuthAppClientSecret       string   `json:"githubOAuthAppClientSecret"`
	GithubOAuthAppAllowedCallbackURL string   `json:"githubOAuthAppAllowedCallbackURL"` // the callback URL that was configured in GitHub for this App
	TestUserUsername                 string   `json:"testUserUsername"`                 // the "login" attribute value for the user
	TestUserPassword                 string   `json:"testUserPassword"`
	TestUserOTPSecret                string   `json:"testUserOTPSecret"`
	TestUserID                       string   `json:"testUserID"`           // the "id" attribute value for the user
	TestUserOrganization             string   `json:"testUserOrganization"` // an org to which the user belongs
	TestUserExpectedTeamNames        []string `json:"testUserExpectedTeamNames"`
	TestUserExpectedTeamSlugs        []string `json:"testUserExpectedTeamSlugs"`
}

// ProxyEnv returns a set of environment variable strings (e.g., to combine with os.Environ()) which set up the configured test HTTP proxy.
func (e *TestEnv) ProxyEnv() []string {
	e.t.Helper()

	if e.Proxy == "" {
		return nil
	}

	// We should never need to use the proxy to access the Kube API server from the kubeconfig.
	// When the cluster is a Kind cluster running in CI, and if the VM has no external IP, then
	// the squid proxy running inside the cluster will not able to reach the IP of the VM at all
	// due to limitations of Docker networking, so in that case we must ensure that we are not
	// trying to use the proxy to reach the Kubernetes API server from the outside. Therefore,
	// always add the Kube API server's address or hostname to the no_proxy list.
	kubeClientConfig := NewClientConfig(e.t)
	parsedKubeAPIServerURL, err := url.Parse(kubeClientConfig.Host)
	require.NoError(e.t, err)

	return []string{
		"http_proxy=" + e.Proxy,
		"https_proxy=" + e.Proxy,
		fmt.Sprintf("no_proxy=127.0.0.1,%s", parsedKubeAPIServerURL.Host),
	}
}

// memoizedTestEnvsByTest maps *testing.T pointers to *TestEnv. It exists so that we don't do all the
// environment parsing N times per test and so that any implicit assertions happen only once.
var memoizedTestEnvsByTest sync.Map //nolint:gochecknoglobals

type TestEnvOption func(env *TestEnv)

// SkipPodRestartAssertions is a functional option that can be passed to IntegrationEnv()
// to skip using the implicit assertions which check that no pods get restarted during tests.
// Please using this sparingly, since most pod restarts are caused by unintentional crashes
// and should therefore cause tests to fail.
func SkipPodRestartAssertions() TestEnvOption {
	return func(t *TestEnv) {
		t.skipPodRestartAssertions = true
		t.t.Log("skipping pod restart assertions for test", t.t.Name())
	}
}

// IntegrationEnv gets the integration test environment from OS environment variables. This
// method also implies SkipUnlessIntegration().
func IntegrationEnv(t *testing.T, opts ...TestEnvOption) *TestEnv {
	if existing, exists := memoizedTestEnvsByTest.Load(t); exists {
		return existing.(*TestEnv)
	}

	t.Helper()
	SkipUnlessIntegration(t)

	capabilitiesDescriptionYAML := os.Getenv("PINNIPED_TEST_CLUSTER_CAPABILITY_YAML")
	capabilitiesDescriptionFile := os.Getenv("PINNIPED_TEST_CLUSTER_CAPABILITY_FILE")
	require.NotEmptyf(t,
		capabilitiesDescriptionYAML+capabilitiesDescriptionFile,
		"must specify either PINNIPED_TEST_CLUSTER_CAPABILITY_YAML or PINNIPED_TEST_CLUSTER_CAPABILITY_FILE env var for integration tests",
	)
	if capabilitiesDescriptionYAML == "" {
		//nolint:gosec // not concerned with tainted file path input for this test helper
		bytes, err := os.ReadFile(capabilitiesDescriptionFile)
		capabilitiesDescriptionYAML = string(bytes)
		require.NoError(t, err)
	}

	var testEnv TestEnv
	err := yaml.Unmarshal([]byte(capabilitiesDescriptionYAML), &testEnv)
	require.NoErrorf(t, err, "capabilities specification was invalid YAML")

	loadEnvVars(t, &testEnv)
	testEnv.t = t

	for _, opt := range opts {
		opt(&testEnv)
	}

	memoizedTestEnvsByTest.Store(t, &testEnv)

	// By default, in every integration test, assert that no pods in our namespaces restart during the test.
	if !testEnv.skipPodRestartAssertions {
		assertNoRestartsDuringTest(t, testEnv.ConciergeNamespace, "!pinniped.dev/test")
		assertNoRestartsDuringTest(t, testEnv.SupervisorNamespace, "!pinniped.dev/test")
	}

	return &testEnv
}

func needEnv(t *testing.T, key string) string {
	t.Helper()
	value := os.Getenv(key)
	require.NotEmptyf(t, value, "must specify %s env var for integration tests", key)
	return value
}

func base64Decoded(t *testing.T, s string) string {
	t.Helper()
	if len(s) == 0 {
		return s
	}
	bytes, err := base64.StdEncoding.DecodeString(s)
	require.NoError(t, err)
	return string(bytes)
}

func wantEnv(key, dephault string) string {
	value, ok := os.LookupEnv(key)
	if !ok {
		return dephault
	}
	return value
}

func filterEmpty(ss []string) []string {
	filtered := []string{}
	for _, s := range ss {
		if len(s) != 0 {
			filtered = append(filtered, s)
		}
	}
	return filtered
}

func loadEnvVars(t *testing.T, result *TestEnv) {
	t.Helper()

	result.ToolsNamespace = os.Getenv("PINNIPED_TEST_TOOLS_NAMESPACE")

	result.ConciergeNamespace = needEnv(t, "PINNIPED_TEST_CONCIERGE_NAMESPACE")
	result.ConciergeAppName = needEnv(t, "PINNIPED_TEST_CONCIERGE_APP_NAME")
	result.TestUser.ExpectedUsername = needEnv(t, "PINNIPED_TEST_USER_USERNAME")
	result.TestUser.ExpectedGroups = strings.Split(strings.ReplaceAll(needEnv(t, "PINNIPED_TEST_USER_GROUPS"), " ", ""), ",")
	result.TestUser.Token = needEnv(t, "PINNIPED_TEST_USER_TOKEN")
	result.TestWebhook.Endpoint = needEnv(t, "PINNIPED_TEST_WEBHOOK_ENDPOINT")
	result.SupervisorNamespace = needEnv(t, "PINNIPED_TEST_SUPERVISOR_NAMESPACE")
	result.SupervisorAppName = needEnv(t, "PINNIPED_TEST_SUPERVISOR_APP_NAME")
	result.SupervisorServiceName = os.Getenv("PINNIPED_TEST_SUPERVISOR_SERVICE_NAME")
	result.TestWebhook.TLS = &authenticationv1alpha1.TLSSpec{CertificateAuthorityData: needEnv(t, "PINNIPED_TEST_WEBHOOK_CA_BUNDLE")}

	result.SupervisorHTTPSIngressAddress = os.Getenv("PINNIPED_TEST_SUPERVISOR_HTTPS_INGRESS_ADDRESS")
	result.SupervisorHTTPSAddress = needEnv(t, "PINNIPED_TEST_SUPERVISOR_HTTPS_ADDRESS")
	result.SupervisorHTTPSIngressCABundle = base64Decoded(t, os.Getenv("PINNIPED_TEST_SUPERVISOR_HTTPS_INGRESS_CA_BUNDLE"))

	conciergeCustomLabelsYAML := needEnv(t, "PINNIPED_TEST_CONCIERGE_CUSTOM_LABELS")
	var conciergeCustomLabels map[string]string
	err := yaml.Unmarshal([]byte(conciergeCustomLabelsYAML), &conciergeCustomLabels)
	require.NoErrorf(t, err, "PINNIPED_TEST_CONCIERGE_CUSTOM_LABELS must be a YAML map of string to string")
	result.ConciergeCustomLabels = conciergeCustomLabels
	require.NotEmpty(t, result.ConciergeCustomLabels, "PINNIPED_TEST_CONCIERGE_CUSTOM_LABELS cannot be empty")
	supervisorCustomLabelsYAML := needEnv(t, "PINNIPED_TEST_SUPERVISOR_CUSTOM_LABELS")
	var supervisorCustomLabels map[string]string
	err = yaml.Unmarshal([]byte(supervisorCustomLabelsYAML), &supervisorCustomLabels)
	require.NoErrorf(t, err, "PINNIPED_TEST_SUPERVISOR_CUSTOM_LABELS must be a YAML map of string to string")
	result.SupervisorCustomLabels = supervisorCustomLabels
	require.NotEmpty(t, result.SupervisorCustomLabels, "PINNIPED_TEST_SUPERVISOR_CUSTOM_LABELS cannot be empty")

	result.Proxy = os.Getenv("PINNIPED_TEST_PROXY")
	result.APIGroupSuffix = wantEnv("PINNIPED_TEST_API_GROUP_SUFFIX", "pinniped.dev")
	result.ShellContainerImage = needEnv(t, "PINNIPED_TEST_SHELL_CONTAINER_IMAGE")

	result.CLIUpstreamOIDC = TestOIDCUpstream{
		Issuer:         needEnv(t, "PINNIPED_TEST_CLI_OIDC_ISSUER"),
		CABundle:       base64Decoded(t, os.Getenv("PINNIPED_TEST_CLI_OIDC_ISSUER_CA_BUNDLE")),
		UsernameClaim:  os.Getenv("PINNIPED_TEST_CLI_OIDC_USERNAME_CLAIM"),
		GroupsClaim:    os.Getenv("PINNIPED_TEST_CLI_OIDC_GROUPS_CLAIM"),
		ClientID:       needEnv(t, "PINNIPED_TEST_CLI_OIDC_CLIENT_ID"),
		CallbackURL:    needEnv(t, "PINNIPED_TEST_CLI_OIDC_CALLBACK_URL"),
		Username:       needEnv(t, "PINNIPED_TEST_CLI_OIDC_USERNAME"),
		Password:       needEnv(t, "PINNIPED_TEST_CLI_OIDC_PASSWORD"),
		ExpectedGroups: filterEmpty(strings.Split(strings.ReplaceAll(os.Getenv("PINNIPED_TEST_CLI_OIDC_EXPECTED_GROUPS"), " ", ""), ",")),
	}

	result.SupervisorUpstreamOIDC = TestOIDCUpstream{
		Issuer:           needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER"),
		CABundle:         base64Decoded(t, os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER_CA_BUNDLE")),
		AdditionalScopes: filterEmpty(strings.Split(strings.ReplaceAll(os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ADDITIONAL_SCOPES"), " ", ""), ",")),
		UsernameClaim:    os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME_CLAIM"),
		GroupsClaim:      os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_GROUPS_CLAIM"),
		ClientID:         needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_CLIENT_ID"),
		ClientSecret:     needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_CLIENT_SECRET"),
		CallbackURL:      needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_CALLBACK_URL"),
		Username:         needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME"),
		Password:         needEnv(t, "PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD"),
		ExpectedGroups:   filterEmpty(strings.Split(strings.ReplaceAll(os.Getenv("PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_EXPECTED_GROUPS"), " ", ""), ",")),
	}

	result.SupervisorUpstreamLDAP = TestLDAPUpstream{
		Host:                           needEnv(t, "PINNIPED_TEST_LDAP_HOST"),
		StartTLSOnlyHost:               needEnv(t, "PINNIPED_TEST_LDAP_STARTTLS_ONLY_HOST"),
		CABundle:                       base64Decoded(t, os.Getenv("PINNIPED_TEST_LDAP_LDAPS_CA_BUNDLE")),
		BindUsername:                   needEnv(t, "PINNIPED_TEST_LDAP_BIND_ACCOUNT_USERNAME"),
		BindPassword:                   needEnv(t, "PINNIPED_TEST_LDAP_BIND_ACCOUNT_PASSWORD"),
		UserSearchBase:                 needEnv(t, "PINNIPED_TEST_LDAP_USERS_SEARCH_BASE"),
		GroupSearchBase:                needEnv(t, "PINNIPED_TEST_LDAP_GROUPS_SEARCH_BASE"),
		GroupSearchFilter:              wantEnv("PINNIPED_TEST_LDAP_GROUPS_SEARCH_FILTER", ""),
		TestUserDN:                     needEnv(t, "PINNIPED_TEST_LDAP_USER_DN"),
		TestUserCN:                     needEnv(t, "PINNIPED_TEST_LDAP_USER_CN"),
		TestUserUniqueIDAttributeName:  needEnv(t, "PINNIPED_TEST_LDAP_USER_UNIQUE_ID_ATTRIBUTE_NAME"),
		TestUserUniqueIDAttributeValue: needEnv(t, "PINNIPED_TEST_LDAP_USER_UNIQUE_ID_ATTRIBUTE_VALUE"),
		TestUserMailAttributeName:      needEnv(t, "PINNIPED_TEST_LDAP_USER_EMAIL_ATTRIBUTE_NAME"),
		TestUserMailAttributeValue:     needEnv(t, "PINNIPED_TEST_LDAP_USER_EMAIL_ATTRIBUTE_VALUE"),
		TestUserDirectGroupsCNs:        filterEmpty(strings.Split(needEnv(t, "PINNIPED_TEST_LDAP_EXPECTED_DIRECT_GROUPS_CN"), ";")),
		TestUserDirectPosixGroupsCNs:   filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_LDAP_EXPECTED_DIRECT_POSIX_GROUPS_CN", ""), ";")),
		TestUserDirectGroupsDNs:        filterEmpty(strings.Split(needEnv(t, "PINNIPED_TEST_LDAP_EXPECTED_DIRECT_GROUPS_DN"), ";")),
		TestUserPassword:               needEnv(t, "PINNIPED_TEST_LDAP_USER_PASSWORD"),
	}

	result.SupervisorUpstreamActiveDirectory = TestLDAPUpstream{
		Host:                                  wantEnv("PINNIPED_TEST_AD_HOST", ""),
		Domain:                                wantEnv("PINNIPED_TEST_AD_DOMAIN", ""),
		CABundle:                              base64Decoded(t, os.Getenv("PINNIPED_TEST_AD_LDAPS_CA_BUNDLE")),
		BindUsername:                          wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_USERNAME", ""),
		BindPassword:                          wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_PASSWORD", ""),
		TestUserPassword:                      wantEnv("PINNIPED_TEST_AD_USER_PASSWORD", ""),
		TestUserUniqueIDAttributeName:         wantEnv("PINNIPED_TEST_AD_USER_UNIQUE_ID_ATTRIBUTE_NAME", ""),
		TestUserUniqueIDAttributeValue:        wantEnv("PINNIPED_TEST_AD_USER_UNIQUE_ID_ATTRIBUTE_VALUE", ""),
		TestUserPrincipalNameValue:            wantEnv("PINNIPED_TEST_AD_USER_USER_PRINCIPAL_NAME", ""),
		TestUserMailAttributeValue:            wantEnv("PINNIPED_TEST_AD_USER_EMAIL_ATTRIBUTE_VALUE", ""),
		TestUserMailAttributeName:             wantEnv("PINNIPED_TEST_AD_USER_EMAIL_ATTRIBUTE_NAME", ""),
		TestUserDirectGroupsDNs:               filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_DN", ""), ";")),
		TestUserDirectGroupsCNs:               filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_CN", ""), ";")),
		TestUserIndirectGroupsSAMAccountNames: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_SAMACCOUNTNAME", ""), ";")),
		TestUserIndirectGroupsSAMAccountPlusDomainNames: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_AD_USER_EXPECTED_GROUPS_SAMACCOUNTNAME_DOMAINNAMES", ""), ";")),
		TestDeactivatedUserSAMAccountNameValue:          wantEnv("PINNIPED_TEST_DEACTIVATED_AD_USER_SAMACCOUNTNAME", ""),
		TestDeactivatedUserPassword:                     wantEnv("PINNIPED_TEST_DEACTIVATED_AD_USER_PASSWORD", ""),
		DefaultNamingContextSearchBase:                  wantEnv("PINNIPED_TEST_AD_DEFAULTNAMINGCONTEXT_DN", ""),
		UserSearchBase:                                  wantEnv("PINNIPED_TEST_AD_USERS_DN", ""),
		GroupSearchBase:                                 wantEnv("PINNIPED_TEST_AD_USERS_DN", ""),
	}

	result.SupervisorUpstreamGithub = TestGithubUpstream{
		GithubAppClientID:                wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_ID", ""),
		GithubAppClientSecret:            wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_SECRET", ""),
		GithubOAuthAppClientID:           wantEnv("PINNIPED_TEST_GITHUB_OAUTH_APP_CLIENT_ID", ""),
		GithubOAuthAppClientSecret:       wantEnv("PINNIPED_TEST_GITHUB_OAUTH_APP_CLIENT_SECRET", ""),
		GithubOAuthAppAllowedCallbackURL: wantEnv("PINNIPED_TEST_GITHUB_OAUTH_APP_ALLOWED_CALLBACK_URL", ""),
		TestUserUsername:                 wantEnv("PINNIPED_TEST_GITHUB_USER_USERNAME", ""),
		TestUserPassword:                 wantEnv("PINNIPED_TEST_GITHUB_USER_PASSWORD", ""),
		TestUserOTPSecret:                wantEnv("PINNIPED_TEST_GITHUB_USER_OTP_SECRET", ""),
		TestUserID:                       wantEnv("PINNIPED_TEST_GITHUB_USERID", ""),
		TestUserOrganization:             wantEnv("PINNIPED_TEST_GITHUB_ORG", ""),
		TestUserExpectedTeamNames:        filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_GITHUB_EXPECTED_TEAM_NAMES", ""), ",")),
		TestUserExpectedTeamSlugs:        filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_GITHUB_EXPECTED_TEAM_SLUGS", ""), ",")),
	}

	sort.Strings(result.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs)
	sort.Strings(result.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs)
	sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsCNs)
	sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs)
	sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountNames)
	sort.Strings(result.SupervisorUpstreamGithub.TestUserExpectedTeamNames)
	sort.Strings(result.SupervisorUpstreamGithub.TestUserExpectedTeamSlugs)
}

func (e *TestEnv) HasCapability(cap Capability) bool {
	e.t.Helper()

	switch cap {
	case AnonymousAuthenticationSupportedForHealthEndpoints:
		versionLessThan := e.Capabilities.AnonymousAuthentication.HealthEndpoints.AllowedIfK8sMinorVersionLessThan
		if versionLessThan == "" {
			return e.Capabilities.AnonymousAuthentication.HealthEndpoints.Allowed
		}
		minorVersionNumber, err := strconv.Atoi(versionLessThan)
		require.NoError(e.t, err, "could not parse minor version number from AnonymousAuthentication HealthEndpoints capability as int")
		return !testutil.KubeServerMinorVersionAtLeastInclusive(e.t, NewKubernetesClientset(e.t).Discovery(), minorVersionNumber)
	case AnonymousAuthenticationSupportedForOtherEndpoints:
		versionLessThan := e.Capabilities.AnonymousAuthentication.OtherEndpoints.AllowedIfK8sMinorVersionLessThan
		if versionLessThan == "" {
			return e.Capabilities.AnonymousAuthentication.OtherEndpoints.Allowed
		}
		minorVersionNumber, err := strconv.Atoi(versionLessThan)
		require.NoError(e.t, err, "could not parse minor version number from AnonymousAuthentication OtherEndpoints capability as int")
		return !testutil.KubeServerMinorVersionAtLeastInclusive(e.t, NewKubernetesClientset(e.t).Discovery(), minorVersionNumber)
	case ClusterSigningKeyIsAvailable:
		return e.Capabilities.ClusterSigningKeyIsAvailable
	case HasExternalLoadBalancerProvider:
		return e.Capabilities.HasExternalLoadBalancerProvider
	case CanReachInternetLDAPPorts:
		return e.Capabilities.CanReachInternetLDAPPorts
	default:
		require.Failf(e.t, "unknown capability type", "name: %s", cap)
		return false
	}
}

func (e *TestEnv) WithCapability(cap Capability) *TestEnv {
	e.t.Helper()
	if !e.HasCapability(cap) {
		e.t.Skipf("skipping integration test because test environment lacks the %q capability", cap)
	}
	return e
}

func (e *TestEnv) WithoutCapability(cap Capability) *TestEnv {
	e.t.Helper()
	if e.HasCapability(cap) {
		e.t.Skipf("skipping integration test because test environment has the %q capability", cap)
	}
	return e
}

// WithKubeDistribution skips the test unless it will run on the expected cluster type.
// Please use this sparingly. We would prefer that a test run on every cluster type where it can possibly run, so
// prefer to run everywhere when possible or use cluster capabilities when needed, rather than looking at the
// type of cluster to decide to skip a test. However, there are some tests that do not depend on or interact with
// Kubernetes itself which really only need to run on a single platform to give us the coverage that we desire.
func (e *TestEnv) WithKubeDistribution(distro KubeDistro) *TestEnv {
	e.t.Helper()
	if e.KubernetesDistribution != distro {
		e.t.Skipf("skipping integration test because test environment is running %q but this test wants %q", e.KubernetesDistribution, distro)
	}
	return e
}
