package apiclient

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
	return f(req)
}

type postDecodePayload struct {
	A string `json:"a"`
	B string `json:"b"`
}

type postDecodeTarget interface {
	postDecodeMarker()
}

type postDecodeConcrete struct {
	A string `json:"a"`
	B string `json:"b"`
}

func (*postDecodeConcrete) postDecodeMarker() {}

type countingReadCloser struct {
	remaining int
	read      int
	closed    bool
}

func (r *countingReadCloser) Read(p []byte) (int, error) {
	if r.remaining <= 0 {
		return 0, io.EOF
	}
	n := min(len(p), r.remaining)
	for i := 0; i < n; i++ {
		p[i] = 'x'
	}
	r.remaining -= n
	r.read += n
	return n, nil
}

func (r *countingReadCloser) Close() error {
	r.closed = true
	return nil
}

func TestCloseResponseBodyDoesNotDrainUnboundedBodies(t *testing.T) {
	body := &countingReadCloser{remaining: maxResponseDrainBytes * 4}

	closeResponseBody(body)

	require.True(t, body.closed)
	require.LessOrEqual(t, body.read, maxResponseDrainBytes)
}

func TestClientGetRetriesServerErrorAndRecordsMetric(t *testing.T) {
	var calls int32
	var retries int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "/internal/test", r.URL.Path)
		require.Equal(t, "Bearer token", r.Header.Get("Authorization"))
		if atomic.AddInt32(&calls, 1) == 1 {
			w.WriteHeader(http.StatusBadGateway)
			_, _ = io.WriteString(w, `{"error":"try again"}`)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:      srv.URL,
		AuthToken:      "token",
		HTTPClient:     srv.Client(),
		RecordAPIRetry: func() { atomic.AddInt32(&retries, 1) },
	}

	var result struct {
		OK bool `json:"ok"`
	}
	require.NoError(t, client.Get(context.Background(), "/internal/test", &result))
	require.True(t, result.OK)
	require.Equal(t, int32(2), atomic.LoadInt32(&calls))
	require.Equal(t, int32(1), atomic.LoadInt32(&retries))
}

func TestClientUsesWorkspaceScopedAgentRuntimePath(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/api/v1/workspaces/wsp_1/agents/agt_1/runtime/history", r.URL.Path)
		require.Empty(t, r.Header.Get(routeWorkspaceHeader))
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:        srv.URL,
		AuthToken:        "token",
		RouteWorkspaceID: "wsp_1",
		AgentID:          "agt_1",
		HTTPClient:       srv.Client(),
	}

	var result struct {
		OK bool `json:"ok"`
	}
	require.NoError(t, client.Get(context.Background(), client.AgentRuntimePath("/history"), &result))
	require.True(t, result.OK)
}

func TestClientUsesWorkspaceScopedAttachmentReadPath(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/api/v1/workspaces/wsp_1/attachments/att_1", r.URL.Path)
		require.Empty(t, r.Header.Get(routeWorkspaceHeader))
		w.Header().Set("Content-Type", "application/octet-stream")
		_, _ = io.WriteString(w, "payload")
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:        srv.URL,
		AuthToken:        "token",
		RouteWorkspaceID: "wsp_1",
		HTTPClient:       srv.Client(),
	}

	download, err := client.GetAttachment(context.Background(), "att_1")
	require.NoError(t, err)
	require.Equal(t, []byte("payload"), download.Data)
}

func TestClientGetRetriesNetworkError(t *testing.T) {
	var calls int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			require.Equal(t, "Bearer token", req.Header.Get("Authorization"))
			if atomic.AddInt32(&calls, 1) == 1 {
				return nil, errors.New("temporary network failure")
			}
			return &http.Response{
				StatusCode: http.StatusOK,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"ok":true}`)),
				Request:    req,
			}, nil
		})},
	}

	var result struct {
		OK bool `json:"ok"`
	}
	require.NoError(t, client.Get(context.Background(), "/status", &result))
	require.True(t, result.OK)
	require.Equal(t, int32(2), atomic.LoadInt32(&calls))
}

func TestClientGetDoesNotRetryClientError(t *testing.T) {
	var calls int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		atomic.AddInt32(&calls, 1)
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = io.WriteString(w, `{"message":"bad token"}`)
	}))
	defer srv.Close()

	client := &Client{ServerURL: srv.URL, AuthToken: "bad", HTTPClient: srv.Client()}
	err := client.Get(context.Background(), "/secure", nil)
	require.Error(t, err)
	require.Equal(t, int32(1), atomic.LoadInt32(&calls))
	var apiErr *APIError
	require.ErrorAs(t, err, &apiErr)
	require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode)
	require.Equal(t, "bad token", apiErr.Message)
}

func TestClientPostWithHeadersSendsJSONAndFiltersBlankHeaders(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/send", r.URL.Path)
		require.Equal(t, "Bearer token", r.Header.Get("Authorization"))
		require.Equal(t, "application/json", r.Header.Get("Content-Type"))
		require.Equal(t, "req-1", r.Header.Get("X-Request-ID"))
		require.Empty(t, r.Header.Get("X-Blank-Key"))
		require.Empty(t, r.Header.Get("X-Blank-Value"))

		var payload map[string]string
		require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
		require.Equal(t, "hello", payload["content"])
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"id":"msg_1"}`)
	}))
	defer srv.Close()

	client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
	var result struct {
		ID string `json:"id"`
	}
	err := client.PostWithHeaders(context.Background(), "/send", map[string]string{"content": "hello"}, &result, map[string]string{
		"X-Request-ID":  "req-1",
		"X-Blank-Key":   "",
		"":              "ignored",
		"X-Blank-Value": "   ",
	})
	require.NoError(t, err)
	require.Equal(t, "msg_1", result.ID)
}

func TestClientPostIdempotentWithHeadersRetriesWithIdenticalRequest(t *testing.T) {
	var calls int32
	var retries int32
	var firstBody []byte
	const idempotencyKey = "workflow-cli:stable-receipt-key"
	headers := map[string]string{
		"Idempotency-Key": idempotencyKey,
		"x-request-id":    "request-1",
	}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/workflow/plan", r.URL.Path)
		require.Equal(t, idempotencyKey, r.Header.Get("Idempotency-Key"))
		require.Equal(t, "request-1", r.Header.Get("X-Request-Id"))
		body, err := io.ReadAll(r.Body)
		require.NoError(t, err)
		if atomic.AddInt32(&calls, 1) == 1 {
			firstBody = append([]byte(nil), body...)
			headers["Idempotency-Key"] = "caller-mutated"
			headers["x-request-id"] = "caller-mutated"
			w.WriteHeader(http.StatusServiceUnavailable)
			_, _ = io.WriteString(w, `{"error":"response lost"}`)
			return
		}
		require.Equal(t, firstBody, body)
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"receipt":"replayed"}`)
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:      srv.URL,
		AuthToken:      "token",
		HTTPClient:     srv.Client(),
		RecordAPIRetry: func() { atomic.AddInt32(&retries, 1) },
	}
	request := map[string]any{
		"context": map[string]string{"commandId": "cli:0123456789abcdef0123456789abcdef"},
		"input":   json.RawMessage(`{"action":"publish","planId":"wpl_1"}`),
	}
	var result struct {
		Receipt string `json:"receipt"`
	}
	err := client.PostIdempotentWithHeaders(
		context.Background(), "/workflow/plan", request, &result,
		headers,
	)
	require.NoError(t, err)
	require.Equal(t, "replayed", result.Receipt)
	require.Equal(t, int32(2), atomic.LoadInt32(&calls))
	require.Equal(t, int32(1), atomic.LoadInt32(&retries))
}

func TestClientPostIdempotentWithHeadersRejectsConflictingDuplicatesBeforeRequest(t *testing.T) {
	var calls atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
			calls.Add(1)
			return nil, errors.New("request must not be sent")
		})},
	}

	err := client.PostIdempotentWithHeaders(
		context.Background(),
		"/workflow/plan",
		map[string]string{"action": "publish"},
		nil,
		map[string]string{
			"Idempotency-Key": "receipt-a",
			"idempotency-key": "receipt-b",
		},
	)

	require.ErrorContains(t, err, `conflicting values for header "Idempotency-Key"`)
	require.Zero(t, calls.Load())
}

func TestClientPostIdempotentWithHeadersMergesDuplicateResponses(t *testing.T) {
	var calls atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			calls.Add(1)
			require.Equal(t, []string{"receipt-a"}, req.Header.Values("Idempotency-Key"))
			return &http.Response{
				StatusCode: http.StatusOK,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"ok":true}`)),
				Request:    req,
			}, nil
		})},
	}

	require.NoError(t, client.PostIdempotentWithHeaders(
		context.Background(),
		"/workflow/plan",
		map[string]string{"action": "publish"},
		nil,
		map[string]string{
			"Idempotency-Key": "receipt-a",
			"idempotency-key": "receipt-a",
		},
	))
	require.Equal(t, int32(1), calls.Load())
}

func TestClientPostIdempotentWithHeadersAcceptsExactlyOneSuccessJSONValue(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "receipt-1", r.Header.Get("Idempotency-Key"))
		_, _ = io.WriteString(w, `{"receipt":"ok"}`)
	}))
	defer srv.Close()

	client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
	var result struct {
		Receipt string `json:"receipt"`
	}
	err := client.PostIdempotentWithHeaders(
		context.Background(), "/send", map[string]string{"content": "hello"}, &result,
		map[string]string{"Idempotency-Key": "receipt-1"},
	)
	require.NoError(t, err)
	require.Equal(t, "ok", result.Receipt)
}

func TestClientPostWithHeadersResponseLimitRejectsOversizedSuccess(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"text":"`+strings.Repeat("x", 64)+`"}`)
	}))
	defer srv.Close()

	client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
	var result struct {
		Text string `json:"text"`
	}
	err := client.PostWithHeadersResponseLimit(context.Background(), "/send", map[string]string{"content": "hello"}, &result, nil, 32)
	require.ErrorContains(t, err, "response exceeds 32 bytes")
	require.Empty(t, result.Text)
}

func TestClientPostIdempotentWithHeadersRejectsTrailingJSON(t *testing.T) {
	var attempts int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		attempts++
		require.Equal(t, "receipt-1", r.Header.Get("Idempotency-Key"))
		_, _ = io.WriteString(w, `{"receipt":"ok"} {}`)
	}))
	defer srv.Close()

	client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
	var result json.RawMessage
	err := client.PostIdempotentWithHeaders(
		context.Background(), "/send", map[string]string{"content": "hello"}, &result,
		map[string]string{"Idempotency-Key": "receipt-1"},
	)
	require.ErrorContains(t, err, "decode response")
	require.Equal(t, 1, attempts, "decode errors are local failures and must not be retried")
}

func TestClientPostIdempotentRetryCommitsOnlyTheSuccessfulAttemptResult(t *testing.T) {
	var attempts int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		attempts++
		if attempts == 1 {
			w.WriteHeader(http.StatusServiceUnavailable)
			_, _ = io.WriteString(w, `{"error":"retry"}`)
			return
		}
		_, _ = io.WriteString(w, `{"b":"fresh"}`)
	}))
	defer srv.Close()

	client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
	result := struct {
		A string `json:"a"`
		B string `json:"b"`
	}{A: "caller-stale"}
	err := client.PostIdempotentWithHeaders(
		context.Background(), "/send", map[string]string{"content": "hello"}, &result,
		map[string]string{"Idempotency-Key": "receipt-1"},
	)
	require.NoError(t, err)
	require.Equal(t, 2, attempts)
	require.Empty(t, result.A, "a failed attempt must not leak decoded fields into the successful result")
	require.Equal(t, "fresh", result.B)
}

func TestClientPostAuthRefreshReplacesRatherThanMergesCallerResult(t *testing.T) {
	var attempts int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		attempts++
		if attempts == 1 {
			require.Equal(t, "Bearer stale-token", r.Header.Get("Authorization"))
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = io.WriteString(w, `{"error":"stale token"}`)
			return
		}
		require.Equal(t, "Bearer fresh-token", r.Header.Get("Authorization"))
		_, _ = io.WriteString(w, `{"b":"fresh"}`)
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:  srv.URL,
		AuthToken:  "stale-token",
		HTTPClient: srv.Client(),
		RefreshAuthToken: func(context.Context, string) (string, error) {
			return "fresh-token", nil
		},
	}
	result := struct {
		A string `json:"a"`
		B string `json:"b"`
	}{A: "caller-stale"}
	err := client.Post(context.Background(), "/send", map[string]string{"content": "hello"}, &result)
	require.NoError(t, err)
	require.Equal(t, 2, attempts)
	require.Empty(t, result.A, "a successful refreshed attempt must atomically replace caller state")
	require.Equal(t, "fresh", result.B)
}

func TestClientPostPreservesInitializedInterfaceConcreteTargets(t *testing.T) {
	for _, tt := range []struct {
		name   string
		result func() any
		assert func(*testing.T, any)
	}{
		{
			name: "any",
			result: func() any {
				var value any = &postDecodePayload{A: "caller-stale"}
				return &value
			},
			assert: func(t *testing.T, result any) {
				value := result.(*any)
				payload, ok := (*value).(*postDecodePayload)
				require.True(t, ok, "dynamic concrete target changed to %T", *value)
				require.Empty(t, payload.A)
				require.Equal(t, "fresh", payload.B)
			},
		},
		{
			name: "non-empty interface",
			result: func() any {
				var value postDecodeTarget = &postDecodeConcrete{A: "caller-stale"}
				return &value
			},
			assert: func(t *testing.T, result any) {
				value := result.(*postDecodeTarget)
				payload, ok := (*value).(*postDecodeConcrete)
				require.True(t, ok, "dynamic concrete target changed to %T", *value)
				require.Empty(t, payload.A)
				require.Equal(t, "fresh", payload.B)
			},
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				_, _ = io.WriteString(w, `{"b":"fresh"}`)
			}))
			defer srv.Close()

			client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
			result := tt.result()
			require.NoError(t, client.Post(context.Background(), "/send", map[string]string{"content": "hello"}, result))
			tt.assert(t, result)
		})
	}
}

func TestClientPostVariantsRequireExactlyOneSuccessJSONValue(t *testing.T) {
	for _, tt := range []struct {
		name     string
		response string
		call     func(context.Context, *Client, any) error
		wantErr  bool
	}{
		{
			name:     "post single value",
			response: `{"id":"msg_1"}`,
			call: func(ctx context.Context, client *Client, result any) error {
				return client.Post(ctx, "/send", map[string]string{"content": "hello"}, result)
			},
		},
		{
			name:     "post trailing value",
			response: `{"id":"msg_1"} {}`,
			call: func(ctx context.Context, client *Client, result any) error {
				return client.Post(ctx, "/send", map[string]string{"content": "hello"}, result)
			},
			wantErr: true,
		},
		{
			name:     "post with headers single value",
			response: `{"id":"msg_1"}`,
			call: func(ctx context.Context, client *Client, result any) error {
				return client.PostWithHeaders(ctx, "/send", map[string]string{"content": "hello"}, result, map[string]string{"X-Test": "1"})
			},
		},
		{
			name:     "post with headers trailing value",
			response: `{"id":"msg_1"} {}`,
			call: func(ctx context.Context, client *Client, result any) error {
				return client.PostWithHeaders(ctx, "/send", map[string]string{"content": "hello"}, result, map[string]string{"X-Test": "1"})
			},
			wantErr: true,
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				require.Equal(t, http.MethodPost, r.Method)
				require.Equal(t, "/send", r.URL.Path)
				_, _ = io.WriteString(w, tt.response)
			}))
			defer srv.Close()

			client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
			var result struct {
				ID string `json:"id"`
			}
			err := tt.call(context.Background(), client, &result)
			if tt.wantErr {
				require.ErrorContains(t, err, "decode response")
				return
			}
			require.NoError(t, err)
			require.Equal(t, "msg_1", result.ID)
		})
	}
}

func TestClientPostRefreshesAuthTokenOnUnauthorized(t *testing.T) {
	var calls int32
	var refreshes int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/send", r.URL.Path)
		switch atomic.AddInt32(&calls, 1) {
		case 1:
			require.Equal(t, "Bearer stale-token", r.Header.Get("Authorization"))
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = io.WriteString(w, `{"error":"stale token"}`)
		case 2:
			require.Equal(t, "Bearer fresh-token", r.Header.Get("Authorization"))
			w.Header().Set("Content-Type", "application/json")
			_, _ = io.WriteString(w, `{"id":"msg_1"}`)
		default:
			t.Fatalf("unexpected request %d", calls)
		}
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:  srv.URL,
		AuthToken:  "stale-token",
		HTTPClient: srv.Client(),
		RefreshAuthToken: func(ctx context.Context, staleToken string) (string, error) {
			require.NoError(t, ctx.Err())
			require.Equal(t, "stale-token", staleToken)
			atomic.AddInt32(&refreshes, 1)
			return "fresh-token", nil
		},
	}

	var result struct {
		ID string `json:"id"`
	}
	err := client.Post(context.Background(), "/send", map[string]string{"content": "hello"}, &result)
	require.NoError(t, err)
	require.Equal(t, "msg_1", result.ID)
	require.Equal(t, "fresh-token", client.AuthToken)
	require.Equal(t, int32(2), atomic.LoadInt32(&calls))
	require.Equal(t, int32(1), atomic.LoadInt32(&refreshes))
}

func TestClientPostReportsRefreshFailure(t *testing.T) {
	var calls int32
	var refreshes int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "Bearer stale-token", r.Header.Get("Authorization"))
		atomic.AddInt32(&calls, 1)
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = io.WriteString(w, `{"error":"stale token"}`)
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:  srv.URL,
		AuthToken:  "stale-token",
		HTTPClient: srv.Client(),
		RefreshAuthToken: func(context.Context, string) (string, error) {
			atomic.AddInt32(&refreshes, 1)
			return "", errors.New("refresh unavailable")
		},
	}

	err := client.Post(context.Background(), "/send", map[string]string{"content": "hello"}, nil)
	require.Error(t, err)
	require.Contains(t, err.Error(), "refresh auth token")
	require.Contains(t, err.Error(), "refresh unavailable")
	require.Equal(t, "stale-token", client.AuthToken)
	require.Equal(t, int32(1), atomic.LoadInt32(&calls))
	require.Equal(t, int32(1), atomic.LoadInt32(&refreshes))
}

func TestClientMutationsDoNotRetryServerErrors(t *testing.T) {
	tests := []struct {
		name   string
		method string
		call   func(context.Context, *Client) error
	}{
		{name: "post", method: http.MethodPost, call: func(ctx context.Context, c *Client) error {
			return c.Post(ctx, "/resource", map[string]string{"name": "new"}, nil)
		}},
		{name: "post-with-headers", method: http.MethodPost, call: func(ctx context.Context, c *Client) error {
			return c.PostWithHeaders(ctx, "/resource", map[string]string{"name": "new"}, nil, map[string]string{"X-Test": "1"})
		}},
		{name: "put", method: http.MethodPut, call: func(ctx context.Context, c *Client) error {
			return c.Put(ctx, "/resource", map[string]string{"name": "put"}, nil)
		}},
		{name: "patch", method: http.MethodPatch, call: func(ctx context.Context, c *Client) error {
			return c.Patch(ctx, "/resource", map[string]string{"name": "patch"}, nil)
		}},
		{name: "delete", method: http.MethodDelete, call: func(ctx context.Context, c *Client) error {
			return c.Delete(ctx, "/resource", nil)
		}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var calls int32
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				require.Equal(t, tt.method, r.Method)
				atomic.AddInt32(&calls, 1)
				w.WriteHeader(http.StatusServiceUnavailable)
				_, _ = io.WriteString(w, `{"error":"unavailable"}`)
			}))
			defer srv.Close()

			client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
			err := tt.call(context.Background(), client)
			require.Error(t, err)
			require.Equal(t, int32(1), atomic.LoadInt32(&calls))
		})
	}
}

func TestClientMutationsDoNotRetryNetworkErrors(t *testing.T) {
	tests := []struct {
		name string
		call func(context.Context, *Client) error
	}{
		{name: "post", call: func(ctx context.Context, c *Client) error {
			return c.Post(ctx, "/resource", map[string]string{"name": "new"}, nil)
		}},
		{name: "post-with-headers", call: func(ctx context.Context, c *Client) error {
			return c.PostWithHeaders(ctx, "/resource", map[string]string{"name": "new"}, nil, map[string]string{"X-Test": "1"})
		}},
		{name: "put", call: func(ctx context.Context, c *Client) error {
			return c.Put(ctx, "/resource", map[string]string{"name": "put"}, nil)
		}},
		{name: "patch", call: func(ctx context.Context, c *Client) error {
			return c.Patch(ctx, "/resource", map[string]string{"name": "patch"}, nil)
		}},
		{name: "delete", call: func(ctx context.Context, c *Client) error {
			return c.Delete(ctx, "/resource", nil)
		}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var calls int32
			client := &Client{
				ServerURL: "http://agentteam.test",
				AuthToken: "token",
				HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
					atomic.AddInt32(&calls, 1)
					return nil, errors.New("temporary network failure")
				})},
			}

			err := tt.call(context.Background(), client)
			require.Error(t, err)
			require.Equal(t, int32(1), atomic.LoadInt32(&calls))
		})
	}
}

func TestAPIErrorMessageParsing(t *testing.T) {
	require.Equal(t, "explicit error", readAPIErrorMessage(strings.NewReader(`{"error":"explicit error","message":"ignored"}`)))
	require.Equal(t, "fallback message", readAPIErrorMessage(strings.NewReader(`{"message":"fallback message"}`)))
	require.Equal(t, "plain text", readAPIErrorMessage(strings.NewReader(` plain text `)))
	require.Empty(t, readAPIErrorMessage(nil))

	err := apiErrorFromResponse(&http.Response{
		StatusCode: http.StatusConflict,
		Body:       io.NopCloser(strings.NewReader(`{"error":"duplicate"}`)),
	}, http.MethodPost, "/tasks")
	var apiErr *APIError
	require.ErrorAs(t, err, &apiErr)
	require.Equal(t, http.StatusConflict, apiErr.StatusCode)
	require.Equal(t, http.MethodPost, apiErr.Method)
	require.Equal(t, "/tasks", apiErr.Path)
	require.Equal(t, "duplicate", apiErr.Message)
	require.Contains(t, apiErr.Error(), "duplicate")

	structuredErr := apiErrorFromResponse(&http.Response{
		StatusCode: http.StatusNotFound,
		Body:       io.NopCloser(strings.NewReader(`{"error":"target missing","code":"target_scope_not_found","details":{"stage":"target_scope_resolution"}}`)),
	}, http.MethodPost, "/agent-thread/upstream")
	require.ErrorAs(t, structuredErr, &apiErr)
	require.Equal(t, "target_scope_not_found", apiErr.Code)
	require.Equal(t, "target_scope_resolution", apiErr.Stage())
	require.Contains(t, apiErr.Error(), "stage=target_scope_resolution")

	var nilRespErr *APIError
	require.ErrorAs(t, apiErrorFromResponse(nil, http.MethodGet, "/nil"), &nilRespErr)
	require.Equal(t, http.MethodGet, nilRespErr.Method)
}

func TestAPIPublicWorkflowContractParsing(t *testing.T) {
	// The server emits the resolved public Workflow error contract as top-level
	// fields alongside {error, code, details}; apiclient must parse and carry
	// the allowlisted fields for Desktop Exec projection.
	index := 2
	body := `{
		"error":"callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$",
		"code":"step_work_items_invalid",
		"stage":"run",
		"path":"callback.workItems[2].key",
		"hint":"Fix the WorkItem key.",
		"retryable":true,
		"diagnostics":[{
			"severity":"error",
			"code":"callback.work_items.key_invalid",
			"path":"callback.workItems[2].key",
			"field":"key",
			"index":2,
			"message":"callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$"
		}]
	}`
	message, code, details, contract, truncated := readAPIErrorContract(strings.NewReader(body))
	require.False(t, truncated, "resolved contract body must not be truncated")
	require.Equal(t, "callback.workItems[2].key must match ^[a-z][a-z0-9_-]{0,255}$", message)
	require.Equal(t, "step_work_items_invalid", code)
	require.Nil(t, details)
	require.Equal(t, "run", contract.stage)
	require.Equal(t, "callback.workItems[2].key", contract.path)
	require.Equal(t, "Fix the WorkItem key.", contract.hint)
	require.True(t, contract.retryable)
	require.Len(t, contract.diagnostics, 1)
	require.Equal(t, "callback.work_items.key_invalid", contract.diagnostics[0].Code)
	require.Equal(t, "key", contract.diagnostics[0].Field)
	require.NotNil(t, contract.diagnostics[0].Index)
	require.Equal(t, index, *contract.diagnostics[0].Index)

	// apiErrorFromResponse carries the allowlisted fields on the typed error.
	err := apiErrorFromResponse(&http.Response{
		StatusCode: http.StatusBadRequest,
		Body:       io.NopCloser(strings.NewReader(body)),
	}, http.MethodPost, "/workflow/step")
	var apiErr *APIError
	require.ErrorAs(t, err, &apiErr)
	require.Equal(t, "step_work_items_invalid", apiErr.Code)
	require.Equal(t, "run", apiErr.Stage())
	require.Equal(t, "callback.workItems[2].key", apiErr.ErrorPath)
	require.Equal(t, "Fix the WorkItem key.", apiErr.Hint)
	require.True(t, apiErr.Retryable)
	require.Len(t, apiErr.Diagnostics, 1)
	require.Equal(t, "key", apiErr.Diagnostics[0].Field)
	require.NotNil(t, apiErr.Diagnostics[0].Index)
	require.Equal(t, 2, *apiErr.Diagnostics[0].Index)
}

func TestAPIContractNonJSONFailsClosed(t *testing.T) {
	// Unknown/non-JSON/network errors fail closed: no allowlisted fields leak.
	err := apiErrorFromResponse(&http.Response{
		StatusCode: http.StatusBadGateway,
		Body:       io.NopCloser(strings.NewReader(`<html>upstream error</html>`)),
	}, http.MethodGet, "/workflow/run")
	var apiErr *APIError
	require.ErrorAs(t, err, &apiErr)
	require.Equal(t, http.StatusBadGateway, apiErr.StatusCode)
	require.Equal(t, "<html>upstream error</html>", apiErr.Message)
	require.Empty(t, apiErr.Code)
	require.Empty(t, apiErr.Stage())
	require.Empty(t, apiErr.ErrorPath)
	require.Empty(t, apiErr.Hint)
	require.False(t, apiErr.Retryable)
	require.Empty(t, apiErr.Diagnostics)
}

func TestGetAttachmentSuccessAndLimits(t *testing.T) {
	t.Run("success", func(t *testing.T) {
		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			require.Equal(t, "/api/v1/workspaces/wsp_1/attachments/att_1", r.URL.Path)
			require.Equal(t, "Bearer token", r.Header.Get("Authorization"))
			w.Header().Set("Content-Type", "text/plain")
			w.Header().Set("Content-Disposition", `attachment; filename="notes.txt"`)
			_, _ = io.WriteString(w, "hello")
		}))
		defer srv.Close()

		client := &Client{ServerURL: srv.URL, AuthToken: "token", RouteWorkspaceID: "wsp_1", HTTPClient: srv.Client()}
		download, err := client.GetAttachment(context.Background(), "att_1")
		require.NoError(t, err)
		require.Equal(t, []byte("hello"), download.Data)
		require.Equal(t, "text/plain", download.ContentType)
		require.Equal(t, "notes.txt", download.Filename)
	})

	t.Run("content-length-too-large", func(t *testing.T) {
		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
			w.Header().Set("Content-Length", fmt.Sprint(MaxUploadBytes+1))
			w.WriteHeader(http.StatusOK)
		}))
		defer srv.Close()

		client := &Client{ServerURL: srv.URL, AuthToken: "token", RouteWorkspaceID: "wsp_1", HTTPClient: srv.Client()}
		download, err := client.GetAttachment(context.Background(), "att_too_large")
		require.ErrorContains(t, err, "attachment too large")
		require.Empty(t, download)
	})

	t.Run("body-too-large-without-content-length", func(t *testing.T) {
		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
			w.Header().Set("Content-Type", "application/octet-stream")
			w.Header().Set("Content-Length", "-1")
			_, _ = io.Copy(w, io.LimitReader(strings.NewReader(strings.Repeat("x", MaxUploadBytes+1)), MaxUploadBytes+1))
		}))
		defer srv.Close()

		client := &Client{ServerURL: srv.URL, AuthToken: "token", RouteWorkspaceID: "wsp_1", HTTPClient: srv.Client()}
		download, err := client.GetAttachment(context.Background(), "att_stream_large")
		require.ErrorContains(t, err, "attachment too large")
		require.Empty(t, download)
	})
}

func TestPostMultipartSuccessAndLimits(t *testing.T) {
	require.Equal(t, int64(10*1024*1024), int64(MaxUploadBytes))

	t.Run("success", func(t *testing.T) {
		file := writeTempUploadFile(t, "payload.txt", "hello multipart")
		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			require.Equal(t, http.MethodPost, r.Method)
			require.Equal(t, "/upload", r.URL.Path)
			require.Equal(t, "Bearer token", r.Header.Get("Authorization"))
			require.True(t, strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data; boundary="))
			require.NoError(t, r.ParseMultipartForm(1<<20))
			require.Equal(t, "ops", r.FormValue("channel"))
			uploaded, header, err := r.FormFile("file")
			require.NoError(t, err)
			defer uploaded.Close()
			require.Equal(t, "payload.txt", header.Filename)
			require.Equal(t, "text/plain; charset=utf-8", header.Header.Get("Content-Type"))
			body, err := io.ReadAll(uploaded)
			require.NoError(t, err)
			require.Equal(t, "hello multipart", string(body))
			w.Header().Set("Content-Type", "application/json")
			_, _ = io.WriteString(w, `{"id":"att_1"}`)
		}))
		defer srv.Close()

		client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
		id, err := client.PostMultipart(context.Background(), "/upload", file, "file", map[string]string{"channel": "ops"}, nil)
		require.NoError(t, err)
		require.Equal(t, "att_1", id)
	})

	t.Run("file-too-large", func(t *testing.T) {
		file := writeSparseUploadFile(t, MaxUploadBytes+1)
		client := &Client{ServerURL: "http://agentteam.test", AuthToken: "token", HTTPClient: http.DefaultClient}
		id, err := client.PostMultipart(context.Background(), "/upload", file, "file", nil, nil)
		require.ErrorContains(t, err, "file too large")
		require.Empty(t, id)
	})
}

func TestPostMultipartRawSuccessAndHTTPError(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "Bearer token", r.Header.Get("Authorization"))
		require.Equal(t, "multipart/form-data; boundary=test", r.Header.Get("Content-Type"))
		if strings.Contains(r.URL.Path, "fail") {
			w.WriteHeader(http.StatusBadRequest)
			_, _ = io.WriteString(w, `{"error":"bad multipart"}`)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"ok":true}`)
	}))
	defer srv.Close()

	client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
	var result struct {
		OK bool `json:"ok"`
	}
	require.NoError(t, client.PostMultipartRaw(context.Background(), "/ok", []byte("body"), "multipart/form-data; boundary=test", &result))
	require.True(t, result.OK)

	err := client.PostMultipartRaw(context.Background(), "/fail", []byte("body"), "multipart/form-data; boundary=test", nil)
	require.ErrorContains(t, err, "bad multipart")
}

func TestPostMultipartRawRefreshesAuthAndReplaysIdenticalBody(t *testing.T) {
	const (
		contentType = "multipart/form-data; boundary=avatar"
		payload     = "--avatar\r\nContent-Disposition: form-data; name=\"file\"; filename=\"avatar.png\"\r\n\r\nimage\r\n--avatar--\r\n"
	)
	var calls int32
	var refreshes int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		attempt := atomic.AddInt32(&calls, 1)
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/avatar", r.URL.Path)
		require.Equal(t, contentType, r.Header.Get("Content-Type"))
		body, err := io.ReadAll(r.Body)
		require.NoError(t, err)
		require.Equal(t, payload, string(body))
		if attempt == 1 {
			require.Equal(t, "Bearer stale-token", r.Header.Get("Authorization"))
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = io.WriteString(w, `{"error":"Unauthorized"}`)
			return
		}
		require.Equal(t, "Bearer fresh-token", r.Header.Get("Authorization"))
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"avatarUrl":"/avatar/fresh"}`)
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:  srv.URL,
		AuthToken:  "stale-token",
		HTTPClient: srv.Client(),
		RefreshAuthToken: func(_ context.Context, staleToken string) (string, error) {
			require.Equal(t, "stale-token", staleToken)
			atomic.AddInt32(&refreshes, 1)
			return "fresh-token", nil
		},
	}
	var result struct {
		AvatarURL string `json:"avatarUrl"`
	}
	require.NoError(t, client.PostMultipartRaw(
		context.Background(), "/avatar", []byte(payload), contentType, &result,
	))
	require.Equal(t, "/avatar/fresh", result.AvatarURL)
	require.Equal(t, "fresh-token", client.AuthToken)
	require.Equal(t, int32(2), atomic.LoadInt32(&calls))
	require.Equal(t, int32(1), atomic.LoadInt32(&refreshes))
}

func TestPostMultipartRawReportsAuthRefreshFailureWithoutReplay(t *testing.T) {
	var calls int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		atomic.AddInt32(&calls, 1)
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = io.WriteString(w, `{"error":"Unauthorized"}`)
	}))
	defer srv.Close()

	client := &Client{
		ServerURL:  srv.URL,
		AuthToken:  "stale-token",
		HTTPClient: srv.Client(),
		RefreshAuthToken: func(context.Context, string) (string, error) {
			return "", errors.New("refresh unavailable")
		},
	}
	err := client.PostMultipartRaw(
		context.Background(), "/avatar", []byte("body"),
		"multipart/form-data; boundary=test", nil,
	)
	require.ErrorContains(t, err, "refresh auth token")
	require.ErrorContains(t, err, "refresh unavailable")
	require.Equal(t, int32(1), atomic.LoadInt32(&calls))
}

func TestPostMultipartStreamUsesChunkedBodyAndReplaysAfterAuthRefresh(t *testing.T) {
	var calls int
	var writes int
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		calls++
		require.Equal(t, int64(-1), request.ContentLength)
		require.Equal(t, []string{"chunked"}, request.TransferEncoding)
		if calls == 1 {
			require.Equal(t, "Bearer stale-token", request.Header.Get("Authorization"))
			_, _ = io.Copy(io.Discard, request.Body)
			writer.WriteHeader(http.StatusUnauthorized)
			_, _ = io.WriteString(writer, `{"error":"expired"}`)
			return
		}
		require.Equal(t, "Bearer fresh-token", request.Header.Get("Authorization"))
		require.NoError(t, request.ParseMultipartForm(1024))
		require.Equal(t, "value", request.FormValue("field"))
		_, _ = io.WriteString(writer, `{"ok":true}`)
	}))
	t.Cleanup(server.Close)

	client := &Client{
		ServerURL: server.URL, AuthToken: "stale-token", HTTPClient: server.Client(),
		RefreshAuthToken: func(_ context.Context, stale string) (string, error) {
			require.Equal(t, "stale-token", stale)
			return "fresh-token", nil
		},
	}
	var result struct {
		OK bool `json:"ok"`
	}
	err := client.PostMultipartStream(context.Background(), "/stream", func(writer *multipart.Writer) error {
		writes++
		return writer.WriteField("field", "value")
	}, &result)
	require.NoError(t, err)
	require.True(t, result.OK)
	require.Equal(t, 2, calls)
	require.Equal(t, 2, writes)
}

func TestPostMultipartStreamHealsStaleCachedTokenOnSecondRefreshRound(t *testing.T) {
	// The first refresh may hand back another cached token the server already
	// rotated out; the bounded second round must recover the streamed upload.
	var calls int
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		calls++
		auth := request.Header.Get("Authorization")
		if auth != "Bearer fresh-token" {
			_, _ = io.Copy(io.Discard, request.Body)
			writer.WriteHeader(http.StatusUnauthorized)
			_, _ = io.WriteString(writer, `{"error":"unauthorized"}`)
			return
		}
		require.NoError(t, request.ParseMultipartForm(1024))
		require.Equal(t, "value", request.FormValue("field"))
		_, _ = io.WriteString(writer, `{"ok":true}`)
	}))
	t.Cleanup(server.Close)

	client := &Client{
		ServerURL: server.URL, AuthToken: "stale-snapshot", HTTPClient: server.Client(),
		RefreshAuthToken: func(_ context.Context, stale string) (string, error) {
			if stale == "stale-snapshot" {
				return "stale-cache", nil
			}
			return "fresh-token", nil
		},
	}
	var result struct {
		OK bool `json:"ok"`
	}
	err := client.PostMultipartStream(context.Background(), "/stream", func(writer *multipart.Writer) error {
		return writer.WriteField("field", "value")
	}, &result)
	require.NoError(t, err)
	require.True(t, result.OK)
	require.Equal(t, 3, calls)
}

func TestPostMultipartStreamDoesNotInheritControlPlaneTotalTimeout(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		require.NoError(t, request.ParseMultipartForm(1024))
		require.Equal(t, "before", request.FormValue("first"))
		require.Equal(t, "after", request.FormValue("second"))
		_, _ = io.WriteString(writer, `{"ok":true}`)
	}))
	t.Cleanup(server.Close)

	controlPlane := server.Client()
	controlPlane.Timeout = 10 * time.Millisecond
	client := &Client{
		ServerURL:           server.URL,
		AuthToken:           "token",
		HTTPClient:          controlPlane,
		StreamingHTTPClient: NewStreamingHTTPClient(controlPlane, 15*time.Millisecond),
	}
	var result struct {
		OK bool `json:"ok"`
	}
	err := client.PostMultipartStream(context.Background(), "/stream", func(writer *multipart.Writer) error {
		if err := writer.WriteField("first", "before"); err != nil {
			return err
		}
		uploadDelay := time.NewTimer(50 * time.Millisecond)
		defer uploadDelay.Stop()
		<-uploadDelay.C
		return writer.WriteField("second", "after")
	}, &result)

	require.NoError(t, err)
	require.True(t, result.OK)
	require.Equal(t, 10*time.Millisecond, controlPlane.Timeout)
	require.Zero(t, client.StreamingHTTPClient.Timeout)
	streamingTransport, ok := client.StreamingHTTPClient.Transport.(*http.Transport)
	require.True(t, ok)
	require.Equal(t, 15*time.Millisecond, streamingTransport.ResponseHeaderTimeout)
}

func TestPostMultipartStreamTimesOutWaitingForHeadersAfterBodyEOF(t *testing.T) {
	bodyRead := make(chan struct{})
	lateHeader := make(chan struct{})
	releaseHeader := make(chan struct{})
	defer close(releaseHeader)
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		_, _ = io.Copy(io.Discard, request.Body)
		close(bodyRead)
		<-releaseHeader
		close(lateHeader)
		_, _ = io.WriteString(writer, `{"ok":true}`)
	}))
	t.Cleanup(server.Close)

	controlPlane := server.Client()
	controlPlane.Timeout = 10 * time.Millisecond
	client := &Client{
		ServerURL:           server.URL,
		AuthToken:           "token",
		HTTPClient:          controlPlane,
		StreamingHTTPClient: NewStreamingHTTPClient(controlPlane, 25*time.Millisecond),
	}
	err := client.PostMultipartStream(context.Background(), "/stream", func(writer *multipart.Writer) error {
		return writer.WriteField("field", "value")
	}, nil)

	require.Error(t, err)
	require.ErrorContains(t, err, "timeout awaiting response headers")
	select {
	case <-bodyRead:
	default:
		t.Fatal("server must consume the complete request body before the header timeout starts")
	}
	select {
	case <-lateHeader:
		t.Fatal("client must fail before the delayed response header is written")
	default:
	}
}

func TestClientMutationsDecodeSuccessfulResponses(t *testing.T) {
	tests := []struct {
		name   string
		method string
		call   func(context.Context, *Client, any) error
	}{
		{name: "put", method: http.MethodPut, call: func(ctx context.Context, c *Client, result any) error {
			return c.Put(ctx, "/resource", map[string]string{"name": "put"}, result)
		}},
		{name: "patch", method: http.MethodPatch, call: func(ctx context.Context, c *Client, result any) error {
			return c.Patch(ctx, "/resource", map[string]string{"name": "patch"}, result)
		}},
		{name: "delete", method: http.MethodDelete, call: func(ctx context.Context, c *Client, result any) error {
			return c.Delete(ctx, "/resource", result)
		}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				require.Equal(t, tt.method, r.Method)
				w.Header().Set("Content-Type", "application/json")
				_, _ = io.WriteString(w, `{"ok":true}`)
			}))
			defer srv.Close()

			client := &Client{ServerURL: srv.URL, AuthToken: "token", HTTPClient: srv.Client()}
			var result struct {
				OK bool `json:"ok"`
			}
			require.NoError(t, tt.call(context.Background(), client, &result))
			require.True(t, result.OK)
		})
	}
}

func TestReadAPIErrorMessageLimitsBody(t *testing.T) {
	// A body longer than the limit is detected via limit+1 reading and reports
	// truncation with a stable safe message instead of returning a partial JSON
	// slice or the raw truncated bytes.
	message := readAPIErrorMessage(strings.NewReader(strings.Repeat("x", maxAPIErrorBodyBytes+1024)))
	require.Equal(t, "server error response exceeded the size limit and was truncated", message)

	// An exactly-at-limit body is fully read and parsed, not flagged truncated.
	exact := strings.Repeat("y", maxAPIErrorBodyBytes)
	_, _, _, contract, truncated := readAPIErrorContract(strings.NewReader(exact))
	require.False(t, truncated, "exactly-at-limit body must not be flagged truncated")
	require.Empty(t, contract.diagnostics)
	require.Empty(t, contract.stage)
}

func TestAPIErrorContractDetectsTruncation(t *testing.T) {
	// apiErrorFromResponse flags oversized error bodies as truncated and never
	// projects structured contract fields from a partial body.
	err := apiErrorFromResponse(&http.Response{
		StatusCode: http.StatusBadGateway,
		Body:       io.NopCloser(strings.NewReader(`{"code":"internal_error","diagnostics":[{"message":"` + strings.Repeat("m", maxAPIErrorBodyBytes) + `"}]}`)),
	}, http.MethodPost, "/workflow/step")
	var apiErr *APIError
	require.ErrorAs(t, err, &apiErr)
	require.True(t, apiErr.Truncated, "oversized error body must be flagged truncated")
	require.Equal(t, http.StatusBadGateway, apiErr.StatusCode)
	require.Empty(t, apiErr.Code)
	require.Empty(t, apiErr.StageName)
	require.Empty(t, apiErr.ErrorPath)
	require.Empty(t, apiErr.Diagnostics)
	require.Contains(t, apiErr.Message, "truncated")
}

func TestClientShouldRetry(t *testing.T) {
	client := &Client{}

	for _, code := range []int{500, 502, 503} {
		err := &APIError{StatusCode: code, Method: "GET", Path: "/test"}
		if !client.shouldRetry(err) {
			t.Errorf("expected shouldRetry=true for status %d", code)
		}
	}

	for _, code := range []int{400, 401, 403, 404, 409} {
		err := &APIError{StatusCode: code, Method: "GET", Path: "/test"}
		if client.shouldRetry(err) {
			t.Errorf("expected shouldRetry=false for status %d", code)
		}
	}

	if client.shouldRetry(fmt.Errorf("decode response: unexpected EOF")) {
		t.Fatal("expected shouldRetry=false for unclassified local error")
	}
	if !client.shouldRetry(&transportError{err: fmt.Errorf("dial tcp: connection refused")}) {
		t.Fatal("expected shouldRetry=true for typed transport error")
	}
}

func writeTempUploadFile(t *testing.T, name string, content string) string {
	t.Helper()
	path := t.TempDir() + string(os.PathSeparator) + name
	require.NoError(t, os.WriteFile(path, []byte(content), 0644))
	return path
}

func writeSparseUploadFile(t *testing.T, size int64) string {
	t.Helper()
	path := t.TempDir() + string(os.PathSeparator) + "large.bin"
	file, err := os.Create(path)
	require.NoError(t, err)
	require.NoError(t, file.Truncate(size))
	require.NoError(t, file.Close())
	return path
}
