package server

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"time"

	"buf.build/go/protovalidate"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/gitlab"
	gapi "gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/gitlab/api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/event_tracker"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/kubernetes_api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/kubernetes_api/rpc"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/kubernetes_api/server/watch_aggregator"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/modserver"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/modshared"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/usage_metrics"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/cache"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/featureflag"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/grpctool"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/httpz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/logz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/memz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/version"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/vendored/k8s.io/apiserver/pkg/endpoints/handlers/negotiation"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/pkg/agentcfg"
	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
	"go.opentelemetry.io/otel/attribute"
	otelmetric "go.opentelemetry.io/otel/metric"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/trace"
	"go.opentelemetry.io/otel/trace/noop"
	"google.golang.org/grpc"
	"google.golang.org/protobuf/proto"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/util/sets"
)

const (
	readHeaderTimeout = 10 * time.Second
	idleTimeout       = 1 * time.Minute

	gitLabKASCookieName             = "_gitlab_kas"
	authorizationHeaderBearerPrefix = "Bearer " // must end with a space
	tokenSeparator                  = ":"
	tokenTypeCI                     = "ci"
	tokenTypePat                    = "pat"

	webSocketTokenSubProtocolPrefix = "websocket-token.kas.gitlab.com."

	watchAPIPath = "/watch"
	graphAPIPath = "/graph"

	// See https://w3c.github.io/network-error-logging/#example-2
	nelRemovePoliciesPayload = `{"max_age": 0}`

	// See https://fetch.spec.whatwg.org/#x-content-type-options-header
	xContentTypeOptionsNoSniff = "nosniff"

	errorTypeMetricAttributeName = "error_type"
	// only use for trusted status code
	statusCodeMetricAttributeName = "status_code"
	// use in proxy responses for untrusted status codes to avoid explosion of cardinality.
	statusCodeClassMetricAttributeName = "status_code_class"
)

var (
	errBlockedResponseHeader = &blockedResponseHeaderError{}

	errorTypeAbortMetricOptions                 = []otelmetric.AddOption{otelmetric.WithAttributeSet(attribute.NewSet(attribute.String(errorTypeMetricAttributeName, "abort")))}
	errorTypeWatchAggregatorAcceptMetricOptions = []otelmetric.AddOption{otelmetric.WithAttributeSet(attribute.NewSet(attribute.String(errorTypeMetricAttributeName, "watch_aggregator_accept")))}
	errorTypeProxyMetricAttr                    = attribute.String(errorTypeMetricAttributeName, "proxy")
	errorTypeURLPathPrefixMetricAttr            = attribute.String(errorTypeMetricAttributeName, "url_path_prefix")
	errorTypeAuthenticationMetricAttr           = attribute.String(errorTypeMetricAttributeName, "authentication")
	errorTypeWebSocketTokenMetricAttr           = attribute.String(errorTypeMetricAttributeName, "websocket_token_generation")

	statusCodeClassInvalidMetricOptions = []otelmetric.AddOption{otelmetric.WithAttributeSet(attribute.NewSet(attribute.String(statusCodeClassMetricAttributeName, "invalid")))}
	statusCodeClassMetricOptions        = [...][]otelmetric.AddOption{
		{otelmetric.WithAttributeSet(attribute.NewSet(attribute.String(statusCodeClassMetricAttributeName, "1xx")))},
		{otelmetric.WithAttributeSet(attribute.NewSet(attribute.String(statusCodeClassMetricAttributeName, "2xx")))},
		{otelmetric.WithAttributeSet(attribute.NewSet(attribute.String(statusCodeClassMetricAttributeName, "3xx")))},
		{otelmetric.WithAttributeSet(attribute.NewSet(attribute.String(statusCodeClassMetricAttributeName, "4xx")))},
		{otelmetric.WithAttributeSet(attribute.NewSet(attribute.String(statusCodeClassMetricAttributeName, "5xx")))},
	}

	code2reason = map[int32]metav1.StatusReason{
		// 4xx
		http.StatusBadRequest:            metav1.StatusReasonBadRequest,
		http.StatusUnauthorized:          metav1.StatusReasonUnauthorized,
		http.StatusForbidden:             metav1.StatusReasonForbidden,
		http.StatusNotFound:              metav1.StatusReasonNotFound,
		http.StatusMethodNotAllowed:      metav1.StatusReasonMethodNotAllowed,
		http.StatusNotAcceptable:         metav1.StatusReasonNotAcceptable,
		http.StatusConflict:              metav1.StatusReasonConflict,
		http.StatusGone:                  metav1.StatusReasonGone,
		http.StatusRequestEntityTooLarge: metav1.StatusReasonRequestEntityTooLarge,
		http.StatusUnsupportedMediaType:  metav1.StatusReasonUnsupportedMediaType,
		http.StatusUnprocessableEntity:   metav1.StatusReasonInvalid,
		http.StatusTooManyRequests:       metav1.StatusReasonTooManyRequests,

		// 5xx
		http.StatusInternalServerError: metav1.StatusReasonInternalError,
		http.StatusBadGateway:          metav1.StatusReasonInternalError,
		http.StatusServiceUnavailable:  metav1.StatusReasonServiceUnavailable,
		http.StatusGatewayTimeout:      metav1.StatusReasonTimeout,
	}
)

// blockedResponseHeaderError represents an error condition when an agent sent response header is blocked.
// This is a dedicated error type (instead of e.g. errors.New()) to have a proper error type in reporting tools like Sentry.
type blockedResponseHeaderError struct{}

func (e *blockedResponseHeaderError) Error() string {
	return "Blocked Kubernetes API proxy response header. Please configure extra allowed headers for your instance in the KAS config with `extra_allowed_response_headers` and have a look at the troubleshooting guide at https://docs.gitlab.com/administration/clusters/kas/#error-blocked-kubernetes-api-proxy-response-header."
}

type proxyUserCacheKey struct {
	agentKey   api.AgentKey
	accessType gapi.AccessType
	accessKey  string
	csrfToken  string
}

type K8sAPIProxyRequestsEvent struct {
	UserID    int64 `json:"user_id"`
	ProjectID int64 `json:"project_id"`
}

func (e K8sAPIProxyRequestsEvent) DeduplicateKey() string {
	return fmt.Sprintf("%d-%d", e.UserID, e.ProjectID)
}

type webSocketTokenResponse struct {
	Token string `json:"token"`
}

type kubernetesAPIProxy struct {
	log                        *slog.Logger
	api                        modserver.API
	makeRequest                func(ctx context.Context, agentKey api.AgentKey) (grpctool.HTTPRequestClient, error)
	watchGraph                 func(ctx context.Context, agentKey api.AgentKey, in *rpc.WatchGraphRequest) (grpc.ServerStreamingClient[rpc.WatchGraphResponse], error)
	gitLabClient               gitlab.ClientInterface
	allowedOriginURLs          []string
	allowedAgentsCache         *cache.CacheWithErr[string, *gapi.AllowedAgentsForJob]
	authorizeProxyUserCache    *cache.CacheWithErr[proxyUserCacheKey, *gapi.AuthorizeProxyUserResponse]
	utRequestCounter           usage_metrics.Counter
	utCIAccessRequestCounter   usage_metrics.Counter
	utCIAccessAgentsCounter    usage_metrics.UniqueCounter
	utCIAccessEventTracker     event_tracker.EventsInterface
	utUserAccessRequestCounter usage_metrics.Counter
	utUserAccessAgentsCounter  usage_metrics.UniqueCounter
	utUserAccessEventTracker   event_tracker.EventsInterface
	utPatAccessRequestCounter  usage_metrics.Counter
	utPatAccessAgentsCounter   usage_metrics.UniqueCounter
	utPatAccessEventTracker    event_tracker.EventsInterface
	requestsTotalCounter       otelmetric.Int64Counter
	requestsSuccessCounter     otelmetric.Int64Counter
	requestsErrorCounter       otelmetric.Int64Counter
	responseSerializer         runtime.NegotiatedSerializer
	traceProvider              trace.TracerProvider
	tracePropagator            propagation.TextMapPropagator
	meterProvider              otelmetric.MeterProvider
	validator                  protovalidate.Validator
	aggregatedWatchCounter     otelmetric.Int64UpDownCounter
	serverName                 string
	serverVersion              version.Version
	serverVia                  string
	// urlPathPrefix is guaranteed to end with / by defaulting.
	urlPathPrefix          string
	listenerGracePeriod    time.Duration
	shutdownGracePeriod    time.Duration
	gitlabReleasesList     []string
	httpServerTracing      bool
	webSocketToken         *webSocketToken
	allowedResponseHeaders sets.Set[string]
}

func (p *kubernetesAPIProxy) Run(ctx context.Context, listener net.Listener) error {
	var handler http.Handler
	handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		auxCtx, auxCancel := context.WithCancel(ctx)
		defer auxCancel()
		p.proxy(auxCtx, w, r)
	})
	var tp trace.TracerProvider
	if p.httpServerTracing {
		tp = p.traceProvider
	} else {
		tp = noop.NewTracerProvider()
	}
	handler = otelhttp.NewHandler(handler, "k8s-proxy",
		otelhttp.WithTracerProvider(tp),
		otelhttp.WithPropagators(p.tracePropagator),
		otelhttp.WithMeterProvider(p.meterProvider),
		otelhttp.WithPublicEndpointFn(func(*http.Request) bool { return true }),
	)
	srv := &http.Server{
		Handler:           handler,
		ReadHeaderTimeout: readHeaderTimeout,
		IdleTimeout:       idleTimeout,
	}
	return httpz.RunServerWithUpgradeSupport(ctx, srv, listener, p.listenerGracePeriod, p.shutdownGracePeriod)
}

// proxy Kubernetes API calls via agentk to the cluster Kube API.
//
// This method also takes care of CORS preflight requests as documented [here](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request).
func (p *kubernetesAPIProxy) proxy(auxCtx context.Context, w http.ResponseWriter, r *http.Request) {
	// for preflight and normal requests we want to allow some configured allowed origins and
	// support exposing the response to the client when credentials (e.g. cookies) are included in the request
	header := w.Header()

	requestedOrigin := r.Header.Get(httpz.OriginHeader)
	if requestedOrigin != "" {
		// If the Origin header is set, it needs to match the configured allowed origin urls.
		if !p.isOriginAllowed(requestedOrigin) {
			// Reject the request because origin is not allowed
			if p.log.Enabled(context.Background(), slog.LevelDebug) { //nolint: contextcheck
				p.log.Debug(fmt.Sprintf("Received Origin %q is not in configured allowed origins", requestedOrigin))
			}
			w.WriteHeader(http.StatusForbidden)
			return
		}
		header[httpz.AccessControlAllowOriginHeader] = []string{requestedOrigin}
		header[httpz.AccessControlAllowCredentialsHeader] = []string{"true"}
		header[httpz.VaryHeader] = []string{httpz.OriginHeader}
	}
	header[httpz.ServerHeader] = []string{p.serverName} // It will be removed just before responding with actual headers from upstream

	if r.Method == http.MethodOptions {
		// we have a preflight request
		header[httpz.AccessControlAllowHeadersHeader] = r.Header[httpz.AccessControlRequestHeadersHeader]
		// all allowed HTTP methods:
		// see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
		header[httpz.AccessControlAllowMethodsHeader] = []string{"GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH"}
		header[httpz.AccessControlMaxAgeHeader] = []string{"86400"}
		return
	}

	ctx := r.Context()
	// count the request to the total number of requests, we don't want to count pre-flight requests.
	p.requestsTotalCounter.Add(context.Background(), 1) //nolint:contextcheck
	log := p.log.With(logz.TraceIDFromContext(ctx))     //nolint:contextcheck

	if !strings.HasPrefix(r.URL.Path, p.urlPathPrefix) {
		msg := "Bad request: URL does not start with expected prefix"
		log.Debug(msg, logz.URLPath(r.URL.Path), logz.URLPathPrefix(p.urlPathPrefix))
		p.writeErrorResponse(log, modshared.NoAgentKey, w, r, errorTypeURLPathPrefixMetricAttr)(&grpctool.ErrResp{ //nolint:contextcheck
			StatusCode: http.StatusBadRequest,
			Msg:        msg,
		})
		return
	}

	// authenticate proxy request
	log, agentKey, userID, impConfig, creds, ffs, eResp := p.authenticateAndImpersonateRequest(ctx, log, r) //nolint:contextcheck
	if eResp != nil {
		// If GitLab doesn't authorize the proxy user to make the call,
		// we send an extra header to indicate that, so that the client
		// can differentiate from an *unauthorized* response from GitLab
		// and from an *authorized* response from the proxied K8s cluster.
		if eResp.StatusCode == http.StatusUnauthorized {
			header[httpz.GitlabUnauthorizedHeader] = []string{"true"}
		}
		p.writeErrorResponse(log, agentKey, w, r, errorTypeAuthenticationMetricAttr)(eResp) //nolint:contextcheck
		return
	}

	query := r.URL.Query()

	// check for WebSocket token requests
	if r.Method == http.MethodGet && isSessionCookieAuthn(creds) && query.Has(webSocketTokenRequestParamName) {
		url := fmt.Sprintf("%s/%s", r.Host, strings.TrimPrefix(r.URL.Path, "/"))
		token, err := p.webSocketToken.generate(url, agentKey, userID, impConfig, ffs)
		if err != nil {
			p.api.HandleProcessingError(ctx, log, "Failed to generate WebSocket token", err)                //nolint:contextcheck
			p.writeErrorResponse(log, agentKey, w, r, errorTypeWebSocketTokenMetricAttr)(&grpctool.ErrResp{ //nolint:contextcheck
				StatusCode: http.StatusInternalServerError,
				Msg:        "Failed to generate WebSocket token",
			})
			return
		}

		header[httpz.ContentTypeHeader] = []string{"application/json"}
		b, err := json.Marshal(webSocketTokenResponse{Token: token})
		if err != nil {
			p.writeErrorResponse(log, agentKey, w, r, errorTypeWebSocketTokenMetricAttr)(&grpctool.ErrResp{ //nolint:contextcheck
				StatusCode: http.StatusInternalServerError,
				Msg:        "Failed to marshal WebSocket token response",
				Err:        err,
			})
			return
		}
		_, _ = w.Write(b) // IO errors
		return
	}

	// prepare request for proxying

	// urlPathPrefix is guaranteed to end with / by defaulting. That means / will be removed here.
	// Put it back by -1 on length.
	r.URL.Path = r.URL.Path[len(p.urlPathPrefix)-1:]

	// remove GitLab authorization headers (job token, session cookie etc)
	delete(r.Header, httpz.AuthorizationHeader)
	delete(r.Header, httpz.CookieHeader)
	delete(r.Header, httpz.GitlabAgentIDHeader)
	delete(r.Header, httpz.CSRFTokenHeader)
	// remove websocket token auth information
	httpz.RemoveHeaderValue(
		r.Header,
		httpz.SecWebSocketProtocolHeader,
		func(s string) bool { return strings.HasPrefix(s, webSocketTokenSubProtocolPrefix) },
	)
	// remove GitLab authorization query parameters
	delete(query, httpz.GitlabAgentIDQueryParam)
	delete(query, httpz.CSRFTokenQueryParam)
	r.URL.RawQuery = query.Encode()
	r.Header[httpz.ViaHeader] = append(r.Header[httpz.ViaHeader], p.serverVia)

	switch r.URL.Path {
	case watchAPIPath:
		p.proxyAggregatedWatch(auxCtx, log, agentKey, impConfig, w, r)
	case graphAPIPath:
		p.proxyGraphAPI(auxCtx, log, agentKey, impConfig, w, r)
	default:
		abort := p.proxyRequest( //nolint:contextcheck
			log,
			agentKey,
			impConfig,
			creds,
			w,
			r,
		)
		if abort {
			p.requestsErrorCounter.Add(context.Background(), 1, errorTypeAbortMetricOptions...) //nolint:contextcheck// we need to use panic here for proper status code handling.
			panic(http.ErrAbortHandler)
		}
	}
}

func (p *kubernetesAPIProxy) isOriginAllowed(origin string) bool {
	return slices.Contains(p.allowedOriginURLs, origin)
}

func (p *kubernetesAPIProxy) proxyRequest(log *slog.Logger, agentKey api.AgentKey, impConfig *kubernetes_api.ImpersonationConfig,
	creds any, w http.ResponseWriter, r *http.Request) bool {
	p.utRequestCounter.Inc() // Count only authenticated and authorized requests

	http2grpc := grpctool.InboundHTTPToOutboundGRPC{
		HTTP2GRPC: grpctool.HTTPToOutboundGRPC{
			Log: log,
			HandleProcessingErrorFunc: func(msg string, err error) {
				p.api.HandleProcessingError(r.Context(), log, msg, err, logz.AgentKey(agentKey))
			},
			CheckHeader: func(statusCode int32, header http.Header) error {
				if !isBrowserAuthn(creds) {
					// The below checks only apply to requests that authenticate using a cookie.
					return nil
				}
				if isHTTPRedirectStatusCode(statusCode) {
					// Do not proxy redirects for requests with Cookie + CSRF token authentication.
					return errors.New("redirects are not allowed")
				}
				err := checkContentType(header, allowedResponseContentTypes...)
				if err != nil {
					return err
				}

				if len(header[httpz.SetCookieHeader]) > 0 {
					delete(header, httpz.SetCookieHeader)
					log.Debug("Deleted Set-Cookie from the server's response")
				}
				return nil
			},
		},
		NewClient: func(ctx context.Context) (grpctool.HTTPRequestClient, error) {
			return p.makeRequest(ctx, agentKey)
		},
		WriteErrorResponse: p.writeErrorResponse(log, agentKey, w, r, errorTypeProxyMetricAttr),
		MergeHeaders:       p.mergeProxiedResponseHeadersFunc(log, agentKey),
		TrackResponse: func(statusCode int) {
			p.requestsSuccessCounter.Add(context.Background(), 1, resolveStatusCodeClassMetricOptions(statusCode)...)
		},
	}
	return http2grpc.Pipe(w, r, impConfig2extra(impConfig))
}

func (p *kubernetesAPIProxy) proxyGraphAPI(auxCtx context.Context, log *slog.Logger, agentKey api.AgentKey, impConfig *kubernetes_api.ImpersonationConfig, w http.ResponseWriter, r *http.Request) {
	ga := graphAPIHandler{
		log:        log,
		api:        p.api,
		validator:  p.validator,
		watchGraph: p.watchGraph,
	}
	ga.Handle(auxCtx, agentKey, impConfig, w, r)
}

func (p *kubernetesAPIProxy) proxyAggregatedWatch(auxCtx context.Context, log *slog.Logger, agentKey api.AgentKey, impConfig *kubernetes_api.ImpersonationConfig, w http.ResponseWriter, r *http.Request) {
	wa := watch_aggregator.NewWatchAggregator(
		log,
		p.api,
		p.validator,
		func() (watch_aggregator.WebSocketInterface, error) { //nolint:contextcheck
			wsi, err := watch_aggregator.WebSocketAccept(w, r)
			if err != nil {
				p.requestsErrorCounter.Add(context.Background(), 1, errorTypeWatchAggregatorAcceptMetricOptions...)
				return wsi, err
			}

			p.requestsSuccessCounter.Add(context.Background(), 1, resolveStatusCodeClassMetricOptions(http.StatusSwitchingProtocols)...)
			return wsi, err
		},
		&grpctool.HTTPRoundTripperToOutboundGRPC{
			HTTP2GRPC: grpctool.HTTPToOutboundGRPC{
				Log: log,
				HandleProcessingErrorFunc: func(msg string, err error) { //nolint:contextcheck
					p.api.HandleProcessingError(r.Context(), log, msg, err, logz.AgentKey(agentKey))
				},
				CheckHeader: func(statusCode int32, header http.Header) error {
					return nil
				},
			},
			NewClient: func(ctx context.Context) (grpctool.HTTPRequestClient, error) {
				return p.makeRequest(ctx, agentKey)
			},
			HeaderExtra: impConfig2extra(impConfig),
		},
	)
	if wa == nil {
		// we prematurely closed the websocket connection on a user error
		return
	}

	p.aggregatedWatchCounter.Add(context.Background(), 1)        //nolint:contextcheck
	defer p.aggregatedWatchCounter.Add(context.Background(), -1) //nolint:contextcheck

	// handle aggregated watch requests
	wa.Handle(auxCtx)
}

// Don't return a concrete type or extra will be passed as a typed nil.
func impConfig2extra(impConfig *kubernetes_api.ImpersonationConfig) proto.Message {
	if impConfig == nil {
		return nil
	}
	return &rpc.HeaderExtra{
		ImpConfig: impConfig,
	}
}

func (p *kubernetesAPIProxy) authenticateAndImpersonateRequest(ctx context.Context, log *slog.Logger, r *http.Request) (*slog.Logger, api.AgentKey, int64 /* user ID */, *kubernetes_api.ImpersonationConfig, any, featureflag.Set, *grpctool.ErrResp) {
	agentKey, creds, err := p.getAuthorizationInfoFromRequest(r)
	if err != nil {
		msg := "Unauthorized"
		log.Debug(msg, logz.Error(err))
		return log, modshared.NoAgentKey, 0, nil, nil, featureflag.Set{}, &grpctool.ErrResp{
			StatusCode: http.StatusUnauthorized,
			Msg:        msg,
			Err:        err,
		}
	}
	log = log.With(logz.AgentKey(agentKey))
	trace.SpanFromContext(ctx).SetAttributes(
		api.TraceAgentIDAttr.Int64(agentKey.ID),
		api.TraceAgentTypeAttr.String(agentKey.Type.String()),
	)

	var (
		userID    int64
		impConfig *kubernetes_api.ImpersonationConfig // can be nil
		ffs       featureflag.Set
	)

	switch c := creds.(type) {
	case ciJobTokenAuthn:
		allowedForJob, eResp := p.getAllowedAgentsForJob(ctx, log, agentKey, c.jobToken)
		if eResp != nil {
			return log, agentKey, 0, nil, creds, featureflag.Set{}, eResp
		}
		ffs = allowedForJob.FeatureFlags
		userID = allowedForJob.Response.User.Id

		aa := findAllowedAgent(agentKey, allowedForJob.Response)
		if aa == nil {
			msg := "Forbidden: agentKey is not allowed"
			log.Debug(msg)
			return log, agentKey, 0, nil, creds, ffs, &grpctool.ErrResp{
				StatusCode: http.StatusForbidden,
				Msg:        msg,
			}
		}

		impConfig, err = constructJobImpersonationConfig(allowedForJob.Response, aa)
		if err != nil {
			msg := "Failed to construct impersonation config"
			p.api.HandleProcessingError(ctx, log, msg, err, logz.AgentKey(agentKey))
			return log, agentKey, 0, nil, creds, ffs, &grpctool.ErrResp{
				StatusCode: http.StatusInternalServerError,
				Msg:        msg,
				Err:        err,
			}
		}

		// update usage metrics for `ci_access` requests using the CI tunnel
		p.utCIAccessRequestCounter.Inc()
		p.utCIAccessAgentsCounter.Add(agentKey.ID)
		p.utCIAccessEventTracker.EmitEvent(K8sAPIProxyRequestsEvent{UserID: userID, ProjectID: aa.ConfigProject.Id})
	case sessionCookieAuthn:
		auth, eResp := p.authorizeProxyUser(ctx, log, agentKey, gapi.SessionCookieAccessType, c.encryptedPublicSessionID, c.csrfToken)
		if eResp != nil {
			return log, agentKey, 0, nil, creds, featureflag.Set{}, eResp
		}
		ffs = auth.FeatureFlags
		userID = auth.Response.User.Id
		impConfig, err = constructUserImpersonationConfig(auth.Response, gapi.SessionCookieAccessType)
		if err != nil {
			msg := "Failed to construct user impersonation config"
			p.api.HandleProcessingError(ctx, log, msg, err, logz.AgentKey(agentKey))
			return log, agentKey, 0, nil, creds, ffs, &grpctool.ErrResp{
				StatusCode: http.StatusInternalServerError,
				Msg:        msg,
				Err:        err,
			}
		}

		// update usage metrics for `user_access` requests using the CI tunnel
		p.utUserAccessRequestCounter.Inc()
		p.utUserAccessAgentsCounter.Add(agentKey.ID)
		p.utUserAccessEventTracker.EmitEvent(K8sAPIProxyRequestsEvent{UserID: userID, ProjectID: auth.Response.Agent.ConfigProject.Id})
	case patAuthn:
		auth, eResp := p.authorizeProxyUser(ctx, log, agentKey, gapi.PersonalAccessTokenAccessType, c.token, "")
		if eResp != nil {
			return log, agentKey, 0, nil, creds, featureflag.Set{}, eResp
		}
		ffs = auth.FeatureFlags
		userID = auth.Response.User.Id
		impConfig, err = constructUserImpersonationConfig(auth.Response, gapi.PersonalAccessTokenAccessType)
		if err != nil {
			msg := "Failed to construct user impersonation config"
			p.api.HandleProcessingError(ctx, log, msg, err, logz.AgentKey(agentKey))
			return log, agentKey, 0, nil, creds, ffs, &grpctool.ErrResp{
				StatusCode: http.StatusInternalServerError,
				Msg:        msg,
				Err:        err,
			}
		}

		// update usage metrics for PAT requests using the CI tunnel
		p.utPatAccessRequestCounter.Inc()
		p.utPatAccessAgentsCounter.Add(agentKey.ID)
		p.utPatAccessEventTracker.EmitEvent(K8sAPIProxyRequestsEvent{UserID: userID, ProjectID: auth.Response.Agent.ConfigProject.Id})
	case webSocketTokenAuthn:
		impConfig = c.impConfig
		ffs = c.ffs

		// not going to update any metrics because it was already done via the token request
	default: // This should never happen
		msg := "Invalid authorization type"
		p.api.HandleProcessingError(ctx, log, msg, err, logz.AgentKey(agentKey))
		return log, agentKey, 0, nil, creds, featureflag.Set{}, &grpctool.ErrResp{
			StatusCode: http.StatusInternalServerError,
			Msg:        msg,
		}
	}
	return log, agentKey, userID, impConfig, creds, ffs, nil
}

func (p *kubernetesAPIProxy) getAllowedAgentsForJob(ctx context.Context, log *slog.Logger, agentKey api.AgentKey, jobToken string) (*gapi.AllowedAgentsForJob, *grpctool.ErrResp) {
	allowedForJob, err := p.allowedAgentsCache.GetItem(ctx, jobToken, func() (*gapi.AllowedAgentsForJob, error) {
		return gapi.GetAllowedAgentsForJob(ctx, p.gitLabClient, jobToken)
	})
	if err != nil {
		var status int32
		var msg string
		switch {
		case gitlab.IsUnauthorized(err):
			status = http.StatusUnauthorized
			msg = "Unauthorized: CI job token"
			log.Debug(msg, logz.Error(err))
		case gitlab.IsForbidden(err):
			status = http.StatusForbidden
			msg = "Forbidden: CI job token"
			log.Debug(msg, logz.Error(err))
		case gitlab.IsNotFound(err):
			status = http.StatusNotFound
			msg = "Not found: agents for CI job token"
			log.Debug(msg, logz.Error(err))
		default:
			status = http.StatusInternalServerError
			msg = "Failed to get allowed agents for CI job token"
			p.api.HandleProcessingError(ctx, log, msg, err, logz.AgentKey(agentKey))
		}
		return nil, &grpctool.ErrResp{
			StatusCode: status,
			Msg:        msg,
			Err:        err,
		}
	}
	return allowedForJob, nil
}

func (p *kubernetesAPIProxy) authorizeProxyUser(ctx context.Context, log *slog.Logger, agentKey api.AgentKey, accessType gapi.AccessType, accessKey, csrfToken string) (*gapi.AuthorizeProxyUserResponse, *grpctool.ErrResp) {
	key := proxyUserCacheKey{
		agentKey:   agentKey,
		accessType: accessType,
		accessKey:  accessKey,
		csrfToken:  csrfToken,
	}
	auth, err := p.authorizeProxyUserCache.GetItem(ctx, key, func() (*gapi.AuthorizeProxyUserResponse, error) {
		return gapi.AuthorizeProxyUser(ctx, p.gitLabClient, agentKey, accessType, accessKey, csrfToken)
	})
	if err != nil {
		switch {
		case gitlab.IsUnauthorized(err), gitlab.IsForbidden(err), gitlab.IsNotFound(err), gitlab.IsBadRequest(err):
			log.Debug("Authorize proxy user error", logz.Error(err))
			return nil, &grpctool.ErrResp{
				StatusCode: http.StatusUnauthorized,
				Msg:        "Unauthorized",
			}
		default:
			msg := "Failed to authorize user session"
			if errors.Is(err, context.Canceled) {
				log.Debug(msg, logz.AgentKey(agentKey), logz.Error(err))
			} else {
				p.api.HandleProcessingError(ctx, log, msg, err, logz.AgentKey(agentKey))
			}
			return nil, &grpctool.ErrResp{
				StatusCode: http.StatusInternalServerError,
				Msg:        msg,
			}
		}

	}

	return auth, nil
}

func (p *kubernetesAPIProxy) mergeProxiedResponseHeadersFunc(log *slog.Logger, agentKey api.AgentKey) func(o, i http.Header) {
	return func(o, i http.Header) {
		p.mergeProxiedResponseHeaders(log, agentKey, o, i)
	}
}

func (p *kubernetesAPIProxy) mergeProxiedResponseHeaders(log *slog.Logger, agentKey api.AgentKey, outbound, inbound http.Header) {
	delete(inbound, httpz.ServerHeader) // remove the header we've added above. We use Via instead.
	// remove all potential CORS headers from the proxied response
	delete(outbound, httpz.AccessControlAllowOriginHeader)
	delete(outbound, httpz.AccessControlAllowHeadersHeader)
	delete(outbound, httpz.AccessControlAllowCredentialsHeader)
	delete(outbound, httpz.AccessControlAllowMethodsHeader)
	delete(outbound, httpz.AccessControlMaxAgeHeader)
	delete(outbound, httpz.NELHeader)

	// set headers from proxied response without overwriting the ones already set (e.g. CORS headers)
	var blockedHeaders http.Header
	for k, vals := range outbound {
		if !p.isHeaderAllowed(k) {
			if !knownBlockedHeaders.Has(k) {
				if blockedHeaders == nil {
					blockedHeaders = make(http.Header)
				}

				blockedHeaders[k] = vals
			}

			// block!
			continue
		}

		if len(inbound[k]) == 0 {
			inbound[k] = vals
		}
	}
	if len(blockedHeaders) > 0 {
		p.api.HandleProcessingError(context.Background(), log, "", errBlockedResponseHeader, logz.AgentKey(agentKey), logz.ResponseHeaders(blockedHeaders))
	}

	// explicitly merge Vary header with the headers from proxies requests.
	// We always set the Vary header to `Origin` for CORS
	if v := append(inbound[httpz.VaryHeader], outbound[httpz.VaryHeader]...); len(v) > 0 { //nolint:gocritic
		inbound[httpz.VaryHeader] = v
	}
	inbound[httpz.ViaHeader] = append(inbound[httpz.ViaHeader], p.serverVia)

	// Prevent user agents from mime sniffing the response content type
	inbound[httpz.XContentTypeOptionsHeader] = []string{xContentTypeOptionsNoSniff}

	// Set Nel header max-age to 0 to remove any previous NEL configuration
	// see https://w3c.github.io/network-error-logging/#the-max_age-member
	// see https://w3c.github.io/network-error-logging/#example-2 (for example on how to remove existing NEL policies)
	inbound[httpz.NELHeader] = []string{nelRemovePoliciesPayload}

	p.addWarningHeaderIfOutdatedAgent(outbound, inbound)
}

func (p *kubernetesAPIProxy) isHeaderAllowed(name string) bool {
	return p.allowedResponseHeaders.Has(name) || slices.ContainsFunc(allowedResponseHeaderNamePrefixes, func(e string) bool { return strings.HasPrefix(name, e) })
}

func (p *kubernetesAPIProxy) addWarningHeaderIfOutdatedAgent(outbound, inbound http.Header) {
	agentVersionString := outbound.Get(httpz.GitlabAgentVersionHeader)
	if agentVersionString == "" {
		return
	}

	agentVersion, err := version.NewVersion(agentVersionString)
	if err != nil {
		p.log.Debug("Agent version from proxy response header is not valid", logz.Error(err))
		return
	}

	typ, warning := version.WarningIfOutdatedAgent(p.serverVersion, agentVersion, p.gitlabReleasesList)
	switch typ { //nolint:exhaustive
	case version.AgentVersionNoWarning:
		return
	default:
		inbound[httpz.WarningHeader] = []string{fmt.Sprintf("299 - %q", warning)}
	}
}

func (p *kubernetesAPIProxy) writeErrorResponse(log *slog.Logger, agentKey api.AgentKey, w http.ResponseWriter, r *http.Request, errorTypeAttr attribute.KeyValue) grpctool.WriteErrorResponse {
	return func(errResp *grpctool.ErrResp) {
		ctx := r.Context()
		p.requestsErrorCounter.Add(context.Background(), 1, otelmetric.WithAttributeSet(attribute.NewSet(
			errorTypeAttr,
			// these status code are trusted and never taken from the downstream response
			attribute.Int(statusCodeMetricAttributeName, int(errResp.StatusCode)),
		)))

		_, info, err := negotiation.NegotiateOutputMediaType(r, p.responseSerializer, negotiation.DefaultEndpointRestrictions)
		if err != nil {
			msg := "Failed to negotiate output media type"
			log.Debug(msg, logz.Error(err))
			http.Error(w, formatStatusMessage(ctx, msg, err), http.StatusNotAcceptable)
			return
		}
		message := formatStatusMessage(ctx, errResp.Msg, errResp.Err)
		s := &metav1.Status{
			TypeMeta: metav1.TypeMeta{
				Kind:       "Status",
				APIVersion: "v1",
			},
			Status:  metav1.StatusFailure,
			Message: message,
			Reason:  code2reason[errResp.StatusCode], // if mapping is not present, then "" means metav1.StatusReasonUnknown
			Code:    errResp.StatusCode,
		}
		bp := memz.Get32k() // use a temporary buffer to segregate I/O errors and encoding errors
		defer memz.Put32k(bp)
		buf := (*bp)[:0] // don't care what's in the buf, start writing from the start
		b := bytes.NewBuffer(buf)
		err = info.Serializer.Encode(s, b) // encoding errors
		if err != nil {
			p.api.HandleProcessingError(ctx, log, "Failed to encode status response", err, logz.AgentKey(agentKey))
			http.Error(w, message, int(errResp.StatusCode))
			return
		}
		w.Header()[httpz.ContentTypeHeader] = []string{info.MediaType}
		w.WriteHeader(int(errResp.StatusCode))
		_, _ = w.Write(b.Bytes()) // I/O errors
	}
}

func isHTTPRedirectStatusCode(statusCode int32) bool {
	return statusCode >= 300 && statusCode < 400
}

// isBrowserAuthn checks if the used creds are browser based.
// browser based:
// - session cookie authentication.
// - websocket token authentication.
// the websocket token authentication is considered a browser based
// authentication because it was ended out by providing
// a valid session cookie which is browser based.
func isBrowserAuthn(creds any) bool {
	switch creds.(type) {
	case sessionCookieAuthn, webSocketTokenAuthn:
		return true
	default:
		return false
	}
}

// isSessionCookieAuthn checks if the used creds is from a session cookie authentication
func isSessionCookieAuthn(creds any) bool {
	_, ok := creds.(sessionCookieAuthn)
	return ok
}

// err can be nil.
func formatStatusMessage(ctx context.Context, msg string, err error) string {
	var b strings.Builder
	b.WriteString("GitLab Agent Server: ")
	b.WriteString(msg)
	if err != nil {
		b.WriteString(": ")
		b.WriteString(err.Error())
	}
	traceID := trace.SpanContextFromContext(ctx).TraceID()
	if traceID.IsValid() {
		b.WriteString(". Trace ID: ")
		b.WriteString(traceID.String())
	}
	return b.String()
}

func findAllowedAgent(agentKey api.AgentKey, agentsForJob *gapi.AllowedAgentsForJobAPIResponse) *gapi.AllowedAgent {
	for _, aa := range agentsForJob.AllowedAgents {
		if aa.Id == agentKey.ID {
			return aa
		}
	}
	return nil
}

type ciJobTokenAuthn struct {
	jobToken string
}

type patAuthn struct {
	token string
}

type sessionCookieAuthn struct {
	encryptedPublicSessionID string
	csrfToken                string
}

type webSocketTokenAuthn struct {
	impConfig *kubernetes_api.ImpersonationConfig
	ffs       featureflag.Set
}

func isWebSocketRequest(r *http.Request) bool {
	return httpz.HasHeaderValue(r.Header, httpz.ConnectionHeader, "upgrade") && httpz.HasHeaderValue(r.Header, httpz.UpgradeHeader, "websocket")
}

func (p *kubernetesAPIProxy) getAuthorizationInfoFromRequest(r *http.Request) (api.AgentKey, any, error) {
	// NOTE: we intentionally have multiple disconnected ifs in this method to support authentication fallbacks.
	// For example, we may want to authenticate via session cookie with KAS but still send an Authorization header
	// to the Kubernetes API.

	if isWebSocketRequest(r) && len(r.Header[httpz.SecWebSocketProtocolHeader]) > 0 {
		for protocol := range httpz.HeaderValues(r.Header, httpz.SecWebSocketProtocolHeader) {
			webSocketToken, found := strings.CutPrefix(protocol, webSocketTokenSubProtocolPrefix)
			if !found {
				continue
			}

			url := fmt.Sprintf("%s/%s", r.Host, strings.TrimPrefix(r.URL.Path, "/"))
			agentKey, impConfig, ffs, err := p.webSocketToken.verify(webSocketToken, url)
			if err != nil {
				return api.AgentKey{}, nil, err
			}

			return agentKey, webSocketTokenAuthn{impConfig: impConfig, ffs: ffs}, nil
		}
	}

	if authzHeader := r.Header[httpz.AuthorizationHeader]; len(authzHeader) >= 1 {
		if len(authzHeader) > 1 {
			return api.AgentKey{}, nil, fmt.Errorf("%s header: expecting a single header, got %d", httpz.AuthorizationHeader, len(authzHeader))
		}
		agentKey, tokenType, token, err := getAgentKeyAndTokenFromHeader(authzHeader[0])
		if err != nil {
			return api.AgentKey{}, nil, err
		}
		switch tokenType {
		case tokenTypeCI:
			return agentKey, ciJobTokenAuthn{
				jobToken: token,
			}, nil
		case tokenTypePat:
			return agentKey, patAuthn{
				token: token,
			}, nil
		}
	}

	if cookie, err := r.Cookie(gitLabKASCookieName); err == nil {
		agentKey, encryptedPublicSessionID, csrfToken, err := getSessionCookieParams(cookie, r)
		if err != nil {
			return api.AgentKey{}, nil, err
		}
		return agentKey, sessionCookieAuthn{
			encryptedPublicSessionID: encryptedPublicSessionID,
			csrfToken:                csrfToken,
		}, nil
	}
	return api.AgentKey{}, nil, errors.New("no valid credentials provided")
}

func getSessionCookieParams(cookie *http.Cookie, r *http.Request) (api.AgentKey, string, string, error) {
	if cookie.Value == "" {
		return api.AgentKey{}, "", "", fmt.Errorf("%s cookie value must not be empty", gitLabKASCookieName)
	}
	// NOTE: GitLab Rails uses `rack` as the generic web server framework, which escapes the cookie values.
	// See https://github.com/rack/rack/blob/0b26518acc4c946ca96dfe3d9e68a05ca84439f7/lib/rack/utils.rb#L300
	encryptedPublicSessionID, err := url.QueryUnescape(cookie.Value)
	if err != nil {
		return api.AgentKey{}, "", "", fmt.Errorf("%s invalid cookie value", gitLabKASCookieName)
	}

	agentKey, err := getAgentKeyForSessionCookieRequest(r)
	if err != nil {
		return api.AgentKey{}, "", "", err
	}
	csrfToken, err := getCSRFTokenForSessionCookieRequest(r)
	if err != nil {
		return api.AgentKey{}, "", "", err
	}

	return agentKey, encryptedPublicSessionID, csrfToken, nil
}

// getAgentKeyForSessionCookieRequest retrieves the agent key from the request when trying to authenticate with a session cookie.
// First, the agent key is tried to be retrieved from the headers.
// If that fails, the query parameters are tried.
// When both the agent key is provided in the headers and the query parameters the header
// has precedence and the query parameter is silently ignored.
func getAgentKeyForSessionCookieRequest(r *http.Request) (api.AgentKey, error) {
	parseAgentID := func(agentIDStr string) (api.AgentKey, error) {
		agentID, err := strconv.ParseInt(agentIDStr, 10, 64)
		if err != nil {
			return api.AgentKey{}, fmt.Errorf("agent id in request: invalid value: %q", agentIDStr)
		}
		return api.AgentKey{ID: agentID, Type: api.AgentTypeKubernetes}, nil
	}

	// Check the agent id header and return if it is present and a valid
	agentIDHeader := r.Header[httpz.GitlabAgentIDHeader]
	if len(agentIDHeader) == 1 {
		return parseAgentID(agentIDHeader[0])
	}

	// If multiple agent id headers are given we abort with a failure
	if len(agentIDHeader) > 1 {
		return api.AgentKey{}, fmt.Errorf("%s header must have exactly one value", httpz.GitlabAgentIDHeader)
	}

	// Check the query parameters for a valid agent id
	agentIDParam := r.URL.Query()[httpz.GitlabAgentIDQueryParam]
	if len(agentIDParam) != 1 {
		return api.AgentKey{}, fmt.Errorf("exactly one agent id must be provided either in the %q header or %q query parameter", httpz.GitlabAgentIDHeader, httpz.GitlabAgentIDQueryParam)
	}
	return parseAgentID(agentIDParam[0])
}

// getCSRFTokenForSessionCookieRequest retrieves the CSRF token from the request when trying to authenticate with a session cookie.
// First, the CSRF token is tried to be retrieved from the headers.
// If that fails, the query parameters are tried.
// When both the CSRF token is provided in the headers and the query parameters the query parameter
// has precedence and the query parameter is silently ignored.
func getCSRFTokenForSessionCookieRequest(r *http.Request) (string, error) {
	// Check the CSRF token header and return if it is present
	csrfTokenHeader := r.Header[httpz.CSRFTokenHeader]
	if len(csrfTokenHeader) == 1 {
		return csrfTokenHeader[0], nil
	}

	// If multiple CSRF tokens headers are given we abort with a failure
	if len(csrfTokenHeader) > 1 {
		return "", fmt.Errorf("%s header must have exactly one value", httpz.CSRFTokenHeader)
	}

	// Check the query parameters for a valid CSRF token
	csrfTokenParam := r.URL.Query()[httpz.CSRFTokenQueryParam]
	if len(csrfTokenParam) != 1 {
		return "", fmt.Errorf("exactly one CSRF token must be provided either in the %q header or %q query parameter", httpz.CSRFTokenHeader, httpz.CSRFTokenQueryParam)
	}

	return csrfTokenParam[0], nil
}

func getAgentKeyAndTokenFromHeader(header string) (api.AgentKey, string /* token type */, string /* token */, error) {
	if !strings.HasPrefix(header, authorizationHeaderBearerPrefix) {
		// "missing" space in message - it's in the authorizationHeaderBearerPrefix constant already
		return api.AgentKey{}, "", "", fmt.Errorf("%s header: expecting %stoken", httpz.AuthorizationHeader, authorizationHeaderBearerPrefix)
	}
	tokenValue := header[len(authorizationHeaderBearerPrefix):]
	tokenType, tokenContents, found := strings.Cut(tokenValue, tokenSeparator)
	if !found {
		return api.AgentKey{}, "", "", fmt.Errorf("%s header: invalid value", httpz.AuthorizationHeader)
	}
	switch tokenType {
	case tokenTypeCI:
	case tokenTypePat:
	default:
		return api.AgentKey{}, "", "", fmt.Errorf("%s header: unknown token type", httpz.AuthorizationHeader)
	}
	agentIDAndToken := tokenContents
	agentIDStr, token, found := strings.Cut(agentIDAndToken, tokenSeparator)
	if !found {
		return api.AgentKey{}, "", "", fmt.Errorf("%s header: invalid value", httpz.AuthorizationHeader)
	}
	agentID, err := strconv.ParseInt(agentIDStr, 10, 64)
	if err != nil {
		return api.AgentKey{}, "", "", fmt.Errorf("%s header: failed to parse: %w", httpz.AuthorizationHeader, err)
	}
	if token == "" {
		return api.AgentKey{}, "", "", fmt.Errorf("%s header: empty token", httpz.AuthorizationHeader)
	}

	agentType := api.AgentTypeKubernetes
	return api.AgentKey{ID: agentID, Type: agentType}, tokenType, token, nil
}

func constructJobImpersonationConfig(allowedForJob *gapi.AllowedAgentsForJobAPIResponse, aa *gapi.AllowedAgent) (*kubernetes_api.ImpersonationConfig, error) {
	as := aa.GetConfiguration().GetAccessAs().GetAs() // all these fields are optional, so handle nils.
	switch imp := as.(type) {
	case nil, *agentcfg.CiAccessAsCF_Agent: // nil means default value, which is Agent.
		return nil, nil
	case *agentcfg.CiAccessAsCF_Impersonate:
		i := imp.Impersonate
		return &kubernetes_api.ImpersonationConfig{
			Username: i.Username,
			Groups:   i.Groups,
			Uid:      i.Uid,
			Extra:    impImpersonationExtra(i.Extra),
		}, nil
	case *agentcfg.CiAccessAsCF_CiJob:
		return &kubernetes_api.ImpersonationConfig{
			Username: fmt.Sprintf("gitlab:ci_job:%d", allowedForJob.Job.Id),
			Groups:   impCIJobGroups(allowedForJob),
			Extra:    impCIJobExtra(allowedForJob, aa),
		}, nil
	default:
		// Normally this should never happen
		return nil, fmt.Errorf("unexpected job impersonation mode: %T", imp)
	}
}

func constructUserImpersonationConfig(auth *gapi.AuthorizeProxyUserAPIResponse, accessType gapi.AccessType) (*kubernetes_api.ImpersonationConfig, error) {
	switch imp := auth.GetAccessAs().AccessAs.(type) {
	case *gapi.AccessAsProxyAuthorization_Agent:
		return nil, nil
	case *gapi.AccessAsProxyAuthorization_User:
		return &kubernetes_api.ImpersonationConfig{
			Username: fmt.Sprintf("gitlab:user:%s", auth.User.Username),
			Groups:   impUserGroups(imp.User),
			Extra:    impUserExtra(auth, accessType),
		}, nil
	default:
		// Normally this should never happen
		return nil, fmt.Errorf("unexpected user impersonation mode: %T", imp)
	}
}

func impImpersonationExtra(in []*agentcfg.ExtraKeyValCF) []*kubernetes_api.ExtraKeyVal {
	out := make([]*kubernetes_api.ExtraKeyVal, 0, len(in))
	for _, kv := range in {
		out = append(out, &kubernetes_api.ExtraKeyVal{
			Key: kv.Key,
			Val: kv.Val,
		})
	}
	return out
}

func impCIJobGroups(allowedForJob *gapi.AllowedAgentsForJobAPIResponse) []string {
	// 1. gitlab:ci_job to identify all requests coming from CI jobs.
	groups := make([]string, 0, 3+len(allowedForJob.Project.Groups))
	groups = append(groups, "gitlab:ci_job")
	// 2. The list of ids of groups the project is in.
	for _, projectGroup := range allowedForJob.Project.Groups {
		groups = append(groups, fmt.Sprintf("gitlab:group:%d", projectGroup.Id))

		// 3. The tier of the environment this job belongs to, if set.
		if allowedForJob.Environment != nil {
			groups = append(groups, fmt.Sprintf("gitlab:group_env_tier:%d:%s", projectGroup.Id, allowedForJob.Environment.Tier))
		}
	}
	// 4. The project id.
	groups = append(groups, fmt.Sprintf("gitlab:project:%d", allowedForJob.Project.Id))
	// 5. The slug and tier of the environment this job belongs to, if set.
	if allowedForJob.Environment != nil {
		groups = append(groups,
			fmt.Sprintf("gitlab:project_env:%d:%s", allowedForJob.Project.Id, allowedForJob.Environment.Slug),
			fmt.Sprintf("gitlab:project_env_tier:%d:%s", allowedForJob.Project.Id, allowedForJob.Environment.Tier),
		)
	}
	return groups
}

func impCIJobExtra(allowedForJob *gapi.AllowedAgentsForJobAPIResponse, aa *gapi.AllowedAgent) []*kubernetes_api.ExtraKeyVal {
	extra := []*kubernetes_api.ExtraKeyVal{
		{
			Key: api.AgentIDKey,
			Val: []string{strconv.FormatInt(aa.Id, 10)}, // agent id
		},
		{
			Key: api.ConfigProjectIDKey,
			Val: []string{strconv.FormatInt(aa.ConfigProject.Id, 10)}, // agent's configuration project id
		},
		{
			Key: api.ProjectIDKey,
			Val: []string{strconv.FormatInt(allowedForJob.Project.Id, 10)}, // CI project id
		},
		{
			Key: api.CIPipelineIDKey,
			Val: []string{strconv.FormatInt(allowedForJob.Pipeline.Id, 10)}, // CI pipeline id
		},
		{
			Key: api.CIJobIDKey,
			Val: []string{strconv.FormatInt(allowedForJob.Job.Id, 10)}, // CI job id
		},
		{
			Key: api.UsernameKey,
			Val: []string{allowedForJob.User.Username}, // username of the user the CI job is running as
		},
	}
	if allowedForJob.Environment != nil {
		extra = append(extra,
			&kubernetes_api.ExtraKeyVal{
				Key: api.EnvironmentSlugKey,
				Val: []string{allowedForJob.Environment.Slug}, // slug of the environment, if set
			},
			&kubernetes_api.ExtraKeyVal{
				Key: api.EnvironmentTierKey,
				Val: []string{allowedForJob.Environment.Tier}, // tier of the environment, if set
			},
		)
	}
	return extra
}

func impUserGroups(user *gapi.AccessAsUserAuthorization) []string {
	groups := []string{"gitlab:user"}
	for _, accessCF := range user.Projects {
		for _, role := range accessCF.Roles {
			groups = append(groups, fmt.Sprintf("gitlab:project_role:%d:%s", accessCF.Id, role))
		}
	}
	for _, accessCF := range user.Groups {
		for _, role := range accessCF.Roles {
			groups = append(groups, fmt.Sprintf("gitlab:group_role:%d:%s", accessCF.Id, role))
		}
	}
	return groups
}

func impUserExtra(auth *gapi.AuthorizeProxyUserAPIResponse, accessType gapi.AccessType) []*kubernetes_api.ExtraKeyVal {
	extra := []*kubernetes_api.ExtraKeyVal{
		{
			Key: api.AgentIDKey,
			Val: []string{strconv.FormatInt(auth.Agent.Id, 10)},
		},
		{
			Key: api.UsernameKey,
			Val: []string{auth.User.Username},
		},
		{
			Key: api.AgentKeyPrefix + "/access_type",
			Val: []string{string(accessType)},
		},
		{
			Key: api.ConfigProjectIDKey,
			Val: []string{strconv.FormatInt(auth.Agent.ConfigProject.Id, 10)},
		},
	}
	return extra
}

func resolveStatusCodeClassMetricOptions(statusCode int) []otelmetric.AddOption {
	// See https://datatracker.ietf.org/doc/html/rfc9110#name-status-codes
	if statusCode < 100 || statusCode > 599 {
		return statusCodeClassInvalidMetricOptions

	}
	// Integer division by 100 to get the hundreds digit (1-5)
	// which corresponds to the HTTP status code range
	return statusCodeClassMetricOptions[(statusCode/100)-1]
}
