package wire

import (
	"context"
	"crypto/tls"
	"errors"
	"io"
	"net"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/profile"
	"github.com/tidbcloud/AgentTeam/desktop/internal/wire/apiclient"
)

func TestRefreshUserTokenCoalescesConcurrentStaleTokenRecovery(t *testing.T) {
	var exchanges atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		require.Equal(t, "/api/v1/workspaces/wsp_1/daemon/session-token", request.URL.Path)
		exchanges.Add(1)
		writer.Header().Set("Content-Type", "application/json")
		_, _ = writer.Write([]byte(`{"token":"fresh-token"}`))
	}))
	defer server.Close()

	connection := &Protocol{
		serverURL:   server.URL,
		apiKey:      "machine-key",
		workspaceID: "wsp_1",
		httpClient:  server.Client(),
		channelUserTokenState: channelUserTokenState{
			userToken:         "stale-token",
			userTokenIssuedAt: time.Now(),
		},
	}

	const callers = 32
	var wg sync.WaitGroup
	errs := make(chan error, callers)
	tokens := make(chan string, callers)
	for range callers {
		wg.Add(1)
		go func() {
			defer wg.Done()
			token, err := connection.RefreshUserToken(context.Background(), "stale-token")
			errs <- err
			tokens <- token
		}()
	}
	wg.Wait()
	close(errs)
	close(tokens)

	for err := range errs {
		require.NoError(t, err)
	}
	for token := range tokens {
		require.Equal(t, "fresh-token", token)
	}
	require.Equal(t, int32(1), exchanges.Load())
}

func TestUserTokenRecoversAfterTransientNotFoundWithoutReconnect(t *testing.T) {
	var exchanges atomic.Int32
	base := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
	var nowNanos atomic.Int64
	nowNanos.Store(base.UnixNano())
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		call := exchanges.Add(1)
		if call <= sessionTokenMaxFailures {
			// Simulate an exchange taking longer than the retry floor. Backoff must
			// begin when the failed request completes, not when it started.
			nowNanos.Add(int64(2 * time.Minute))
			http.NotFound(writer, request)
			return
		}
		writer.Header().Set("Content-Type", "application/json")
		_, _ = writer.Write([]byte(`{"token":"recovered-token"}`))
	}))
	defer server.Close()

	connection := &Protocol{
		serverURL:   server.URL,
		apiKey:      "machine-key",
		workspaceID: "wsp_1",
		httpClient:  server.Client(),
		channelUserTokenState: channelUserTokenState{
			userTokenNow:      func() time.Time { return time.Unix(0, nowNanos.Load()).UTC() },
			userTokenRetryMin: time.Minute,
			userTokenRetryMax: time.Minute,
		},
	}

	for attempt := 0; attempt < sessionTokenMaxFailures; attempt++ {
		_, err := connection.UserToken(context.Background())
		require.ErrorContains(t, err, "HTTP 404")
	}
	require.Equal(t, int32(sessionTokenMaxFailures), exchanges.Load())

	_, err := connection.UserToken(context.Background())
	require.ErrorContains(t, err, "backoff")
	require.Equal(t, int32(sessionTokenMaxFailures), exchanges.Load(), "cooldown must suppress a hot retry agentteam")

	nowNanos.Add(int64(time.Minute))
	token, err := connection.UserToken(context.Background())
	require.NoError(t, err)
	require.Equal(t, "recovered-token", token)
	require.Equal(t, int32(sessionTokenMaxFailures+1), exchanges.Load())
	require.Zero(t, connection.userTokenFailures)
	require.True(t, connection.userTokenRetryAt.IsZero())
}

func TestUserTokenRetryDelayIsExponentiallyBounded(t *testing.T) {
	connection := &Protocol{channelUserTokenState: channelUserTokenState{
		userTokenRetryMin: 10 * time.Second,
		userTokenRetryMax: 25 * time.Second,
	}}

	connection.userTokenFailures = sessionTokenMaxFailures
	require.Equal(t, 10*time.Second, connection.userTokenRetryDelayLocked())
	connection.userTokenFailures++
	require.Equal(t, 20*time.Second, connection.userTokenRetryDelayLocked())
	connection.userTokenFailures++
	require.Equal(t, 25*time.Second, connection.userTokenRetryDelayLocked())
	connection.userTokenFailures += 100
	require.Equal(t, 25*time.Second, connection.userTokenRetryDelayLocked())
}

// TestGatewayHTTPClientStopsRedirects locks the gateway client's
// redirect-suppression contract: the local Desktop gateway owns all
// redirect handling, so outbound requests must surface 3xx responses
// instead of following them upstream.
func TestGatewayHTTPClientStopsRedirects(t *testing.T) {
	connection := &Protocol{httpClient: &http.Client{}}
	client := connection.GatewayHTTPClient()
	require.ErrorIs(t, client.CheckRedirect(&http.Request{}, nil), http.ErrUseLastResponse)
}

// TestGatewayHTTPClientHasNoTotalTimeout ensures SSE/streaming bodies
// are not capped by the control-plane client's 30s total deadline.
// The gateway client must leave Timeout==0 so long-lived streams are
// bounded only by ResponseHeaderTimeout (headers received) and the
// body-idle watchdog (no progress while reading body bytes).
func TestGatewayHTTPClientHasNoTotalTimeout(t *testing.T) {
	connection := &Protocol{httpClient: &http.Client{
		Timeout: 30 * time.Second,
	}}
	client := connection.GatewayHTTPClient()
	require.Zero(t, client.Timeout, "gateway client must not inherit control-plane total timeout")
}

// TestGatewayHTTPClientHasNoCookieJar prevents the upstream server
// from binding the gateway request into the control-plane cookie
// session. The gateway injects Authorization per request, so any
// cookie state set on the control-plane client must not leak through.
func TestGatewayHTTPClientHasNoCookieJar(t *testing.T) {
	jar, err := cookiejar.New(nil)
	require.NoError(t, err)
	connection := &Protocol{httpClient: &http.Client{Jar: jar}}
	client := connection.GatewayHTTPClient()
	require.Nil(t, client.Jar, "gateway client must not share the control-plane cookie jar")
}

// TestGatewayHTTPClientUsesIndependentTransport guards that the
// gateway client gets its own connection pool via Transport.Clone(),
// so in-flight streams do not consume slots from the control-plane
// pool and vice versa. Same pointer means an SSE stream could starve
// daemon control-plane calls.
func TestGatewayHTTPClientUsesIndependentTransport(t *testing.T) {
	transport := &http.Transport{}
	connection := &Protocol{httpClient: &http.Client{Transport: transport}}

	client := connection.GatewayHTTPClient()

	require.NotNil(t, client.Transport, "gateway client must have a transport")
	require.NotSame(t, transport, client.Transport, "gateway transport must be a clone, not the same pointer")
	cloned, ok := client.Transport.(*http.Transport)
	require.True(t, ok, "cloned transport must remain an *http.Transport")
	_ = cloned
}

// TestGatewayHTTPClientPreservesTransportPolicy ensures the cloned
// transport inherits proxy, dial, and TLS configuration from the
// control-plane transport so enterprise proxy / mTLS setups still
// apply to gateway traffic.
func TestGatewayHTTPClientPreservesTransportPolicy(t *testing.T) {
	dialCallCount := atomic.Int32{}
	dialer := func(ctx context.Context, network, addr string) (net.Conn, error) {
		dialCallCount.Add(1)
		return nil, errors.New("test sentinel: never dial")
	}
	tlsConfig := &tls.Config{ServerName: "policy-marker"}
	transport := &http.Transport{
		DialContext:       dialer,
		TLSClientConfig:   tlsConfig,
		DisableKeepAlives: true,
		MaxIdleConns:      7,
	}
	connection := &Protocol{httpClient: &http.Client{Transport: transport}}

	client := connection.GatewayHTTPClient()
	cloned, ok := client.Transport.(*http.Transport)
	require.True(t, ok)
	require.Equal(t, "policy-marker", cloned.TLSClientConfig.ServerName, "TLS config must be inherited")
	require.True(t, cloned.DisableKeepAlives, "DisableKeepAlives policy must be inherited")
	require.Equal(t, 7, cloned.MaxIdleConns, "MaxIdleConns policy must be inherited")
	require.NotNil(t, cloned.DialContext, "DialContext policy must be inherited")

	// Independent pool: the cloned transport must still dial through
	// the inherited policy but must not share idle connection state.
	_, _ = cloned.DialContext(context.Background(), "tcp", "127.0.0.1:1")
	require.Equal(t, int32(1), dialCallCount.Load(), "cloned transport must use inherited DialContext")
}

// TestGatewayHTTPClientSetsResponseHeaderTimeout bounds the failure
// mode where the upstream accepts the request but never responds.
// Without a total Timeout, this is the only header-side guard; it
// must be present on the cloned transport — including when the
// control-plane client has Transport==nil (the production wire.New
// shape).
func TestGatewayHTTPClientSetsResponseHeaderTimeout(t *testing.T) {
	t.Run("explicit transport", func(t *testing.T) {
		connection := &Protocol{httpClient: &http.Client{Transport: &http.Transport{}}}
		client := connection.GatewayHTTPClient()
		cloned, ok := client.Transport.(*http.Transport)
		require.True(t, ok)
		require.Positive(t, cloned.ResponseHeaderTimeout, "gateway transport must set ResponseHeaderTimeout")
	})
	t.Run("nil transport mirrors wire.New", func(t *testing.T) {
		jar, err := cookiejar.New(nil)
		require.NoError(t, err)
		// Mirrors wire/protocol.go:345 — wire.New's httpClient leaves
		// Transport at nil, so http.DefaultTransport is used at request
		// time unless the gateway client explicitly clones one.
		connection := &Protocol{httpClient: &http.Client{
			Timeout: 30 * time.Second,
			Jar:     jar,
		}}
		client := connection.GatewayHTTPClient()
		cloned, ok := client.Transport.(*http.Transport)
		require.True(t, ok, "nil-Transport source must still yield a cloned *http.Transport on the gateway client")
		require.Positive(t, cloned.ResponseHeaderTimeout, "gateway transport must set ResponseHeaderTimeout even when source Transport is nil")
	})
}

// TestGatewayHTTPClientReturnsStableClient verifies that all calls
// within a Protocol's lifecycle return the SAME cached client
// instance. Each gateway request must NOT clone a fresh transport —
// keep-alive connections only work if the pool persists across
// requests, and shutdown can only reclaim idle conns if there is a
// single owning transport.
func TestGatewayHTTPClientReturnsStableClient(t *testing.T) {
	connection := &Protocol{httpClient: &http.Client{
		Timeout:   30 * time.Second,
		Transport: &http.Transport{},
	}}
	first := connection.GatewayHTTPClient()
	second := connection.GatewayHTTPClient()
	third := connection.GatewayHTTPClient()
	require.Same(t, first, second, "Protocol must cache its gateway client")
	require.Same(t, first, third, "Protocol must cache its gateway client across many calls")
	require.NotSame(t, connection.httpClient, first, "gateway client must not be the control-plane client")
	require.Same(t, first.Transport, second.Transport, "gateway transport must be reused, not re-cloned")
}

// TestGatewayHTTPClientPreservesCheckRedirectAcrossCalls makes sure
// every gateway client returned has the redirect-suppressing policy
// wired, not just the first call.
func TestGatewayHTTPClientPreservesCheckRedirectAcrossCalls(t *testing.T) {
	connection := &Protocol{httpClient: &http.Client{}}
	for range 3 {
		client := connection.GatewayHTTPClient()
		require.ErrorIs(t, client.CheckRedirect(&http.Request{}, nil), http.ErrUseLastResponse)
	}
}

// TestGatewayHTTPClientRejectsCustomRoundTripper locks the fail-fast
// contract reviewer3/planner3/codex5 required for the custom RT branch.
// The standard library's ResponseHeaderTimeout field exists only on
// *http.Transport; a custom RoundTripper cannot be given a header-side
// watchdog. Rather than silently degrade (the prior behavior, which
// shared the RT and let a wedged upstream hang forever), the builder
// installs a rejecting transport so every Do fails immediately with
// ErrUnsupportedGatewayTransport. The original custom RT must NEVER
// be invoked, even when Do is called.
func TestGatewayHTTPClientRejectsCustomRoundTripper(t *testing.T) {
	calls := atomic.Int32{}
	custom := countingStubRoundTripper{onCall: func() { calls.Add(1) }}
	connection := &Protocol{httpClient: &http.Client{
		Transport: custom,
		Timeout:   30 * time.Second,
		Jar:       mockCookieJar{},
	}}
	client := connection.GatewayHTTPClient()
	require.Zero(t, client.Timeout, "gateway client must still drop total timeout")
	require.Nil(t, client.Jar, "gateway client must still drop cookie jar")
	require.ErrorIs(t, client.CheckRedirect(&http.Request{}, nil), http.ErrUseLastResponse,
		"gateway client must still suppress redirects")

	// Fails immediately even with NO caller deadline — the failure is
	// sourced from the rejecting RT, not from any context cancellation.
	req, err := http.NewRequest(http.MethodGet, "http://example.test/", nil)
	require.NoError(t, err)
	_, err = client.Do(req)
	require.ErrorIs(t, err, ErrUnsupportedGatewayTransport,
		"gateway Do must surface ErrUnsupportedGatewayTransport for custom RT")

	// The original custom RoundTripper must NEVER be called: the
	// rejecting transport intercepts before delegating.
	require.Zero(t, calls.Load(), "custom RoundTripper must not be invoked when builder rejects it")
}

// TestGatewayHTTPClientRejectsCustomGlobalDefaultTransportPlainNil
// closes the corner case codex5 flagged: when the control-plane
// client has Transport==nil (the production wire.New shape) AND the
// process has replaced http.DefaultTransport with a custom
// RoundTripper, the gateway client cannot inherit the global default
// via the standard library's implicit Do fallback — that would
// silently bypass ResponseHeaderTimeout. The builder must install
// the rejecting transport so Do fails with ErrUnsupportedGatewayTransport
// and the custom global default is never invoked.
//
// Mutates http.DefaultTransport globally; t.Parallel is forbidden and
// cleanup restores the original.
func TestGatewayHTTPClientRejectsCustomGlobalDefaultTransportPlainNil(t *testing.T) {
	// t.Parallel MUST NOT be added: this test swaps http.DefaultTransport.
	customCalls := atomic.Int32{}
	custom := countingStubRoundTripper{onCall: func() { customCalls.Add(1) }}
	original := http.DefaultTransport
	http.DefaultTransport = custom
	t.Cleanup(func() { http.DefaultTransport = original })

	control := &http.Client{} // Transport==nil — production wire.New shape
	client := BuildGatewayHTTPClient(control, GatewayResponseHeaderTimeout)

	req, err := http.NewRequest(http.MethodGet, "http://example.test/", nil)
	require.NoError(t, err)
	_, err = client.Do(req)
	require.ErrorIs(t, err, ErrUnsupportedGatewayTransport,
		"plain-nil source with custom global default must surface ErrUnsupportedGatewayTransport, not the custom RT sentinel")
	require.Zero(t, customCalls.Load(),
		"custom global DefaultTransport must not be invoked when builder rejects the fallback")
}

// TestGatewayHTTPClientRejectsCustomGlobalDefaultTransportTypedNil
// mirrors the plain-nil case but with a typed-nil *http.Transport on
// the control-plane client. The builder must hit the same default
// fallback path and reject when the global default is custom.
func TestGatewayHTTPClientRejectsCustomGlobalDefaultTransportTypedNil(t *testing.T) {
	// t.Parallel MUST NOT be added: this test swaps http.DefaultTransport.
	customCalls := atomic.Int32{}
	custom := countingStubRoundTripper{onCall: func() { customCalls.Add(1) }}
	original := http.DefaultTransport
	http.DefaultTransport = custom
	t.Cleanup(func() { http.DefaultTransport = original })

	control := &http.Client{Transport: (*http.Transport)(nil)}
	client := BuildGatewayHTTPClient(control, GatewayResponseHeaderTimeout)

	req, err := http.NewRequest(http.MethodGet, "http://example.test/", nil)
	require.NoError(t, err)
	_, err = client.Do(req)
	require.ErrorIs(t, err, ErrUnsupportedGatewayTransport,
		"typed-nil *http.Transport source with custom global default must surface ErrUnsupportedGatewayTransport")
	require.Zero(t, customCalls.Load(),
		"custom global DefaultTransport must not be invoked when builder rejects the typed-nil fallback")
}

// TestGatewayHTTPClientRejectsCustomRoundTripperStableErrorAcrossCalls
// guards that every Do against the rejecting transport surfaces the
// SAME sentinel error class, so callers can rely on errors.Is across
// retries. A drift here would let upstream-token-refresh code mistake
// the rejection for a transient network error and spin retries.
func TestGatewayHTTPClientRejectsCustomRoundTripperStableErrorAcrossCalls(t *testing.T) {
	connection := &Protocol{httpClient: &http.Client{
		Transport: stubRoundTripper{},
	}}
	client := connection.GatewayHTTPClient()
	for range 3 {
		req, err := http.NewRequest(http.MethodGet, "http://example.test/", nil)
		require.NoError(t, err)
		_, err = client.Do(req)
		require.ErrorIs(t, err, ErrUnsupportedGatewayTransport)
	}
}

// TestControlPlaneHttpClientTimeoutPreserved is a regression guard:
// the control-plane client that backs daemon API calls (token
// refresh, outbound, upgrade info) must keep its 30s total timeout
// even after the gateway client policy split. Otherwise a slow
// streaming response could hang daemon control-plane requests via a
// shared Timeout budget. The fixture precisely mirrors wire.New's
// httpClient construction (see wire/protocol.go:345).
func TestControlPlaneHttpClientTimeoutPreserved(t *testing.T) {
	connection := newControlPlaneFixture()
	require.NotNil(t, connection.httpClient)
	require.Equal(t, 30*time.Second, connection.httpClient.Timeout, "control-plane client must keep 30s total timeout")
	apiClient := connection.APIClient()
	require.Same(t, connection.httpClient, apiClient.HTTPClient)
	require.NotNil(t, apiClient.StreamingHTTPClient)
	require.NotSame(t, connection.httpClient, apiClient.StreamingHTTPClient)
	require.Zero(t, apiClient.StreamingHTTPClient.Timeout,
		"streamed request bodies must not inherit the control-plane total timeout")
	streamingTransport, ok := apiClient.StreamingHTTPClient.Transport.(*http.Transport)
	require.True(t, ok)
	require.Equal(t, apiclient.DefaultStreamingResponseHeaderTimeout,
		streamingTransport.ResponseHeaderTimeout)
	require.Same(t, apiClient.StreamingHTTPClient, connection.APIClient().StreamingHTTPClient,
		"streaming API transport must be lifecycle-scoped instead of cloned per command")
	// Drain a gateway client and re-assert control-plane is untouched.
	_ = connection.GatewayHTTPClient()
	require.Equal(t, 30*time.Second, connection.httpClient.Timeout, "control-plane timeout must survive gateway client construction")
	require.Nil(t, connection.httpClient.Transport, "control-plane client must keep Transport=nil so DefaultTransport is used")
}

// TestGatewayHTTPClientStopReleasesIdleConnections verifies that
// Protocol.Stop closes idle connections on the gateway transport
// even when the Protocol was never Start-ed (cancel/done == nil).
// This catches the codex5 review point: Stop must not skip gateway
// pool cleanup via the early return.
//
// Evidence chain:
//   - Real httptest.Server with keepalive + ConnState hook tracks
//     every conn the gateway listener's transport opens.
//   - We make a gateway request via GatewayHTTPClient(); the response
//     completes, leaving the conn idle in the pool.
//   - We call Protocol.Stop. With the production code path,
//     Stop→closeGatewayClientIdleConnections→CloseIdleConnections on
//     the cloned transport actively closes the pooled conn.
//   - The server's ConnState hook observes StateClosed. If the impl
//     regresses (e.g., Stop skips cleanup via the early return when
//     cancel==nil), no StateClosed fires and the test fails.
func TestGatewayHTTPClientStopReleasesIdleConnections(t *testing.T) {
	var serverConnsActive atomic.Int32
	var serverConnsClosed atomic.Int32
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("ok"))
	}))
	server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
		switch state {
		case http.StateNew:
			serverConnsActive.Add(1)
		case http.StateClosed:
			serverConnsActive.Add(-1)
			serverConnsClosed.Add(1)
		}
	}
	server.Start()
	defer server.Close()
	defer server.CloseClientConnections()

	// Construct a Protocol whose control-plane client points at the
	// test server. GatewayHTTPClient() lazily builds a separate client
	// with its own cloned transport.
	connection := &Protocol{httpClient: server.Client()}
	gatewayClient := connection.GatewayHTTPClient()
	require.NotNil(t, gatewayClient)
	cloned, ok := gatewayClient.Transport.(*http.Transport)
	require.True(t, ok, "gateway transport must be an *http.Transport (cloned)")
	require.NotNil(t, cloned)

	// Make a request through the gateway client. After it completes,
	// the conn sits idle in the cloned transport's pool.
	req, err := http.NewRequest(http.MethodGet, server.URL, nil)
	require.NoError(t, err)
	resp, err := gatewayClient.Do(req)
	require.NoError(t, err)
	_, _ = io.Copy(io.Discard, resp.Body)
	require.NoError(t, resp.Body.Close())

	// Wait until the server observes the conn as active (so we know
	// the pool actually holds one).
	require.Eventually(t, func() bool { return serverConnsActive.Load() >= 1 },
		2*time.Second, 5*time.Millisecond,
		"server must see the gateway request's connection as active before Stop")

	// Stop a never-Start-ed Protocol. cancel/done are nil, so the
	// early return at the bottom of Stop is reached — but only AFTER
	// closeGatewayClientIdleConnections has had a chance to run.
	require.NoError(t, connection.Stop(context.Background()))

	// The server must observe the conn reaching StateClosed: the
	// production Stop path actively invokes CloseIdleConnections on
	// the cached gateway transport. If the impl regresses (e.g.,
	// cleanup moved back below the early return), the conn stays
	// parked and this assertion fails.
	require.Eventually(t, func() bool { return serverConnsClosed.Load() >= 1 },
		2*time.Second, 10*time.Millisecond,
		"Stop must close the gateway pool's idle connection; closed count = %d", serverConnsClosed.Load())
}

type mockCookieJar struct{}

func (mockCookieJar) SetCookies(*url.URL, []*http.Cookie) {}

func (mockCookieJar) Cookies(*url.URL) []*http.Cookie { return nil }

type stubRoundTripper struct{}

func (stubRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
	return nil, errors.New("stub round tripper: not implemented")
}

// countingStubRoundTripper records every RoundTrip invocation via the
// onCall hook. It is the witness used by the custom-RT rejection test
// to prove the original transport is never invoked after the builder
// installs its rejecting transport.
type countingStubRoundTripper struct {
	onCall func()
}

func (c countingStubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	if c.onCall != nil {
		c.onCall()
	}
	return nil, errors.New("counting stub round tripper: not implemented")
}

// newControlPlaneFixture mirrors wire.New's httpClient construction
// (wire/protocol.go:345) so the regression guard reflects the real
// production shape: Timeout=30s, Jar set, Transport=nil.
func newControlPlaneFixture() *Protocol {
	jar, _ := cookiejar.New(nil)
	return &Protocol{
		httpClient: &http.Client{Timeout: 30 * time.Second, Jar: jar},
	}
}

// TestNewConstructsProductionControlAndGatewayHTTPClients is the
// production-owner evidence gate: it exercises the real wire.New
// constructor end-to-end (no hand-rolled Protocol struct) and
// asserts both the control-plane httpClient policy AND the cached
// gateway client policy. Tests in the api package reuse
// BuildGatewayHTTPClient directly via gatewayTestConnection, but
// this test is what proves the production wire.New shape actually
// flows into BuildGatewayHTTPClient the way the api fake mirrors.
func TestNewConstructsProductionControlAndGatewayHTTPClients(t *testing.T) {
	cfg := profile.AgentTeamServer{
		ID:          "wsp-probe",
		Enabled:     true,
		ServerURL:   "http://example.test",
		APIKey:      "machine-key",
		WorkspaceID: "wsp-1",
		MachineID:   "mac-1",
	}
	conn, err := New(cfg, &agentteamTestDispatcher{})
	require.NoError(t, err)
	require.NotNil(t, conn)

	protocol, ok := conn.(*Protocol)
	require.True(t, ok, "wire.New must return a *wire.Protocol so GatewayHTTPClient is reachable")

	// Control-plane client: 30s total timeout, cookie jar set,
	// Transport nil so http.DefaultTransport is used at request time.
	require.NotNil(t, protocol.httpClient, "wire.New must construct a control-plane httpClient")
	require.Equal(t, 30*time.Second, protocol.httpClient.Timeout,
		"control-plane client must keep its 30s total timeout so daemon API calls are bounded")
	require.Nil(t, protocol.httpClient.Transport,
		"control-plane client must keep Transport=nil (the production wire.New shape)")
	require.NotNil(t, protocol.httpClient.Jar, "control-plane client must have a cookie jar")

	// Gateway client: cached, separate identity from control-plane,
	// cloned transport with ResponseHeaderTimeout, no total timeout,
	// no jar, redirects suppressed.
	first := protocol.GatewayHTTPClient()
	second := protocol.GatewayHTTPClient()
	require.Same(t, first, second, "Protocol must cache its gateway client across calls")
	require.NotSame(t, protocol.httpClient, first,
		"gateway client must not be the control-plane client")

	require.Zero(t, first.Timeout,
		"gateway client must not inherit the control-plane total timeout")
	require.Nil(t, first.Jar, "gateway client must not share the control-plane cookie jar")
	require.ErrorIs(t, first.CheckRedirect(&http.Request{}, nil), http.ErrUseLastResponse,
		"gateway client must surface 3xx responses instead of following them upstream")

	cloned, ok := first.Transport.(*http.Transport)
	require.True(t, ok, "gateway transport must be an *http.Transport (cloned from http.DefaultTransport)")
	require.Positive(t, cloned.ResponseHeaderTimeout,
		"gateway transport must set ResponseHeaderTimeout so a wedged upstream fails fast")
	require.Equal(t, GatewayResponseHeaderTimeout, cloned.ResponseHeaderTimeout,
		"gateway transport must use the production GatewayResponseHeaderTimeout constant")
}
