// Package gateway contains the public edge gateway used by SaaS split deployments.
package gateway

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httputil"
	"net/url"
	"strconv"
	"strings"
	"time"

	repositoryroute "github.com/pingcap/agent-git-service/repositoryroutecontract"
	"github.com/tidbcloud/AgentTeam/internal/agsbridge/workspaceurl"
	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/config"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
	"github.com/tidbcloud/AgentTeam/internal/workspace/router"
)

// Resolver resolves a workspace id to its current owner group.
type Resolver interface {
	ResolveWorkspaceRouteTarget(ctx context.Context, workspaceID string) (workspace.WorkspaceRouteTarget, error)
}

// Config defines the gateway upstream boundary.
type Config struct {
	CentralUpstream    string
	WorkspaceUpstreams map[string]string
	ReadinessClient    *http.Client
	IdentityResolver   WorkspaceIdentityResolver
	// TrustedJWTHeader is the protected identity header configured for the
	// split deployment. Split mode deliberately supports only the shared
	// default so the edge sanitizer, target bridge, and embedded AGS cannot
	// disagree about which client-supplied header is security-sensitive.
	TrustedJWTHeader string
	// ExtensionUpstream optionally configures the ProfileExtension reverse-proxy
	// target for split deployments (spec §7). It must be origin-only — exactly
	// http(s)://host[:port] with no path (a single root '/' is accepted as the
	// equivalent of no path), no query, no userinfo, and no fragment — and the
	// proxy forwards the original request path and query unchanged. When unset,
	// Extension runtime routes (/api/v1/workspaces/{workspaceId}/agents/{agentId}
	// /runtime/ext and descendants) are still recognized by the classifier but
	// fail closed at the gateway rather than being forwarded to a central or
	// workspace upstream.
	ExtensionUpstream string
}

// Handler routes public edge traffic to central or workspace upstreams.
type Handler struct {
	centralProxy     *httputil.ReverseProxy
	extensionProxy   *httputil.ReverseProxy
	workspace        *router.Handler
	identityResolver WorkspaceIdentityResolver
	readinessClient  *http.Client
	readinessTargets []readinessTarget
}

type readinessTarget struct {
	name string
	url  *url.URL
}

// NewHandler builds a gateway handler with explicit upstream configuration.
func NewHandler(resolver Resolver, cfg Config) (*Handler, error) {
	if _, err := config.NormalizeSplitTrustedJWTHeader(cfg.TrustedJWTHeader); err != nil {
		return nil, fmt.Errorf("gateway trusted identity header: %w", err)
	}
	central, err := parseRequiredUpstream("central", cfg.CentralUpstream)
	if err != nil {
		return nil, err
	}
	if len(cfg.WorkspaceUpstreams) == 0 {
		return nil, fmt.Errorf("gateway workspace upstreams are required")
	}
	workspaceUpstreams, err := parseWorkspaceUpstreams(cfg.WorkspaceUpstreams)
	if err != nil {
		return nil, err
	}
	if cfg.IdentityResolver == nil {
		return nil, fmt.Errorf("gateway workspace identity resolver is required")
	}
	workspaceHandler, err := router.NewHandler(activePlacementResolver{inner: resolver}, cfg.WorkspaceUpstreams)
	if err != nil {
		return nil, fmt.Errorf("create workspace gateway router: %w", err)
	}
	proxy := newSanitizedReverseProxy(central)
	// The Extension upstream is optional (spec §7 split-deployment model). When
	// configured it must be origin-only (http(s)://host[:port]); it is
	// reverse-proxied with the request path unchanged and participates in
	// readiness. When absent, recognized Extension routes fail closed at the
	// gateway.
	var extensionProxy *httputil.ReverseProxy
	var extensionURL *url.URL
	if raw := strings.TrimSpace(cfg.ExtensionUpstream); raw != "" {
		parsed, err := parseExtensionUpstream(raw)
		if err != nil {
			return nil, err
		}
		extensionURL = parsed
		extensionProxy = newExtensionReverseProxy(parsed)
	}
	client := cfg.ReadinessClient
	if client == nil {
		client = &http.Client{Timeout: 2 * time.Second}
	}
	readinessTargets := append([]readinessTarget{{name: "central", url: central}}, workspaceUpstreams...)
	if extensionURL != nil {
		readinessTargets = append(readinessTargets, readinessTarget{name: "extension", url: extensionURL})
	}
	return &Handler{
		centralProxy:     proxy,
		extensionProxy:   extensionProxy,
		workspace:        workspaceHandler,
		identityResolver: cfg.IdentityResolver,
		readinessClient:  client,
		readinessTargets: readinessTargets,
	}, nil
}

// newExtensionReverseProxy forwards Extension runtime traffic to the ProfileExtension
// upstream with the path and query unchanged (spec §7: the Extension server
// re-authenticates workspace and agent). Its Director pins the incoming path and
// query after the base director runs so the path-unchanged contract holds
// regardless of the configured upstream shape. Its ErrorHandler replaces any
// connection-level error with a fixed message so the internal upstream URL is
// never reflected back to the client or into logs (spec §7: "never exposes its
// URL in errors").
func newExtensionReverseProxy(upstream *url.URL) *httputil.ReverseProxy {
	proxy := httputil.NewSingleHostReverseProxy(upstream)
	originalDirector := proxy.Director
	proxy.Director = func(out *http.Request) {
		// Forward the original request path and query UNCHANGED (spec §7). The
		// origin-only validation (parseExtensionUpstream) already ensures the
		// base director injects no path prefix or query, but pinning them here
		// makes the contract explicit and robust independent of upstream shape.
		incomingPath := out.URL.Path
		incomingRawPath := out.URL.RawPath
		incomingRawQuery := out.URL.RawQuery
		originalDirector(out)
		out.URL.Path = incomingPath
		out.URL.RawPath = incomingRawPath
		out.URL.RawQuery = incomingRawQuery
		out.Host = upstream.Host
	}
	proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) {
		http.Error(writer, "extension runtime upstream unavailable", http.StatusBadGateway)
	}
	return proxy
}

// parseExtensionUpstream validates that the Extension upstream is an origin-only
// URL (spec §7): exactly http(s)://host[:port] with no path, query, userinfo, or
// fragment. The reverse proxy forwards the original request path and query
// unchanged, so any configured path or query would be injected onto proxied
// requests (and userinfo/fragment are never meaningful for an internal upstream).
// Rejecting non-origin upstreams at config time keeps the contract explicit and
// fail-closed rather than silently rewriting proxied traffic.
//
// The authority is validated precisely: the hostname must be non-empty (rejecting
// forms like http://:3002). A port is optional. A bracketed IPv6 literal (e.g.
// http://[::1]) contains colons as part of the address and is a valid host with
// no port; only a ":" port-separator OUTSIDE the brackets introduces a port, and
// when present it must be a non-empty numeric value in [1, 65535] (rejecting
// http://host:, http://host:0, and http://host:70000). A bare query separator
// (ForceQuery, e.g. http://host?) is a query signal and is rejected even when the
// query string itself is empty. The root path is accepted in either of its two
// equivalent forms — empty or a single '/' — so http://host:3002 and
// http://host:3002/ are both valid origins.
func parseExtensionUpstream(raw string) (*url.URL, error) {
	parsed, err := parseRequiredUpstream("extension", raw)
	if err != nil {
		return nil, err
	}
	switch parsed.Scheme {
	case "http", "https":
	default:
		return nil, fmt.Errorf("gateway extension upstream URL must use http or https scheme")
	}
	if parsed.User != nil {
		return nil, fmt.Errorf("gateway extension upstream URL must be origin-only: userinfo is forbidden")
	}
	// Root path: empty and a single '/' are equivalent origins. Any other path
	// would be injected onto proxied requests.
	if (parsed.Path != "" && parsed.Path != "/") || parsed.RawPath != "" {
		return nil, fmt.Errorf("gateway extension upstream URL must be origin-only: path is forbidden")
	}
	// ForceQuery (a bare '?' with an empty query, e.g. http://host?) is a query
	// signal and is forbidden, as is any non-empty query.
	if parsed.ForceQuery || parsed.RawQuery != "" {
		return nil, fmt.Errorf("gateway extension upstream URL must be origin-only: query is forbidden")
	}
	if parsed.Fragment != "" {
		return nil, fmt.Errorf("gateway extension upstream URL must be origin-only: fragment is forbidden")
	}
	// Authority precision: hostname must be non-empty (rejects http://:3002).
	if parsed.Hostname() == "" {
		return nil, fmt.Errorf("gateway extension upstream URL must be origin-only: hostname is required")
	}
	// A port is optional. extensionPortSeparatorPresent distinguishes an IPv6
	// literal's address colons (no separator, valid with no port) from an
	// explicit port separator (":" outside the brackets). When a separator is
	// present the port must be a non-empty numeric value in [1, 65535].
	if extensionPortSeparatorPresent(parsed.Host) {
		port, err := strconv.Atoi(parsed.Port())
		if err != nil {
			return nil, fmt.Errorf("gateway extension upstream URL must be origin-only: port is malformed")
		}
		if port < 1 || port > 65535 {
			return nil, fmt.Errorf("gateway extension upstream URL must be origin-only: port %d is out of range [1, 65535]", port)
		}
	}
	return parsed, nil
}

// extensionPortSeparatorPresent reports whether host contains a port-separator
// ":" outside any IPv6 "[...]" brackets. A bracketed IPv6 literal contains colons
// as part of the address (e.g. "[::1]"); only a ":" after the closing "]" is a
// port separator. A plain (non-bracketed) host has no legal ":" in its name, so
// any ":" is a port separator. This distinguishes a valid bracketed IPv6 origin
// with no port (http://[::1], Host="[::1]") from an explicit empty port
// (http://host:, Host="host:"), both of which yield Port()=="".
func extensionPortSeparatorPresent(host string) bool {
	if close := strings.LastIndex(host, "]"); close >= 0 {
		return len(host) > close+1 && host[close+1] == ':'
	}
	return strings.Contains(host, ":")
}

func newSanitizedReverseProxy(upstream *url.URL) *httputil.ReverseProxy {
	proxy := httputil.NewSingleHostReverseProxy(upstream)
	originalDirector := proxy.Director
	proxy.Director = func(out *http.Request) {
		originalDirector(out)
		out.Host = upstream.Host
		out.Header.Del(api.HeaderRouteWorkspaceID)
		out.Header.Del(router.HeaderResolvedWorkspaceGroupID)
	}
	return proxy
}

func parseWorkspaceUpstreams(raw map[string]string) ([]readinessTarget, error) {
	parsed := make([]readinessTarget, 0, len(raw))
	for groupID, upstream := range raw {
		trimmedGroupID := strings.TrimSpace(groupID)
		if trimmedGroupID == "" {
			return nil, fmt.Errorf("gateway workspace upstream group id is required")
		}
		parsedURL, err := parseRequiredUpstream("workspace group "+trimmedGroupID, upstream)
		if err != nil {
			return nil, err
		}
		parsed = append(parsed, readinessTarget{name: "workspace group " + trimmedGroupID, url: parsedURL})
	}
	return parsed, nil
}

func parseRequiredUpstream(name string, raw string) (*url.URL, error) {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return nil, fmt.Errorf("gateway %s upstream URL is required", name)
	}
	upstream, err := url.Parse(trimmed)
	if err != nil {
		return nil, fmt.Errorf("parse gateway %s upstream URL: %w", name, err)
	}
	if upstream.Scheme == "" || upstream.Host == "" {
		return nil, fmt.Errorf("gateway %s upstream URL must include scheme and host", name)
	}
	return upstream, nil
}

// ServeHTTP classifies each public route and forwards or rejects it.
func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
	if handler == nil || handler.centralProxy == nil || handler.workspace == nil {
		http.Error(writer, "agentteam gateway server is not configured", http.StatusServiceUnavailable)
		return
	}
	path := requestPath(request)
	if path == "/healthz" {
		writer.WriteHeader(http.StatusOK)
		_, _ = writer.Write([]byte("ok\n"))
		return
	}
	if path == "/readyz" {
		handler.serveReadyz(writer, request)
		return
	}
	if handler.serveWorkspaceAGSSeam(writer, request) {
		return
	}
	if !validRequestRawPath(request) {
		http.Error(writer, "invalid request path", http.StatusBadRequest)
		return
	}
	// Extension owns its namespace before general workspace routing so malformed
	// slash aliases keep its fail-closed 404 contract instead of being
	// reclassified as a workspace-identity 400.
	if !ownsExtensionNamespace(path) && !workspaceQualifiedRawSegmentIsValid(request) {
		http.Error(writer, "invalid route workspace identity", http.StatusBadRequest)
		return
	}
	decision := classifyRequest(request)
	switch decision.family {
	case routeCentral:
		handler.centralProxy.ServeHTTP(writer, request)
	case routeWorkspace:
		request = request.Clone(request.Context())
		if decision.agsEdgePolicy != repositoryroute.ScopeUnsupported {
			stripUntrustedAGSIdentityHeaders(request.Header)
		}
		if !prepareWorkspaceIdentityClaims(writer, request) {
			return
		}
		authority, ok := prepareAGSWorkspaceAuthority(writer, request, decision)
		if !ok {
			return
		}
		if decision.legacyDesktopWorkspaceData {
			if !prepareLegacyDesktopWorkspaceRequest(writer, request) {
				return
			}
		} else if decision.legacyRewrite != legacyRewriteNone {
			if !prepareLegacyWorkspaceParameter(writer, request) {
				return
			}
		}
		if decision.resolveIdentity {
			if _, ok := handler.resolveWorkspaceIdentity(writer, request, decision.requireResolver, authority); !ok {
				return
			}
		}
		if !prepareWorkspaceRouteRequest(writer, request, decision) {
			return
		}
		if decision.rewritePath != "" {
			rewriteRequestPath(request, decision.rewritePath)
		} else if decision.legacyRewrite != legacyRewriteNone {
			if !rewriteLegacyWorkspaceRequest(writer, request, decision.legacyRewrite) {
				return
			}
		} else if decision.agsEdgePolicy != repositoryroute.ScopeUnsupported {
			workspaceID := strings.TrimSpace(request.Header.Get(api.HeaderRouteWorkspaceID))
			if err := workspaceurl.Qualify(request, workspaceID); err != nil {
				http.Error(writer, "invalid workspace AGS path", http.StatusBadRequest)
				return
			}
		}
		handler.workspace.ServeHTTP(writer, request)
	case routeInternalOnly:
		http.Error(writer, "route is internal-only and cannot be served by agentteam gateway", http.StatusNotFound)
	case routeExtension:
		// The Extension family forwards the path unchanged to the ProfileExtension
		// upstream (spec §7), which re-authenticates workspace and agent. A Workspace
		// header forwarded by a caller is at most a routing hint, never authorization.
		// When no Extension upstream is configured the route fails closed at the
		// gateway: it is NEVER handed off to the central or workspace family.
		if handler.extensionProxy == nil {
			http.Error(writer, "extension runtime upstream is not configured", http.StatusServiceUnavailable)
			return
		}
		handler.extensionProxy.ServeHTTP(writer, request)
	default:
		http.Error(writer, "unknown gateway route", http.StatusNotFound)
	}
}

func prepareAGSWorkspaceAuthority(writer http.ResponseWriter, request *http.Request, decision routeDecision) (legacyAGSAuthority, bool) {
	switch decision.agsEdgePolicy {
	case repositoryroute.ScopeSessionWorkspace, repositoryroute.ScopeRepositoryCredentialOrSession:
		claim := claimedWorkspaceID(request.Header.Get(api.HeaderRouteWorkspaceID), request.Header.Get(api.HeaderWorkspaceID))
		if strings.TrimSpace(claim) == "" {
			http.Error(writer, "workspace claim is required", http.StatusBadRequest)
			return legacyAGSAuthorityUnspecified, false
		}
		authority := classifyLegacyAGSAuthorization(request.Header.Values("Authorization"))
		switch authority {
		case legacyAGSAuthoritySession:
			return authority, true
		case legacyAGSAuthorityMissing:
			http.Error(writer, "workspace session is required", http.StatusUnauthorized)
			return authority, false
		case legacyAGSAuthorityRepositoryCredential:
			http.Error(writer, "repository credential workspace discovery is unavailable", http.StatusUnprocessableEntity)
			return authority, false
		default:
			http.Error(writer, "invalid authorization", http.StatusBadRequest)
			return authority, false
		}
	case repositoryroute.ScopeGitCredentialOnly, repositoryroute.ScopePublicWorkspaceDiscovery:
		authority := classifyLegacyAGSAuthorization(request.Header.Values("Authorization"))
		if authority == legacyAGSAuthorityMalformed {
			http.Error(writer, "invalid authorization", http.StatusBadRequest)
			return authority, false
		}
		http.Error(writer, "public repository workspace discovery is unavailable", http.StatusUnprocessableEntity)
		return authority, false
	default:
		return legacyAGSAuthorityUnspecified, true
	}
}

func validRequestRawPath(request *http.Request) bool {
	if request == nil || request.URL == nil || request.URL.RawPath == "" {
		return request != nil && request.URL != nil
	}
	decoded, err := url.PathUnescape(request.URL.RawPath)
	return err == nil && decoded == request.URL.Path
}

func (handler *Handler) resolveWorkspaceIdentity(writer http.ResponseWriter, request *http.Request, required bool, authority legacyAGSAuthority) (RouteIdentity, bool) {
	routeWorkspaceID := strings.TrimSpace(request.Header.Get(api.HeaderRouteWorkspaceID))
	currentWorkspaceID := strings.TrimSpace(request.Header.Get(api.HeaderWorkspaceID))
	if !required && (routeWorkspaceID != "" || currentWorkspaceID != "") {
		workspaceID := routeWorkspaceID
		if workspaceID == "" {
			workspaceID = currentWorkspaceID
		}
		return RouteIdentity{WorkspaceID: workspaceID}, true
	}
	if handler.identityResolver == nil {
		http.Error(writer, "workspace identity cannot be resolved", http.StatusBadRequest)
		return RouteIdentity{}, false
	}
	identity, err := handler.identityResolver.ResolveWorkspaceIdentity(request.Context(), WorkspaceIdentityRequest{
		Method: request.Method, Path: request.URL.Path, RawPath: request.URL.RawPath, RawQuery: request.URL.RawQuery,
		WorkspaceQualifiedSessionAuthorization: request.Header.Get("Authorization"), Authority: legacyAGSAuthorityDeclaration(authority), WorkspaceID: claimedWorkspaceID(routeWorkspaceID, currentWorkspaceID),
	})
	if err != nil {
		if errors.Is(err, ErrWorkspaceIdentityUnauthorized) {
			http.Error(writer, "unauthorized", http.StatusUnauthorized)
		} else if errors.Is(err, ErrWorkspaceIdentityUnresolved) {
			http.Error(writer, "workspace identity cannot be resolved", http.StatusBadRequest)
		} else {
			http.Error(writer, "workspace identity resolver unavailable", http.StatusServiceUnavailable)
		}
		return RouteIdentity{}, false
	}
	workspaceID := strings.TrimSpace(identity.WorkspaceID)
	if (routeWorkspaceID != "" && routeWorkspaceID != workspaceID) || (currentWorkspaceID != "" && currentWorkspaceID != workspaceID) {
		http.Error(writer, "conflicting route workspace identity", http.StatusBadRequest)
		return RouteIdentity{}, false
	}
	request.Header.Set(api.HeaderRouteWorkspaceID, workspaceID)
	return RouteIdentity{WorkspaceID: workspaceID}, true
}

func prepareWorkspaceIdentityClaims(writer http.ResponseWriter, request *http.Request) bool {
	for _, header := range []string{api.HeaderRouteWorkspaceID, api.HeaderWorkspaceID} {
		value, ok := parseWorkspaceIdentityHeader(request.Header.Values(header))
		if !ok {
			http.Error(writer, "invalid route workspace identity", http.StatusBadRequest)
			return false
		}
		if value != "" {
			request.Header.Set(header, value)
		}
	}
	routeID := strings.TrimSpace(request.Header.Get(api.HeaderRouteWorkspaceID))
	workspaceID := strings.TrimSpace(request.Header.Get(api.HeaderWorkspaceID))
	if routeID != "" && workspaceID != "" && routeID != workspaceID {
		http.Error(writer, "conflicting route workspace identity", http.StatusBadRequest)
		return false
	}
	return true
}

func parseWorkspaceIdentityHeader(values []string) (string, bool) {
	if len(values) == 0 {
		return "", true
	}
	if len(values) != 1 || values[0] == "" || values[0] != strings.TrimSpace(values[0]) ||
		strings.Contains(values[0], ",") || !api.IsValidRouteWorkspaceID(values[0]) {
		return "", false
	}
	return values[0], true
}

func claimedWorkspaceID(routeWorkspaceID, currentWorkspaceID string) string {
	if strings.TrimSpace(routeWorkspaceID) != "" {
		return routeWorkspaceID
	}
	return currentWorkspaceID
}

func prepareWorkspaceRouteRequest(writer http.ResponseWriter, request *http.Request, decision routeDecision) bool {
	routeWorkspaceID := strings.TrimSpace(request.Header.Get(api.HeaderRouteWorkspaceID))
	currentWorkspaceID := strings.TrimSpace(request.Header.Get(api.HeaderWorkspaceID))
	decisionWorkspaceID := strings.TrimSpace(decision.workspaceID)
	for _, workspaceID := range []string{routeWorkspaceID, currentWorkspaceID, decisionWorkspaceID} {
		if workspaceID != "" && !api.IsValidRouteWorkspaceID(workspaceID) {
			http.Error(writer, "invalid route workspace identity", http.StatusBadRequest)
			return false
		}
	}
	if decisionWorkspaceID != "" {
		if (routeWorkspaceID != "" && routeWorkspaceID != decisionWorkspaceID) ||
			(currentWorkspaceID != "" && currentWorkspaceID != decisionWorkspaceID) {
			http.Error(writer, "conflicting route workspace identity", http.StatusBadRequest)
			return false
		}
		request.Header.Set(api.HeaderRouteWorkspaceID, decisionWorkspaceID)
		return true
	}
	if decision.requireRouteID && routeWorkspaceID == "" {
		http.Error(writer, "X-AgentTeam-Route-Workspace-Id is required", http.StatusBadRequest)
		return false
	}
	if routeWorkspaceID != "" && currentWorkspaceID != "" && routeWorkspaceID != currentWorkspaceID {
		http.Error(writer, "conflicting route workspace identity", http.StatusBadRequest)
		return false
	}
	if routeWorkspaceID == "" && currentWorkspaceID != "" {
		request.Header.Set(api.HeaderRouteWorkspaceID, currentWorkspaceID)
	}
	return true
}

func (handler *Handler) serveReadyz(writer http.ResponseWriter, request *http.Request) {
	for _, target := range handler.readinessTargets {
		if err := handler.checkReadyz(request.Context(), target); err != nil {
			http.Error(writer, err.Error(), http.StatusServiceUnavailable)
			return
		}
	}
	writer.WriteHeader(http.StatusOK)
	_, _ = writer.Write([]byte("ready\n"))
}

func (handler *Handler) checkReadyz(ctx context.Context, target readinessTarget) error {
	readyURL := *target.url
	readyURL.Path = "/readyz"
	readyURL.RawPath = ""
	readyURL.RawQuery = ""
	request, err := http.NewRequestWithContext(ctx, http.MethodGet, readyURL.String(), nil)
	if err != nil {
		// Do not reflect the verbatim error: it may contain the internal URL.
		return fmt.Errorf("gateway readiness check failed for %s: build request", target.name)
	}
	response, err := handler.readinessClient.Do(request)
	if err != nil {
		// Do not wrap the network error: dial errors contain the internal
		// upstream host (spec §7: never expose the upstream URL in errors).
		return fmt.Errorf("gateway readiness check failed for %s: upstream unreachable", target.name)
	}
	defer response.Body.Close()
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		return fmt.Errorf("gateway readiness check failed for %s: status %d", target.name, response.StatusCode)
	}
	return nil
}

type routeFamily int

const (
	routeUnknown routeFamily = iota
	routeCentral
	routeWorkspace
	routeInternalOnly
	routeExtension
)

type routeDecision struct {
	family                     routeFamily
	workspaceID                string
	rewritePath                string
	resolveIdentity            bool
	requireResolver            bool
	requireRouteID             bool
	legacyDesktopWorkspaceData bool
	legacyRewrite              legacyRewrite
	agsEdgePolicy              repositoryroute.ScopeKind
}

type legacyRewrite int

const (
	legacyRewriteNone legacyRewrite = iota
	legacyRewriteDaemon
	legacyRewriteApp
)

func classify(method string, path string) routeDecision {
	request := &http.Request{Method: method, URL: &url.URL{Path: path}}
	return classifyRequest(request)
}

func classifyRequest(request *http.Request) routeDecision {
	if request == nil || request.URL == nil {
		return routeDecision{}
	}
	method := request.Method
	path := requestPath(request)
	if path == "" || path[0] != '/' {
		return routeDecision{}
	}
	if strings.HasPrefix(path, "/internal/admin/") {
		return routeDecision{family: routeInternalOnly}
	}
	// Extension runtime routes are classified BEFORE central/workspace so the
	// generic families cannot steal the /runtime/ext prefix (spec §7 order:
	// internal-only -> Extension -> central -> workspace -> unknown).
	if decision, ok := classifyExtensionRoute(method, path); ok {
		return decision
	}
	scope, activated := activatedAGSScope(request)
	if scope.EdgePolicy != repositoryroute.ScopeUnsupported && !activated {
		return routeDecision{}
	}
	switch scope.EdgePolicy {
	case repositoryroute.ScopeSessionWorkspace,
		repositoryroute.ScopeRepositoryCredentialOrSession,
		repositoryroute.ScopeGitCredentialOnly:
		return routeDecision{family: routeWorkspace, resolveIdentity: true, requireResolver: true, agsEdgePolicy: scope.EdgePolicy}
	case repositoryroute.ScopePublicWorkspaceDiscovery:
		return routeDecision{family: routeWorkspace, agsEdgePolicy: scope.EdgePolicy}
	}
	if workspaceID, rewritePath, ok := legacyWorkspaceMachineRoute(path); ok {
		return routeDecision{family: routeWorkspace, workspaceID: workspaceID, rewritePath: rewritePath}
	}
	if workspaceID, ok := workspaceV1IMRoute(path); ok {
		return routeDecision{family: routeWorkspace, workspaceID: workspaceID}
	}
	if rewrite, ok := legacyRuntimeRoute(path); ok {
		return routeDecision{family: routeWorkspace, resolveIdentity: true, requireResolver: true, legacyRewrite: rewrite}
	}
	if isLegacyDesktopWorkspaceRoute(path) {
		return routeDecision{family: routeWorkspace, legacyDesktopWorkspaceData: true}
	}
	if isCentralRoute(method, path) {
		return routeDecision{family: routeCentral}
	}
	if isWorkspaceRoute(path) {
		return routeDecision{family: routeWorkspace, resolveIdentity: true}
	}
	return routeDecision{}
}

// extensionRouteSegments is the segment count of the Extension
// runtime base path /api/v1/workspaces/{workspaceId}/agents/{agentId}/runtime/ext
// (spec §6.2) when split on '/' WITHOUT trimming: a workspace-qualified absolute request
// path begins with exactly one '/', so strings.Split yields a leading empty
// segment, giving ["", "api", "v1", "workspaces", workspaceId, "agents",
// agentId, "runtime", "ext"].
const extensionRouteSegments = 9

// classifyExtensionRoute recognizes the Extension runtime route family (spec §7)
// under /api/v1/workspaces/{workspaceId}/agents/{agentId}/runtime/ext. ok is true
// for ANY path owned by the Extension namespace, so the namespace can never be
// stolen by the central or workspace family: a valid route+method yields
// routeExtension, while a malformed path (slash alias, empty id), invalid method,
// or unknown descendant fails closed as routeUnknown (no fallback). ok is false
// only when the path is outside the Extension namespace, so classification may
// continue.
//
// Namespace ownership is claimed BEFORE exact validation (spec §7). The central
// family matches "/api/v1/workspaces/" as a prefix and the workspace family's
// workspaceV1IMRoute recognizes "/agents" as a suffix; both normalize slashes.
// Without claiming ownership first, a slash-noise or empty-id variant of an
// Extension path would be stolen by one of them after its own normalization.
// ownsExtensionNamespace therefore matches the namespace on a slash-normalized
// spine so every such variant is owned here and fails closed; only a workspace-qualified,
// exactly-parsed path is then classified as an Extension route.
//
// Valid descendants (spec §6.2):
//
//	GET  /runtime/ext
//	GET  /runtime/ext/{domain}
//	GET  /runtime/ext/{domain}/actions/{action}
//	POST /runtime/ext/{domain}/actions/{action}
//
// Any other descendant (for example .../actions/{action}/bogus) or any method
// outside the closed set above fails closed.
func classifyExtensionRoute(method, path string) (decision routeDecision, extensionPath bool) {
	if !ownsExtensionNamespace(path) {
		return routeDecision{}, false
	}
	// Exact path parsing: NO slash trimming. An absolute request path
	// starts with exactly one '/', so the first split element is empty. Extra
	// slashes (leading "//", trailing "/", or empty id segments "//") break the
	// exact match and fail closed here instead of being aliased to a valid route.
	parts := strings.Split(path, "/")
	// ["", "api", "v1", "workspaces", workspaceId, "agents", agentId, "runtime", "ext", ...]
	if len(parts) < extensionRouteSegments ||
		parts[0] != "" || // exactly one leading slash
		parts[1] != "api" || parts[2] != "v1" || parts[3] != "workspaces" ||
		parts[4] == "" || // workspace id must be present and exact
		parts[5] != "agents" ||
		parts[6] == "" || // agent id must be present and exact
		parts[7] != "runtime" || parts[8] != "ext" {
		// Owned but malformed: fail closed (404). The path is in the Extension
		// namespace, so it must NEVER fall through to central or workspace.
		return routeDecision{}, true
	}
	rest := parts[extensionRouteSegments:]
	switch len(rest) {
	case 0:
		// Root catalog: GET /runtime/ext.
		if method == http.MethodGet {
			return routeDecision{family: routeExtension}, true
		}
	case 1:
		// Domain catalog: GET /runtime/ext/{domain}. rest[0] != "" also rejects a
		// trailing-slash alias (".../ext/").
		if rest[0] != "" && method == http.MethodGet {
			return routeDecision{family: routeExtension}, true
		}
	case 3:
		// Action catalog/invoke: GET|POST /runtime/ext/{domain}/actions/{action}.
		if rest[0] != "" && rest[1] == "actions" && rest[2] != "" &&
			(method == http.MethodGet || method == http.MethodPost) {
			return routeDecision{family: routeExtension}, true
		}
	}
	// Owned by Extension: invalid methods and unknown descendants fail closed
	// here and never fall back to central/workspace (spec §7).
	return routeDecision{}, true
}

// ownsExtensionNamespace reports whether path is within the Extension runtime
// namespace /api/v1/workspaces/{*}/agents/{*}/runtime/ext (spec §7). It claims
// the namespace on a slash-normalized keyword spine so malformed variants
// (leading/trailing/interior slashes, empty workspace or agent ids) are owned by
// the Extension family and fail closed, rather than spilling to a central or
// workspace family that matches its own slash-normalized view of the same path.
//
// The claim is intentionally NARROW (stage-4 round-3): the collapsed path must
// reproduce the workspace-qualified keyword spine api/v1/workspaces/{ws}/agents/{agent}
// /runtime/ext as a PREFIX, where the {ws} and {agent} id slots may be empty
// (collapsed away). The "runtime" keyword is load-bearing: collapseSlashes can
// only remove EMPTY segments (slash noise), never a literal keyword, so a path
// that lacks "runtime" before "ext" (e.g. /workspaces/ws/agents/agent/ext or
// /workspaces/ws/agents/ext) is genuinely a workspace agents route and must NOT
// be claimed; likewise a path that lacks "agents" before "runtime" (e.g.
// /workspaces/ws/runtime/foo/ext) is not the Extension spine. The prior
// "agents...ext" fallback that claimed any /agents/.../ext path is removed
// entirely because it stole workspace routes. Any descendant AFTER runtime/ext
// is still claimed so unknown descendants fail closed (404) instead of spilling;
// route validity is decided separately by the exact raw-path parse in
// classifyExtensionRoute. ownsExtensionNamespace only claims ownership.
func ownsExtensionNamespace(path string) bool {
	// collapseSlashes removes every empty segment (slash noise), so an empty ws
	// or agent id slot collapses away and the surrounding keywords become
	// adjacent. parts therefore has no "" entries (an empty collapsed path
	// yields a single "").
	parts := strings.Split(collapseSlashes(path), "/")
	if len(parts) < 3 || parts[0] != "api" || parts[1] != "v1" || parts[2] != "workspaces" {
		return false
	}
	// After "workspaces" (index 2) comes the single-segment workspace id slot,
	// then the "agents" keyword. When the ws id is empty it collapses away, so
	// "agents" sits at index 3 (empty ws) or 4 (present ws). Any other position
	// means an extra segment breaks the workspace-qualified spine -> not owned.
	agentsIdx := -1
	switch {
	case len(parts) > 3 && parts[3] == "agents":
		agentsIdx = 3
	case len(parts) > 4 && parts[4] == "agents":
		agentsIdx = 4
	}
	if agentsIdx == -1 {
		return false
	}
	// After "agents" comes the single-segment agent id slot, then "runtime".
	// Same collapse rule: "runtime" sits at agentsIdx+1 (empty agent) or
	// agentsIdx+2 (present agent). The "runtime" keyword MUST be present —
	// collapseSlashes cannot remove it, so its absence means this is not an
	// Extension runtime path.
	runtimeIdx := -1
	switch {
	case len(parts) > agentsIdx+1 && parts[agentsIdx+1] == "runtime":
		runtimeIdx = agentsIdx + 1
	case len(parts) > agentsIdx+2 && parts[agentsIdx+2] == "runtime":
		runtimeIdx = agentsIdx + 2
	}
	if runtimeIdx == -1 {
		return false
	}
	// "ext" must immediately follow "runtime". Any descendants after ext are
	// still claimed (they fail closed in classifyExtensionRoute); the spine
	// itself ends at the runtime/ext prefix.
	return len(parts) > runtimeIdx+1 && parts[runtimeIdx+1] == "ext"
}

// collapseSlashes collapses every run of consecutive '/' in path into a single
// '/' and trims leading and trailing '/'. It is used ONLY for the
// Extension-namespace ownership claim (ownsExtensionNamespace) so interior slash
// noise such as "/a//b" is normalized to the workspace-qualified spine "a/b" and the path
// is claimed by the Extension family (fail-closed) instead of spilling to a
// central or workspace family that applies its own normalization. It is NOT the
// validity check: route validity is decided by exact raw-path parsing in
// classifyExtensionRoute, which performs no trimming or collapsing.
func collapseSlashes(path string) string {
	for strings.Contains(path, "//") {
		path = strings.ReplaceAll(path, "//", "/")
	}
	return strings.Trim(path, "/")
}

func isCentralRoute(method string, path string) bool {
	return (method == http.MethodPost && path == "/api/v1/machine-auth-resolutions") || path == "/api" || hasAnyPathPrefix(path,
		"/api/avatars/", // Deprecated: historical avatar object URL compatibility.
		"/api/v1/auth/",
		"/api/v1/public/",
		"/api/v1/me/",
		"/api/v1/machine-connect-exchanges",
		"/api/v1/workspaces/",
		"/api/v1/workspaces",
		"/api/v1/orgs/",
		"/api/v1/admin/",
		"/api/v1/billing/",
		"/api/v1/users/",
	) || path == "/api/github/callback" || path == "/api/github/setup"
}

func isRepositoryRawRoute(method string, path string) bool {
	return method == http.MethodGet && isRepositoryRawPath(path)
}

func isRepositoryRawPath(path string) bool {
	parts := strings.Split(strings.Trim(path, "/"), "/")
	return len(parts) >= 4 && parts[2] == "raw"
}

func isWorkspaceRoute(path string) bool {
	return hasAnyPathPrefix(path,
		"/api/agents",
		"/api/llm",
		"/api/v1/agents",
		"/api/v1/agents-for-current-member",
		"/api/v1/review-context",
	) || isWorkspaceAppWebhookRoute(path)
}

func isWorkspaceAppWebhookRoute(path string) bool {
	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 legacyRuntimeRoute(path string) (legacyRewrite, bool) {
	switch {
	case pathMatchesPrefix(path, "/daemon"):
		return legacyRewriteDaemon, true
	case pathMatchesPrefix(path, "/internal/app/runtime"):
		return legacyRewriteApp, true
	default:
		return legacyRewriteNone, false
	}
}

func prepareLegacyWorkspaceParameter(writer http.ResponseWriter, request *http.Request) bool {
	if request == nil || request.URL == nil {
		return true
	}
	workspaceID, ok := uniqueLegacyWorkspaceParameter(request.URL.Query())
	if !ok {
		http.Error(writer, "conflicting route workspace identity", http.StatusBadRequest)
		return false
	}
	if workspaceID == "" {
		return true
	}
	if strings.Contains(workspaceID, "/") {
		http.Error(writer, "invalid route workspace identity", http.StatusBadRequest)
		return false
	}
	for _, header := range []string{api.HeaderRouteWorkspaceID, api.HeaderWorkspaceID} {
		if current := strings.TrimSpace(request.Header.Get(header)); current != "" && current != workspaceID {
			http.Error(writer, "conflicting route workspace identity", http.StatusBadRequest)
			return false
		}
	}
	request.Header.Set(api.HeaderRouteWorkspaceID, workspaceID)
	return true
}

func uniqueLegacyWorkspaceParameter(query url.Values) (string, bool) {
	workspaceID := ""
	for _, key := range []string{"workspaceId", "workspace_id"} {
		for _, value := range query[key] {
			value = strings.TrimSpace(value)
			if value == "" {
				continue
			}
			if workspaceID != "" && workspaceID != value {
				return "", false
			}
			workspaceID = value
		}
	}
	return workspaceID, true
}

func rewriteLegacyWorkspaceRequest(writer http.ResponseWriter, request *http.Request, rewrite legacyRewrite) bool {
	workspaceID := strings.TrimSpace(request.Header.Get(api.HeaderRouteWorkspaceID))
	if workspaceID == "" || strings.Contains(workspaceID, "/") {
		http.Error(writer, "workspace identity cannot be resolved", http.StatusBadRequest)
		return false
	}
	var oldPrefix string
	var workspaceQualifiedPrefix string
	switch rewrite {
	case legacyRewriteDaemon:
		oldPrefix = "/daemon"
		workspaceQualifiedPrefix = "/api/v1/workspaces/" + workspaceID + "/daemon"
	case legacyRewriteApp:
		oldPrefix = "/internal/app/runtime"
		workspaceQualifiedPrefix = "/api/v1/workspaces/" + workspaceID + "/app-runtime"
	default:
		return true
	}
	return rewriteRequestPathPrefix(request, oldPrefix, workspaceQualifiedPrefix)
}

func legacyWorkspaceMachineRoute(path string) (string, string, bool) {
	parts := strings.Split(strings.Trim(path, "/"), "/")
	if len(parts) < 4 || parts[0] != "api" || parts[1] != "workspaces" || parts[3] != "machines" {
		return "", "", false
	}
	workspaceID := strings.TrimSpace(parts[2])
	if workspaceID == "" {
		return "", "", false
	}

	suffix := append([]string(nil), parts[3:]...)
	if len(suffix) >= 3 {
		switch suffix[2] {
		case "rotate-key":
			suffix[2] = "api-key-rotations"
		case "connect-token":
			suffix[2] = "connect-tokens"
		case "evict":
			suffix[2] = "evictions"
		case "workspaces":
			suffix[2] = "workspace-directories"
		}
	}

	rewriteParts := append([]string{"api", "v1", "workspaces", workspaceID}, suffix...)
	return workspaceID, "/" + strings.Join(rewriteParts, "/"), true
}

func hasAnyPathPrefix(path string, prefixes ...string) bool {
	for _, prefix := range prefixes {
		if pathMatchesPrefix(path, prefix) {
			return true
		}
	}
	return false
}

func pathMatchesPrefix(path string, prefix string) bool {
	if strings.HasSuffix(prefix, "/") {
		return path == strings.TrimSuffix(prefix, "/") || strings.HasPrefix(path, prefix)
	}
	return path == prefix || strings.HasPrefix(path, prefix+"/")
}

type activePlacementResolver struct {
	inner Resolver
}

func (resolver activePlacementResolver) ResolveWorkspaceRouteTarget(ctx context.Context, workspaceID string) (workspace.WorkspaceRouteTarget, error) {
	if resolver.inner == nil {
		return workspace.WorkspaceRouteTarget{}, fmt.Errorf("gateway placement resolver is required")
	}
	target, err := resolver.inner.ResolveWorkspaceRouteTarget(ctx, workspaceID)
	if err != nil {
		return workspace.WorkspaceRouteTarget{}, err
	}
	if target.PlacementState != "" && target.PlacementState != workspace.PlacementStateActive {
		return workspace.WorkspaceRouteTarget{}, storeerr.Conflict(fmt.Sprintf("workspace %s placement state %s cannot receive gateway traffic", target.WorkspaceID, target.PlacementState))
	}
	return target, nil
}

func requestPath(request *http.Request) string {
	if request == nil || request.URL == nil {
		return ""
	}
	return request.URL.Path
}

func rewriteRequestPath(request *http.Request, path string) {
	if request == nil || request.URL == nil {
		return
	}
	request.URL.Path = path
	request.URL.RawPath = ""
}
