package api

import (
	"bytes"
	"context"
	"errors"
	"io"
	"mime"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	repositoryroute "github.com/pingcap/agent-git-service/repositoryroutecontract"
	"github.com/tidbcloud/AgentTeam/desktop/internal/api/auth"
	"github.com/tidbcloud/AgentTeam/desktop/internal/api/localsecurity"
	"github.com/tidbcloud/AgentTeam/desktop/internal/profile"
	"github.com/tidbcloud/AgentTeam/desktop/internal/streamregistry"
	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
	"github.com/tidbcloud/AgentTeam/desktop/internal/uisession"
	"github.com/tidbcloud/AgentTeam/desktop/internal/userauth"
)

const maxRetryableGatewayBodyBytes = 8 << 20

var hopByHopHeaders = []string{
	"Connection",
	"Proxy-Connection",
	"Keep-Alive",
	"Proxy-Authenticate",
	"Proxy-Authorization",
	"Te",
	"Trailer",
	"Transfer-Encoding",
	"Upgrade",
}

type desktopGateway struct {
	connections types.ServerConnectionAccessor
	servers     interface {
		Get(string) (profile.AgentTeamServer, bool)
	}
	sessions *uisession.Store
	userAuth *userauth.Manager
	registry gatewayOperationRegistry
	// lifecycleMu guards lifecycles. The map is the per-session observer
	// surface for TestGatewayWebSocketLifecycleStopsAfterClientClose and
	// any future runtime watchdog: each WS proxyWebSocket registers its
	// tracker here so ObserveLifecycleStop can return a snapshot before
	// the per-session goroutines exit, then unregisters via defer.
	lifecycleMu sync.Mutex
	lifecycles  map[string]*wsSessionLifecycle
}

// gatewayOperationRegistry is the narrow admission surface used by the
// gateway handler. Keeping this as an interface lets integration tests place
// deterministic barriers immediately before and after the real Registry's
// Register call without adding production-only test hooks or exposing registry
// internals.
type gatewayOperationRegistry interface {
	Register(
		parent context.Context,
		sessionID string,
		generation uint64,
		serverID string,
	) (*streamregistry.Entry, error)
}

type gatewayRequestPayload struct {
	contentLength int64
	first         io.Reader
	replay        []byte
	replayable    bool
}

type gatewayQuery struct {
	raw           string
	values        url.Values
	credentialKey string
}

func newDesktopGateway(
	connections types.ServerConnectionAccessor,
	sessions *uisession.Store,
	registry gatewayOperationRegistry,
) *desktopGateway {
	if registry == nil {
		registry = streamregistry.New()
	}
	return &desktopGateway{
		connections: connections,
		servers:     profile.Default().Servers(),
		sessions:    sessions,
		registry:    registry,
	}
}

func (gateway *desktopGateway) handle(c *gin.Context) {
	if isInternalPath(c.Request.URL.Path) {
		c.JSON(http.StatusNotFound, gin.H{"error": "Not Found"})
		return
	}
	webSocketIntent := isWebSocketIntent(c.Request)
	if webSocketIntent {
		if err := websocketPreflight(c.Request); err != nil {
			writeWebSocketPreflightError(c, err)
			return
		}
	}
	query, err := parseGatewayQuery(c.Request.URL.RawQuery)
	if err != nil {
		writeGatewayError(c, http.StatusBadRequest, "INVALID_QUERY", "The request query is malformed.")
		return
	}
	if err := localsecurity.ValidateQueryCredentials(c.Request); err != nil {
		writeGatewayError(c, http.StatusBadRequest, "INVALID_QUERY_CREDENTIALS", "Query credentials are not allowed for this request.")
		return
	}
	if queryCredentialKey, ok := gatewayAuthenticateQuerySession(c, query.values); ok {
		query.credentialKey = queryCredentialKey
		gateway.proxy(c, query)
		return
	}
	if !webSocketIntent && isHTMLNavigation(c.Request) {
		c.JSON(http.StatusNotFound, gin.H{"error": "Not Found"})
		return
	}
	auth.Middleware()(c)
	if c.IsAborted() {
		return
	}
	gateway.proxy(c, query)
}

func gatewayAuthenticateQuerySession(
	c *gin.Context,
	query url.Values,
) (string, bool) {
	if c.Request.Header.Get("Authorization") != "" ||
		c.Request.Header.Get("X-Session-Token") != "" ||
		c.Request.Header.Get("X-Api-Key") != "" {
		return "", false
	}
	for _, candidate := range query["accessToken"] {
		if auth.AuthenticateSession(c, candidate) {
			return "accessToken", true
		}
	}
	return "", false
}

func (gateway *desktopGateway) proxy(c *gin.Context, query gatewayQuery) {
	webSocketIntent := isWebSocketIntent(c.Request)
	sessionID, _, ok := auth.SessionIdentity(c)
	if !ok {
		writeGatewayError(c, http.StatusUnauthorized, "SESSION_REQUIRED", "An authenticated Desktop session is required.")
		return
	}
	snapshot, ok := gateway.sessions.Get(sessionID)
	if !ok || snapshot.ActiveServerID == "" {
		writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_REQUIRED", "Select a AgentTeam server to continue.")
		return
	}
	generationChanged, releaseGenerationWatch, ok := gateway.sessions.WatchGeneration(
		sessionID,
		snapshot.Generation,
	)
	if !ok {
		writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_CHANGED", "The active AgentTeam server changed before the request began.")
		return
	}
	defer releaseGenerationWatch()
	entry, err := gateway.registry.Register(
		c.Request.Context(),
		sessionID,
		snapshot.Generation,
		snapshot.ActiveServerID,
	)
	if err != nil {
		writeGatewayError(c, http.StatusServiceUnavailable, "REGISTRY_CLOSED", "The desktop daemon is shutting down; retry shortly.")
		return
	}
	defer entry.Release()
	go func() {
		select {
		case <-generationChanged:
			entry.Cancel(streamregistry.CancelError{
				Reason: streamregistry.ReasonGenerationChange,
			})
		case <-entry.Released():
		}
	}()
	// The body-read interrupt goroutine is HTTP-only. It calls
	// SetReadDeadline on c.Writer to unblock a handler parked in
	// prepareGatewayRequestBody/io.ReadAll; gorilla's WS path never
	// parks there because it owns a hijacked conn. If we start this
	// goroutine for WS intents, the WS success handshake races gin's
	// context pool reset: gin recycles *gin.Context once ServeHTTP
	// returns, but the goroutine still selects on entry.Context().Done()
	// and reads c.Writer — a data race surfaced by
	// TestGatewayWebSocketAllowsSameOriginAndMissingOrigin under -race.
	// The WS pipeline interrupts its reads via conn close / pump
	// ownership in P4; it does not need this goroutine.
	if !webSocketIntent {
		go func() {
			select {
			case <-entry.Context().Done():
				_ = maybeSetReadDeadline(c.Writer, time.Now())
			case <-entry.Released():
			}
		}()
	}
	if !gateway.sessions.MatchesGeneration(sessionID, snapshot.Generation) {
		entry.Cancel(streamregistry.CancelError{
			Reason: streamregistry.ReasonGenerationChange,
		})
		writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_CHANGED", "The active AgentTeam server changed before the request began.")
		return
	}
	requestContext, cancelRequest := context.WithCancel(entry.Context())
	defer cancelRequest()

	server, ok := gateway.servers.Get(snapshot.ActiveServerID)
	if !ok || !server.Enabled {
		gateway.sessions.ClearIfGeneration(sessionID, snapshot.Generation)
		writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_REQUIRED", "Select a AgentTeam server to continue.")
		return
	}
	if availability, ok := gateway.servers.(interface {
		ServerAvailableForSelection(string) bool
	}); ok && !availability.ServerAvailableForSelection(server.ID) {
		gateway.sessions.InvalidateServer(server.ID)
		writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_REQUIRED", "The selected AgentTeam server runtime is unavailable.")
		return
	}
	rawConnection, ok := gatewayConnection(gateway.connections, gateway.userAuth, server.ID)
	if !ok {
		writeGatewayError(c, http.StatusBadGateway, "REMOTE_UNREACHABLE", "The selected AgentTeam server is unavailable.")
		return
	}
	connection, ok := rawConnection.(types.ServerGatewayConnection)
	if !ok {
		writeGatewayError(c, http.StatusBadGateway, "REMOTE_UNREACHABLE", "The selected AgentTeam server cannot serve web traffic.")
		return
	}
	upstream, err := validateGatewayUpstream(server, connection)
	if err != nil {
		writeGatewayError(c, http.StatusBadGateway, "REMOTE_UNREACHABLE", "The selected AgentTeam server is unavailable.")
		return
	}

	if webSocketIntent {
		gateway.proxyWebSocket(
			c,
			query,
			entry,
			requestContext,
			sessionID,
			snapshot,
			connection,
			upstream,
			server.WorkspaceID,
		)
		return
	}

	body, err := prepareGatewayRequestBody(c.Request)
	if err != nil {
		if gateway.writeOperationCancel(c, entry) {
			return
		}
		writeGatewayError(c, http.StatusBadRequest, "INVALID_REQUEST_BODY", "Unable to read the request body.")
		return
	}
	userToken, err := connection.UserToken(requestContext)
	if err != nil {
		if gateway.writeOperationCancel(c, entry) {
			return
		}
		if !gateway.sessions.MatchesGeneration(sessionID, snapshot.Generation) {
			writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_CHANGED", "The active AgentTeam server changed while the request was running.")
			return
		}
		if userauth.SavedLoginErrorCode(err) == userauth.SavedLoginErrorReauthenticationRequired {
			writeGatewayError(c, http.StatusUnauthorized, "REAUTHENTICATION_REQUIRED", "Sign in again to continue.")
			return
		}
		writeGatewayError(c, http.StatusBadGateway, "REMOTE_UNREACHABLE", "Unable to authenticate with the selected AgentTeam server.")
		return
	}
	response, err := gateway.doRequest(
		requestContext,
		c.Request,
		upstream,
		connection,
		body,
		userToken,
		query,
		false,
		server.WorkspaceID,
	)
	if err != nil {
		if gateway.writeOperationCancel(c, entry) {
			return
		}
		if !gateway.sessions.MatchesGeneration(sessionID, snapshot.Generation) {
			writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_CHANGED", "The active AgentTeam server changed while the request was running.")
			return
		}
		writeGatewayError(c, http.StatusBadGateway, "REMOTE_UNREACHABLE", "Unable to reach the selected AgentTeam server.")
		return
	}
	if response.StatusCode == http.StatusUnauthorized && body.replayable {
		_ = response.Body.Close()
		userToken, err = connection.RefreshUserToken(requestContext, userToken)
		if err == nil {
			response, err = gateway.doRequest(
				requestContext,
				c.Request,
				upstream,
				connection,
				body,
				userToken,
				query,
				true,
				server.WorkspaceID,
			)
		}
		if err != nil {
			if gateway.writeOperationCancel(c, entry) {
				return
			}
			if !gateway.sessions.MatchesGeneration(sessionID, snapshot.Generation) {
				writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_CHANGED", "The active AgentTeam server changed while the request was running.")
				return
			}
			if userauth.SavedLoginErrorCode(err) == userauth.SavedLoginErrorReauthenticationRequired {
				writeGatewayError(c, http.StatusUnauthorized, "REAUTHENTICATION_REQUIRED", "Sign in again to continue.")
				return
			}
			writeGatewayError(c, http.StatusBadGateway, "REMOTE_UNREACHABLE", "Unable to reach the selected AgentTeam server.")
			return
		}
	}
	defer response.Body.Close()

	if gateway.writeOperationCancel(c, entry) {
		return
	}
	if !gateway.sessions.MatchesGeneration(sessionID, snapshot.Generation) {
		writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_CHANGED", "The active AgentTeam server changed while the request was running.")
		return
	}
	if !rewriteGatewayRedirect(c.Request, upstream, response) {
		writeGatewayError(c, http.StatusBadGateway, "REMOTE_REDIRECT_REJECTED", "The selected AgentTeam server returned an unsafe redirect.")
		return
	}
	mediaType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
	isSSE := mediaType == "text/event-stream"
	var sseFlusher http.Flusher
	if isSSE {
		var supported bool
		sseFlusher, supported = leafFlusherOf(c.Writer)
		if !supported {
			writeGatewayError(
				c,
				http.StatusBadGateway,
				"SSE_NOT_SUPPORTED",
				"The local gateway response writer cannot flush server-sent events.",
			)
			return
		}
	}
	copyGatewayResponseHeaders(c.Writer.Header(), response.Header)
	c.Status(response.StatusCode)
	bodyReader := newStreamBodyReaderWithContext(requestContext, response.Body, streamBodyConfig{
		IdleTimeout: defaultStreamBodyIdleTimeout,
		Cancel:      cancelRequest,
	})
	defer bodyReader.Close()
	if isSSE {
		_, _ = copySSEBody(c.Writer, bodyReader, sseFlusher)
		return
	}
	_, _ = io.Copy(c.Writer, bodyReader)
}

// writeOperationCancel maps a typed registry cancellation to the gateway's
// pre-commit HTTP contract. It returns true whenever the operation is already
// canceled, including causes for which no response should be written.
func (gateway *desktopGateway) writeOperationCancel(
	c *gin.Context,
	entry *streamregistry.Entry,
) bool {
	if entry == nil {
		return false
	}
	cause := context.Cause(entry.Context())
	if cause == nil {
		return false
	}
	return gateway.writePreCommitCancelCause(c, cause)
}

// writePreCommitCancelCause routes a pre-commit cancel cause to the typed HTTP
// contract. Returns true whenever the caller must abort the upcoming stage,
// including causes for which no response is written. The mapping is:
//
//   - ReasonGenerationChange → 409 ACTIVE_SERVER_CHANGED
//   - ReasonDaemonShutdown   → 503 REGISTRY_CLOSED
//   - ReasonClientDisconnect / context.DeadlineExceeded → no response
//     (the inbound client/deadline is already gone; synthesizing a body
//     cannot reach it)
//   - unknown causes → fail closed without inventing a new HTTP contract
//
// Used by both the pre-stage fence (writeOperationCancel +
// writeTypedCancelOrGenerationChange) and the TryCommit handoff so the
// cause-to-HTTP mapping is identical at every pre-commit boundary.
func (gateway *desktopGateway) writePreCommitCancelCause(c *gin.Context, cause error) bool {
	if cause == nil {
		return false
	}
	if c.Writer.Written() {
		return true
	}
	switch {
	case errors.Is(cause, streamregistry.ReasonGenerationChange):
		writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_CHANGED", "The active AgentTeam server changed while the request was running.")
	case errors.Is(cause, streamregistry.ReasonDaemonShutdown):
		writeGatewayError(c, http.StatusServiceUnavailable, "REGISTRY_CLOSED", "The desktop daemon is shutting down; retry shortly.")
	case errors.Is(cause, streamregistry.ReasonClientDisconnect),
		errors.Is(cause, context.DeadlineExceeded):
		// The inbound client/deadline is already gone; do not synthesize a
		// response that cannot be delivered. P1 intentionally keeps transport
		// header timeouts on the existing 502 path.
	default:
		// Unknown cancellation causes fail closed without inventing a new HTTP
		// contract.
	}
	return true
}

// writeTypedCancelOrGenerationChange is the unified pre-stage fence for the
// WebSocket pipeline. It composes two independent cancellation signals:
//
//  1. The registry's typed cause (already applied to entry.Context()).
//     Surfaced via writeOperationCancel which routes the cause to the typed
//     HTTP contract.
//  2. The session generation mismatch (Cancel is in-flight from the
//     generation-watcher goroutine but has not yet reached the entry).
//     Surfaced as 409 ACTIVE_SERVER_CHANGED so a generation change that
//     races with the just-returned blocking op cannot silently proceed
//     to the next stage.
//
// Returns true whenever the caller must abort the upcoming stage. On a true
// return the typed HTTP response has been written (or intentionally
// suppressed for client disconnect / deadline).
func (gateway *desktopGateway) writeTypedCancelOrGenerationChange(
	c *gin.Context,
	entry *streamregistry.Entry,
	sessionID string,
	generation uint64,
) bool {
	if gateway.writeOperationCancel(c, entry) {
		return true
	}
	if !gateway.sessions.MatchesGeneration(sessionID, generation) {
		if !c.Writer.Written() {
			writeGatewayError(c, http.StatusConflict, "ACTIVE_SERVER_CHANGED",
				"The active AgentTeam server changed while the request was running.")
		}
		return true
	}
	return false
}

func (gateway *desktopGateway) doRequest(
	ctx context.Context,
	source *http.Request,
	upstream *url.URL,
	connection types.ServerGatewayConnection,
	body gatewayRequestPayload,
	userToken string,
	query gatewayQuery,
	retry bool,
	workspaceID string,
) (*http.Response, error) {
	target := *upstream
	target.Path = joinGatewayPath(upstream.Path, source.URL.Path)
	target.RawPath = joinGatewayPath(upstream.EscapedPath(), source.URL.EscapedPath())
	target.RawQuery = query.rewrite(userToken)

	bodyReader := body.first
	if retry {
		bodyReader = bytes.NewReader(body.replay)
	}
	request, err := http.NewRequestWithContext(ctx, source.Method, target.String(), bodyReader)
	if err != nil {
		return nil, err
	}
	request.ContentLength = body.contentLength
	request.Header = cloneGatewayRequestHeaders(source.Header)
	request.Header.Set("Authorization", "Bearer "+userToken)
	applyAuthoritativeWorkspaceHeadersForRequest(request.Header, source, workspaceID)
	request.Host = target.Host

	client := connection.GatewayHTTPClient()
	if client == nil {
		client = http.DefaultClient
	}
	return client.Do(request)
}

func applyAuthoritativeWorkspaceHeadersForRequest(
	target http.Header,
	source *http.Request,
	workspaceID string,
) {
	target.Del("X-Workspace-Id")
	target.Del("X-AgentTeam-Route-Workspace-Id")
	target.Del("X-AgentTeam-Frontend-Build")
	target.Del("X-AgentTeam-Route-Source")
	if source == nil {
		return
	}
	workspaceID = strings.TrimSpace(workspaceID)
	if workspaceID == "" {
		return
	}
	if pathWorkspaceID, ok := desktopGatewayPathWorkspaceID(source.URL.Path); ok {
		if pathWorkspaceID == workspaceID {
			target.Set("X-Workspace-Id", workspaceID)
			target.Set("X-AgentTeam-Route-Workspace-Id", workspaceID)
		}
		return
	}
	scope := repositoryroute.Classify(source)
	if scope.EdgePolicy == repositoryroute.ScopeSessionWorkspace || scope.EdgePolicy == repositoryroute.ScopeRepositoryCredentialOrSession {
		target.Set("X-Workspace-Id", workspaceID)
		target.Set("X-AgentTeam-Route-Workspace-Id", workspaceID)
		return
	}
	if isDesktopGatewayWorkspaceRoute(source.URL.Path) {
		target.Set("X-Workspace-Id", workspaceID)
		target.Set("X-AgentTeam-Route-Workspace-Id", workspaceID)
	}
}

func desktopGatewayPathWorkspaceID(path string) (string, bool) {
	const prefix = "/api/v1/workspaces/"
	if !strings.HasPrefix(path, prefix) {
		return "", false
	}
	rest := strings.TrimPrefix(path, prefix)
	workspaceID, _, _ := strings.Cut(rest, "/")
	workspaceID = strings.TrimSpace(workspaceID)
	return workspaceID, workspaceID != ""
}

func isDesktopGatewayWorkspaceRoute(path string) bool {
	if path == "/api/v1/agent-thread/session-channel" {
		return true
	}
	// These unscoped workspace-data prefixes are a transport compatibility
	// inventory, not URL constructors. The Desktop gateway still adds the
	// selected workspace headers for an older embedded Web bundle so the remote
	// Edge Gateway can perform the only supported legacy-to-canonical rewrite.
	// Remove them after pre-maintenance Web/Desktop builds are no longer
	// supported; new callers construct /api/v1/workspaces/:workspaceId paths.
	for _, prefix := range []string{
		"/api/agents",
		"/api/llm",
		"/api/v1/agents",
		"/api/v1/agents-for-current-member",
		"/api/v1/billing",
		"/api/v1/review-context",
		"/api/v1/kb",
		"/api/v1/memory",
		"/api/v1/skills",
		"/api/v1/roles",
		"/api/v1/roles:from-agent",
		"/api/v1/repository-owners",
		"/api/v1/repository-names",
		"/api/v1/repository-access-tokens",
		"/api/v1/personal-access-tokens",
		"/api/v1/automations",
		"/api/v1/mcp",
		"/api/v1/repositories",
	} {
		if path == prefix || strings.HasPrefix(path, prefix+"/") {
			return true
		}
	}
	parts := strings.Split(strings.Trim(path, "/"), "/")
	return len(parts) == 4 && parts[0] == "api" && parts[1] == "apps" &&
		(parts[2] == "github" || parts[2] == "gitea") && parts[3] == "webhook"
}

func validateGatewayUpstream(
	server profile.AgentTeamServer,
	connection types.ServerGatewayConnection,
) (*url.URL, error) {
	configured, err := url.Parse(strings.TrimSpace(server.ServerURL))
	if err != nil {
		return nil, err
	}
	running, err := url.Parse(strings.TrimSpace(connection.ServerURL()))
	if err != nil {
		return nil, err
	}
	if !gatewayURLAllowed(configured) || !gatewayURLAllowed(running) ||
		!sameGatewayOrigin(configured, running) {
		return nil, errors.New("running upstream does not match server registry")
	}
	return configured, nil
}

func gatewayURLAllowed(upstream *url.URL) bool {
	if upstream == nil || upstream.User != nil || upstream.Host == "" {
		return false
	}
	return upstream.Scheme == "https" || upstream.Scheme == "http"
}

func prepareGatewayRequestBody(request *http.Request) (gatewayRequestPayload, error) {
	if request == nil || request.Body == nil || request.Body == http.NoBody {
		return gatewayRequestPayload{contentLength: 0, replayable: true}, nil
	}
	if request.ContentLength < 0 || request.ContentLength > maxRetryableGatewayBodyBytes {
		return gatewayRequestPayload{
			contentLength: request.ContentLength,
			first:         request.Body,
		}, nil
	}
	data, err := io.ReadAll(request.Body)
	if err != nil {
		return gatewayRequestPayload{}, err
	}
	return gatewayRequestPayload{
		contentLength: int64(len(data)),
		first:         bytes.NewReader(data),
		replay:        data,
		replayable:    true,
	}, nil
}

func cloneGatewayRequestHeaders(source http.Header) http.Header {
	target := make(http.Header, len(source))
	for key, values := range source {
		target[key] = append([]string(nil), values...)
	}
	for _, value := range source.Values("Connection") {
		for _, key := range strings.Split(value, ",") {
			target.Del(strings.TrimSpace(key))
		}
	}
	for _, key := range hopByHopHeaders {
		target.Del(key)
	}
	target.Del("Authorization")
	target.Del("Cookie")
	target.Del("Host")
	target.Del("X-Session-Token")
	target.Del("X-Api-Key")
	target.Del("X-Workspace-Id")
	target.Del("X-AgentTeam-Route-Workspace-Id")
	target.Del("X-AgentTeam-Frontend-Build")
	target.Del("X-AgentTeam-Route-Source")
	target.Del("Forwarded")
	target.Del("X-Forwarded-For")
	target.Del("X-Forwarded-Host")
	target.Del("X-Forwarded-Port")
	target.Del("X-Forwarded-Proto")
	target.Del("X-Forwarded-Server")
	target.Del("X-Real-Ip")
	target.Del("Origin")
	target.Del("Referer")
	return target
}

func copyGatewayResponseHeaders(target, source http.Header) {
	for key, values := range source {
		for _, value := range values {
			target.Add(key, value)
		}
	}
	for _, value := range source.Values("Connection") {
		for _, key := range strings.Split(value, ",") {
			target.Del(strings.TrimSpace(key))
		}
	}
	for _, key := range hopByHopHeaders {
		target.Del(key)
	}
	target.Del("Set-Cookie")
}

func rewriteGatewayRedirect(source *http.Request, upstream *url.URL, response *http.Response) bool {
	if response.StatusCode < 300 || response.StatusCode >= 400 {
		return true
	}
	location := strings.TrimSpace(response.Header.Get("Location"))
	if location == "" {
		return true
	}
	resolved, err := upstream.Parse(location)
	if err != nil || !sameGatewayOrigin(upstream, resolved) {
		return false
	}
	local := *resolved
	if source.TLS != nil {
		local.Scheme = "https"
	} else {
		local.Scheme = "http"
	}
	local.Host = source.Host
	response.Header.Set("Location", local.String())
	return true
}

func sameGatewayOrigin(left, right *url.URL) bool {
	if left == nil || right == nil {
		return false
	}
	return right.User == nil && strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host)
}

func joinGatewayPath(base, requestPath string) string {
	base = strings.TrimRight(base, "/")
	if requestPath == "" {
		requestPath = "/"
	}
	if !strings.HasPrefix(requestPath, "/") {
		requestPath = "/" + requestPath
	}
	return base + requestPath
}

func parseGatewayQuery(rawQuery string) (gatewayQuery, error) {
	values, err := url.ParseQuery(rawQuery)
	if err != nil {
		return gatewayQuery{}, err
	}
	credentialKey := ""
	if _, ok := values["accessToken"]; ok {
		credentialKey = "accessToken"
	}
	return gatewayQuery{
		raw:           rawQuery,
		values:        values,
		credentialKey: credentialKey,
	}, nil
}

func (query gatewayQuery) rewrite(userToken string) string {
	if query.credentialKey == "" {
		return query.raw
	}
	values := make(url.Values, len(query.values))
	for key, current := range query.values {
		values[key] = append([]string(nil), current...)
	}
	values.Del("accessToken")
	values.Set(query.credentialKey, userToken)
	return values.Encode()
}

func isInternalPath(path string) bool {
	return path == "/api/internal" || strings.HasPrefix(path, "/api/internal/")
}

func isHTMLNavigation(request *http.Request) bool {
	if request == nil ||
		(request.Method != http.MethodGet && request.Method != http.MethodHead) {
		return false
	}
	for _, value := range strings.Split(request.Header.Get("Accept"), ",") {
		mediaType := strings.TrimSpace(strings.SplitN(value, ";", 2)[0])
		if mediaType != "text/html" && mediaType != "application/xhtml+xml" {
			continue
		}
		mode := strings.TrimSpace(
			strings.ToLower(request.Header.Get("Sec-Fetch-Mode")),
		)
		return mode == "" || mode == "navigate"
	}
	return false
}

func writeGatewayError(c *gin.Context, status int, code, message string) {
	c.JSON(status, gin.H{"code": code, "error": message})
}
