package gateway

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"reflect"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/pingcap-inc/nexuscrm-api-host/processhttp"
)

func TestGatewayConfigHasNoOutboundTransportEscapeHatch(t *testing.T) {
	if _, exists := reflect.TypeOf(Config{}).FieldByName("Transport"); exists {
		t.Fatal("Gateway Config exposes a caller-owned outbound transport authority")
	}
}

func TestGatewayRoutesExactPathWithTrustedEdgeHeaders(t *testing.T) {
	var upstreamCalls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamCalls.Add(1)
		body, _ := io.ReadAll(r.Body)
		if r.URL.Path != "/api/v1/items/access" || r.URL.RawQuery != "mode=typed" || string(body) != `{"ids":["1"]}` {
			t.Errorf("upstream request = %s?%s %q", r.URL.Path, r.URL.RawQuery, body)
		}
		if got := r.Header.Get(RequestIDHeader); got != "request-42" {
			t.Errorf("request id = %q", got)
		}
		if got := r.Header.Get("X-Forwarded-Host"); got == "spoofed.example" || got == "" {
			t.Errorf("forwarded host = %q", got)
		}
		if got := r.Header.Get("X-Forwarded-Proto"); got != "http" {
			t.Errorf("forwarded proto = %q", got)
		}
		for _, header := range []string{"X-Real-IP", "True-Client-IP", "CF-Connecting-IP", "Fastly-Client-IP", "X-Client-IP", "X-Cluster-Client-IP", "X-Original-Forwarded-For", "X-Forwarded-Spoof", "Via"} {
			if got := r.Header.Get(header); got != "" {
				t.Errorf("untrusted proxy identity %s = %q", header, got)
			}
		}
		if got := r.Header.Get("Authorization"); got != "Bearer browser-session" {
			t.Errorf("authorization = %q", got)
		}
		if r.Host != "gateway.test" {
			t.Errorf("public Host = %q", r.Host)
		}
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set(RequestIDHeader, "upstream-spoof")
		w.Header().Set("Content-Security-Policy", "default-src *")
		w.Header().Set("X-Frame-Options", "ALLOWALL")
		w.Header().Set("Alt-Svc", `h3=":443"`)
		_, _ = io.WriteString(w, `{"allowed":true}`)
	}))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 4, 5*time.Second, 1024)
	request := httptest.NewRequest(http.MethodPost, "http://gateway.test/api/v1/items/access?mode=typed", strings.NewReader(`{"ids":["1"]}`))
	request.Header.Set(RequestIDHeader, "request-42")
	request.Header.Set("X-Forwarded-Host", "spoofed.example")
	for _, header := range []string{"X-Real-IP", "True-Client-IP", "CF-Connecting-IP", "Fastly-Client-IP", "X-Client-IP", "X-Cluster-Client-IP", "X-Original-Forwarded-For", "X-Forwarded-Spoof", "Via"} {
		request.Header.Set(header, "spoofed.example")
	}
	request.Header.Set("Authorization", "Bearer browser-session")
	response := httptest.NewRecorder()
	gateway.ServeHTTP(response, request)
	if response.Code != http.StatusOK || strings.TrimSpace(response.Body.String()) != `{"allowed":true}` {
		t.Fatalf("response = %d %q", response.Code, response.Body.String())
	}
	if response.Header().Get(RequestIDHeader) != "request-42" {
		t.Fatalf("response request id = %q", response.Header().Get(RequestIDHeader))
	}
	if response.Header().Get("X-Content-Type-Options") != "nosniff" {
		t.Fatal("missing edge security headers")
	}
	if response.Header().Get("Content-Security-Policy") != "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" ||
		response.Header().Get("X-Frame-Options") != "DENY" || response.Header().Get("Alt-Svc") != "" {
		t.Fatalf("upstream overrode edge policy: %v", response.Header())
	}
	if upstreamCalls.Load() != 1 {
		t.Fatalf("upstream calls = %d", upstreamCalls.Load())
	}
}

func TestGatewayV2RoutesExactlyOneOpaqueTerminalSegment(t *testing.T) {
	var upstreamCalls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamCalls.Add(1)
		if r.URL.Path != "/services/data/v63.0/query/opaque_locator=" || r.URL.RawQuery != "trace=1" {
			t.Errorf("upstream request = %s?%s", r.URL.Path, r.URL.RawQuery)
		}
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()
	raw := configJSON(RouteConfigAPIVersionV2, "test-r2", `{"name":"query-more","path":"/services/data/v63.0/query/:locator","methods":["GET"],"upstream":"`+upstream.URL+`","timeoutMillis":1000,"maxRequestBytes":1024}`)
	config, err := ParseRouteConfig([]byte(raw))
	if err != nil {
		t.Fatal(err)
	}
	gateway, err := New(Config{Version: "test", MaxInFlight: 1, Routes: config})
	if err != nil {
		t.Fatal(err)
	}

	response := httptest.NewRecorder()
	gateway.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/services/data/v63.0/query/opaque_locator=?trace=1", nil))
	if response.Code != http.StatusNoContent || upstreamCalls.Load() != 1 {
		t.Fatalf("valid terminal segment status=%d calls=%d body=%s", response.Code, upstreamCalls.Load(), response.Body.String())
	}

	for _, request := range []*http.Request{
		httptest.NewRequest(http.MethodGet, "/services/data/v63.0/query/", nil),
		httptest.NewRequest(http.MethodGet, "/services/data/v63.0/query/a/b", nil),
		httptest.NewRequest(http.MethodGet, "/services/data/v63.0/query/.", nil),
		httptest.NewRequest(http.MethodGet, "/services/data/v63.0/query/..", nil),
		httptest.NewRequest(http.MethodGet, "/services/data/v63.0/query/a%2Fb", nil),
	} {
		rejected := httptest.NewRecorder()
		gateway.ServeHTTP(rejected, request)
		if rejected.Code != http.StatusNotFound {
			t.Fatalf("path %q status=%d body=%s", request.URL.String(), rejected.Code, rejected.Body.String())
		}
	}
	wrongMethod := httptest.NewRecorder()
	gateway.ServeHTTP(wrongMethod, httptest.NewRequest(http.MethodPost, "/services/data/v63.0/query/opaque_locator=", nil))
	if wrongMethod.Code != http.StatusMethodNotAllowed || upstreamCalls.Load() != 1 {
		t.Fatalf("wrong method status=%d calls=%d body=%s", wrongMethod.Code, upstreamCalls.Load(), wrongMethod.Body.String())
	}

	capabilities := httptest.NewRecorder()
	gateway.ServeHTTP(capabilities, httptest.NewRequest(http.MethodGet, processhttp.CapabilitiesPath, nil))
	if capabilities.Code != http.StatusOK || !strings.Contains(capabilities.Body.String(), "gateway.static-routing.v2") {
		t.Fatalf("capabilities = %d %s", capabilities.Code, capabilities.Body.String())
	}
}

func TestGatewayV3RoutesMultipleOpaqueSegments(t *testing.T) {
	var upstreamCalls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamCalls.Add(1)
		if r.URL.Path != "/services/data/v63.0/sobjects/Account/001xx" {
			t.Errorf("upstream request path = %q", r.URL.Path)
		}
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()
	raw := configJSON(RouteConfigAPIVersionV3, "test-r3", `{"name":"record-write","path":"/services/data/v63.0/sobjects/:object/:id","methods":["PATCH"],"upstream":"`+upstream.URL+`","timeoutMillis":1000,"maxRequestBytes":1024}`)
	config, err := ParseRouteConfig([]byte(raw))
	if err != nil {
		t.Fatal(err)
	}
	gateway, err := New(Config{Version: "test", MaxInFlight: 1, Routes: config})
	if err != nil {
		t.Fatal(err)
	}

	response := httptest.NewRecorder()
	gateway.ServeHTTP(response, httptest.NewRequest(http.MethodPatch, "/services/data/v63.0/sobjects/Account/001xx", nil))
	if response.Code != http.StatusNoContent || upstreamCalls.Load() != 1 {
		t.Fatalf("valid multi-segment route status=%d calls=%d body=%s", response.Code, upstreamCalls.Load(), response.Body.String())
	}
	for _, path := range []string{
		"/services/data/v63.0/sobjects/Account",
		"/services/data/v63.0/sobjects/Account/001xx/extra",
		"/services/data/v63.0/sobjects/Account/.",
		"/services/data/v63.0/sobjects/Account/a%2Fb",
	} {
		rejected := httptest.NewRecorder()
		gateway.ServeHTTP(rejected, httptest.NewRequest(http.MethodPatch, path, nil))
		if rejected.Code != http.StatusNotFound {
			t.Fatalf("path %q status=%d body=%s", path, rejected.Code, rejected.Body.String())
		}
	}
	capabilities := httptest.NewRecorder()
	gateway.ServeHTTP(capabilities, httptest.NewRequest(http.MethodGet, processhttp.CapabilitiesPath, nil))
	if capabilities.Code != http.StatusOK || !strings.Contains(capabilities.Body.String(), "gateway.static-routing.v3") {
		t.Fatalf("capabilities = %d %s", capabilities.Code, capabilities.Body.String())
	}
}

func TestGatewayV3MatchingHasOneRequestScopedAllocation(t *testing.T) {
	var routes strings.Builder
	for index := int64(0); index < 128; index++ {
		if index > 0 {
			routes.WriteByte(',')
		}
		value := intString(index)
		routes.WriteString(`{"name":"route-` + value +
			`","path":"/api/` + value +
			`/:object/:id","methods":["GET"],"upstream":"https://app.example.test","timeoutMillis":1000,"maxRequestBytes":1024}`)
	}
	raw := `{"apiVersion":"gateway.nexuscrm.io/v3","revision":"allocation-proof","routes":[` +
		routes.String() + `]}`
	config, err := ParseRouteConfig([]byte(raw))
	if err != nil {
		t.Fatal(err)
	}
	gateway, err := New(Config{Version: "test", MaxInFlight: 1, Routes: config})
	if err != nil {
		t.Fatal(err)
	}

	// All 128 candidates have this segment count, but the request path is split
	// once before scanning the precompiled bucket—not once per candidate.
	if allocations := testing.AllocsPerRun(100, func() {
		_ = gateway.matchRoute("/api/missing/Account/001xx")
	}); allocations > 1 {
		t.Fatalf("same-shape route matching allocations = %.0f, want at most 1", allocations)
	}

	// A very long, impossible shape is rejected by its zero-allocation segment
	// count lookup before strings.Split and before request admission.
	impossible := "/" + strings.Repeat("x/", 32768) + "x"
	if allocations := testing.AllocsPerRun(100, func() {
		_ = gateway.matchRoute(impossible)
	}); allocations != 0 {
		t.Fatalf("impossible-shape route matching allocations = %.0f, want 0", allocations)
	}
}

func TestGatewayRejectsUpgradeBeforeUpstreamOrAdmission(t *testing.T) {
	var upstreamCalls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { upstreamCalls.Add(1) }))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, time.Second, 1024)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil)
	request.Header.Set("Connection", "keep-alive, Upgrade")
	request.Header.Set("Upgrade", "websocket")
	response := httptest.NewRecorder()
	gateway.ServeHTTP(response, request)
	if response.Code != http.StatusBadRequest || upstreamCalls.Load() != 0 {
		t.Fatalf("upgrade response = %d calls=%d", response.Code, upstreamCalls.Load())
	}
}

func TestGatewayOwnsAnImmutableCopyOfParsedRoutes(t *testing.T) {
	var calls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		calls.Add(1)
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()
	raw := configJSON(RouteConfigAPIVersion, "test-r1", `{"name":"bounded-read","path":"/api/v1/items/access","methods":["POST"],"upstream":"`+upstream.URL+`","timeoutMillis":1000,"maxRequestBytes":1024}`)
	config, err := ParseRouteConfig([]byte(raw))
	if err != nil {
		t.Fatal(err)
	}
	gateway, err := New(Config{Version: "test", MaxInFlight: 1, Routes: config})
	if err != nil {
		t.Fatal(err)
	}
	config.Routes[0].Methods[0] = http.MethodDelete
	config.Routes[0].Upstream = "https://changed.example.test"
	config.Revision = "changed"
	response := httptest.NewRecorder()
	gateway.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil))
	if response.Code != http.StatusNoContent || calls.Load() != 1 {
		t.Fatalf("mutated caller config affected gateway: status=%d calls=%d", response.Code, calls.Load())
	}
}

func TestGatewayRejectsAStaticGenerationWithUnboundedAggregateBuffering(t *testing.T) {
	raw := configJSON(RouteConfigAPIVersion, "test-r1", `{"name":"bounded-read","path":"/api/v1/items/access","methods":["POST"],"upstream":"https://app.example.test","timeoutMillis":1000,"maxRequestBytes":67108864}`)
	config, err := ParseRouteConfig([]byte(raw))
	if err != nil {
		t.Fatal(err)
	}
	if _, err := New(Config{Version: "test", MaxInFlight: 2, Routes: config}); err == nil || !strings.Contains(err.Error(), "buffered request budget") {
		t.Fatalf("aggregate buffer error = %v", err)
	}
	if _, err := New(Config{Version: "test", MaxInFlight: 1, Routes: config}); err != nil {
		t.Fatalf("one bounded request should fit the process budget: %v", err)
	}
}

func TestGatewayHasNoUnknownRouteOrMethodFallback(t *testing.T) {
	var upstreamCalls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { upstreamCalls.Add(1) }))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, time.Second, 1024)
	tests := []struct {
		method string
		path   string
		status int
	}{
		{method: http.MethodPost, path: "/legacy/api", status: http.StatusNotFound},
		{method: http.MethodGet, path: "/api/v1/items/access", status: http.StatusMethodNotAllowed},
		{method: http.MethodPost, path: "/api/v1/items/access/", status: http.StatusNotFound},
	}
	for _, test := range tests {
		response := httptest.NewRecorder()
		gateway.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
		if response.Code != test.status {
			t.Fatalf("%s %s = %d, want %d", test.method, test.path, response.Code, test.status)
		}
	}
	if upstreamCalls.Load() != 0 {
		t.Fatalf("unknown route reached upstream %d times", upstreamCalls.Load())
	}
}

func TestGatewayRejectsEscapedPathAliasesBeforeDispatch(t *testing.T) {
	var upstreamCalls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { upstreamCalls.Add(1) }))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, time.Second, 1024)
	for _, path := range []string{
		"/api%2Fv1/items/access",
		"/api/v1/items%2Faccess",
		"/%6civez",
	} {
		request := httptest.NewRequest(http.MethodPost, path, nil)
		response := httptest.NewRecorder()
		gateway.ServeHTTP(response, request)
		if response.Code != http.StatusNotFound {
			t.Fatalf("escaped path %q status = %d body=%s", path, response.Code, response.Body.String())
		}
	}
	if upstreamCalls.Load() != 0 {
		t.Fatalf("escaped path reached upstream %d times", upstreamCalls.Load())
	}
}

func TestGatewayDefaultTransportHasNoEnvironmentProxyAuthority(t *testing.T) {
	t.Setenv("HTTP_PROXY", "http://proxy.invalid:8080")
	t.Setenv("HTTPS_PROXY", "http://proxy.invalid:8080")
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, time.Second, 1024)
	transport, ok := gateway.compiled["/api/v1/items/access"].proxy.Transport.(*http.Transport)
	if !ok {
		t.Fatalf("default transport type = %T", gateway.compiled["/api/v1/items/access"].proxy.Transport)
	}
	if transport.Proxy != nil {
		t.Fatal("default transport retained an environment proxy authority")
	}
	response := httptest.NewRecorder()
	gateway.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil))
	if response.Code != http.StatusNoContent {
		t.Fatalf("direct upstream response = %d %s", response.Code, response.Body.String())
	}
}

func TestGatewayRejectsBodiesOverTheConfiguredEnvelope(t *testing.T) {
	var upstreamCalls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { upstreamCalls.Add(1) }))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, time.Second, 4)
	for _, contentLength := range []int64{5, -1} {
		request := httptest.NewRequest(http.MethodPost, "/api/v1/items/access", strings.NewReader("12345"))
		request.ContentLength = contentLength
		response := httptest.NewRecorder()
		gateway.ServeHTTP(response, request)
		if response.Code != http.StatusRequestEntityTooLarge {
			t.Fatalf("content length %d status = %d body=%s", contentLength, response.Code, response.Body.String())
		}
	}
	if upstreamCalls.Load() != 0 {
		t.Fatalf("oversized body reached upstream %d times", upstreamCalls.Load())
	}
}

func TestGatewayDeadlineCancelsUpstream(t *testing.T) {
	canceled := make(chan struct{})
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		<-r.Context().Done()
		close(canceled)
	}))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, 20*time.Millisecond, 1024)
	response := httptest.NewRecorder()
	gateway.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil))
	if response.Code != http.StatusGatewayTimeout {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	select {
	case <-canceled:
	case <-time.After(time.Second):
		t.Fatal("deadline did not cancel upstream request")
	}
}

func TestGatewayDeadlineBoundsSlowRequestBodyBeforeUpstream(t *testing.T) {
	var upstreamCalls atomic.Int64
	upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { upstreamCalls.Add(1) }))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, 20*time.Millisecond, 1024)
	body := newBlockingBody()
	request := httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil)
	request.Body = body
	request.ContentLength = -1
	response := httptest.NewRecorder()
	started := time.Now()
	gateway.ServeHTTP(response, request)
	if response.Code != http.StatusGatewayTimeout {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	if time.Since(started) > 500*time.Millisecond {
		t.Fatalf("slow body exceeded route deadline: %v", time.Since(started))
	}
	if upstreamCalls.Load() != 0 {
		t.Fatalf("slow body reached upstream %d times", upstreamCalls.Load())
	}
}

func TestGatewayAdmissionAndDrainFailClosed(t *testing.T) {
	entered := make(chan struct{})
	release := make(chan struct{})
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		close(entered)
		<-release
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, time.Second, 1024)
	firstDone := make(chan int, 1)
	go func() {
		response := httptest.NewRecorder()
		gateway.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil))
		firstDone <- response.Code
	}()
	<-entered
	second := httptest.NewRecorder()
	gateway.ServeHTTP(second, httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil))
	if second.Code != http.StatusServiceUnavailable || !strings.Contains(second.Body.String(), "overloaded") {
		t.Fatalf("overload response = %d %s", second.Code, second.Body.String())
	}
	close(release)
	if status := <-firstDone; status != http.StatusNoContent {
		t.Fatalf("first status = %d", status)
	}
	gateway.Drain()
	if gateway.Ready() {
		t.Fatal("draining gateway remained ready")
	}
	draining := httptest.NewRecorder()
	gateway.ServeHTTP(draining, httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil))
	if draining.Code != http.StatusServiceUnavailable || !strings.Contains(draining.Body.String(), "draining") {
		t.Fatalf("drain response = %d %s", draining.Code, draining.Body.String())
	}
	ready := httptest.NewRecorder()
	gateway.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, processhttp.ReadyPath, nil))
	if ready.Code != http.StatusServiceUnavailable {
		t.Fatalf("ready status = %d", ready.Code)
	}
	live := httptest.NewRecorder()
	gateway.ServeHTTP(live, httptest.NewRequest(http.MethodGet, processhttp.LivePath, nil))
	if live.Code != http.StatusOK {
		t.Fatalf("live status while draining = %d", live.Code)
	}
}

func TestGatewayMetricsAndGeneratedCorrelation(t *testing.T) {
	upstreamRequestID := make(chan string, 1)
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		upstreamRequestID <- r.Header.Get(RequestIDHeader)
		w.WriteHeader(http.StatusNoContent)
	}))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, time.Second, 1024)
	response := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil)
	request.Header.Set(RequestIDHeader, strings.Repeat("x", 129))
	gateway.ServeHTTP(response, request)
	generated := <-upstreamRequestID
	if len(generated) != 32 || response.Header().Get(RequestIDHeader) != generated {
		t.Fatalf("generated request id upstream=%q response=%q", generated, response.Header().Get(RequestIDHeader))
	}
	metrics := httptest.NewRecorder()
	gateway.ServeHTTP(metrics, httptest.NewRequest(http.MethodGet, MetricsPath, nil))
	for _, fact := range []string{
		"nexuscrm_gateway_requests_accepted_total 1",
		"nexuscrm_gateway_requests_in_flight 0",
		`revision="test-r1"`,
		`digest="sha256:`,
	} {
		if !strings.Contains(metrics.Body.String(), fact) {
			t.Fatalf("metrics missing %q:\n%s", fact, metrics.Body.String())
		}
	}
	capabilities := httptest.NewRecorder()
	gateway.ServeHTTP(capabilities, httptest.NewRequest(http.MethodGet, processhttp.CapabilitiesPath, nil))
	if capabilities.Code != http.StatusOK || !strings.Contains(capabilities.Body.String(), "gateway.static-routing.v1") {
		t.Fatalf("capabilities = %d %s", capabilities.Code, capabilities.Body.String())
	}
	version := httptest.NewRecorder()
	gateway.ServeHTTP(version, httptest.NewRequest(http.MethodGet, processhttp.VersionPath, nil))
	if version.Code != http.StatusOK || !strings.Contains(version.Body.String(), `"role":"gateway"`) || !strings.Contains(version.Body.String(), `"version":"test"`) {
		t.Fatalf("version = %d %s", version.Code, version.Body.String())
	}
}

func TestGatewayPropagatesCallerCancellation(t *testing.T) {
	upstreamEntered := make(chan struct{})
	upstreamCanceled := make(chan struct{})
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(upstreamEntered)
		<-r.Context().Done()
		close(upstreamCanceled)
	}))
	defer upstream.Close()
	gateway := newTestGateway(t, upstream.URL, 1, time.Second, 1024)
	ctx, cancel := context.WithCancel(context.Background())
	request := httptest.NewRequest(http.MethodPost, "/api/v1/items/access", nil).WithContext(ctx)
	done := make(chan struct{})
	go func() {
		gateway.ServeHTTP(httptest.NewRecorder(), request)
		close(done)
	}()
	select {
	case <-upstreamEntered:
	case <-time.After(time.Second):
		t.Fatal("request did not reach upstream")
	}
	cancel()
	select {
	case <-upstreamCanceled:
	case <-time.After(time.Second):
		t.Fatal("caller cancellation did not reach upstream")
	}
	<-done
}

func newTestGateway(t *testing.T, upstream string, maxInFlight int, timeout time.Duration, maxBytes int64) *Gateway {
	t.Helper()
	raw := configJSON(RouteConfigAPIVersion, "test-r1", `{"name":"bounded-read","path":"/api/v1/items/access","methods":["POST"],"upstream":"`+upstream+`","timeoutMillis":`+durationMillis(timeout)+`,"maxRequestBytes":`+intString(maxBytes)+`}`)
	config, err := ParseRouteConfig([]byte(raw))
	if err != nil {
		t.Fatal(err)
	}
	gateway, err := New(Config{Version: "test", MaxInFlight: maxInFlight, Routes: config})
	if err != nil {
		t.Fatal(err)
	}
	return gateway
}

func durationMillis(value time.Duration) string { return intString(value.Milliseconds()) }

func intString(value int64) string {
	const digits = "0123456789"
	if value == 0 {
		return "0"
	}
	var buffer [20]byte
	position := len(buffer)
	for value > 0 {
		position--
		buffer[position] = digits[value%10]
		value /= 10
	}
	return string(buffer[position:])
}

type blockingBody struct {
	once   sync.Once
	closed chan struct{}
}

func newBlockingBody() *blockingBody { return &blockingBody{closed: make(chan struct{})} }

func (b *blockingBody) Read([]byte) (int, error) {
	<-b.closed
	return 0, io.ErrClosedPipe
}

func (b *blockingBody) Close() error {
	b.once.Do(func() { close(b.closed) })
	return nil
}
