package connector_callout

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sync/atomic"
	"testing"
)

func makeIDs(n int) []string {
	ids := make([]string, n)
	for i := range ids {
		ids[i] = fmt.Sprintf("id-%04d", i)
	}
	return ids
}

func TestBatchReadChunks250By100(t *testing.T) {
	var requestCount int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&requestCount, 1)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprintf(w, `{"chunk":%d}`, atomic.LoadInt32(&requestCount))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    100,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	ids := makeIDs(250)
	results, err := engine.BatchRead(context.Background(), spec, ids, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(results) != 3 {
		t.Errorf("expected 3 chunk results, got %d", len(results))
	}
	if n := atomic.LoadInt32(&requestCount); n != 3 {
		t.Errorf("expected 3 HTTP requests (100+100+50), got %d", n)
	}
}

func TestBatchReadEmptyIDs(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := BatchSpec{MaxBatchSize: 100, IDsRequestField: "ids"}
	_, err := engine.BatchRead(context.Background(), spec, nil, noopPolicy())
	if !errors.Is(err, ErrEmptyBatch) {
		t.Errorf("expected ErrEmptyBatch, got %v", err)
	}
}

func TestBatchReadInvalidSpec(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := BatchSpec{MaxBatchSize: 0, IDsRequestField: "ids"}
	_, err := engine.BatchRead(context.Background(), spec, []string{"x"}, noopPolicy())
	if !errors.Is(err, ErrInvalidBatchSpec) {
		t.Errorf("expected ErrInvalidBatchSpec, got %v", err)
	}
}

func TestBatchReadPerChunkErrorReportsRange(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&count, 1)
		if n == 2 {
			// Second chunk fails with a non-retryable 500 — but 500 is retryable
			// by default. Use 400 for non-retryable terminal error.
			w.WriteHeader(400)
			return
		}
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "class",
		MaxBatchSize:    100,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	ids := makeIDs(250)
	_, err := engine.BatchRead(context.Background(), spec, ids, noopPolicy())
	if err == nil {
		t.Fatal("expected error for middle chunk failure")
	}
	// Error must contain the id-index range [100, 200)
	errStr := err.Error()
	if errStr == "" || !containsRange(errStr, 100, 200) {
		t.Errorf("expected error to contain range [100, 200), got: %v", err)
	}
}

// TestBatchReadSetsContentTypeJSON verifies that BatchRead sets
// Content-Type: application/json on each chunk request. Vendors typically
// reject JSON bodies without this header, causing silent parse failures.
func TestBatchReadSetsContentTypeJSON(t *testing.T) {
	var gotContentType, gotAccept string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotContentType = r.Header.Get("Content-Type")
		gotAccept = r.Header.Get("Accept")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if gotContentType != "application/json" {
		t.Errorf("Content-Type = %q, want %q", gotContentType, "application/json")
	}
	if gotAccept != "application/json" {
		t.Errorf("Accept = %q, want %q", gotAccept, "application/json")
	}
}

// TestBatchReadHonorsCallerContentType verifies that if the caller constructs
// a BatchSpec that piggybacks caller-set headers via Engine.Do directly, the
// precedence is documented: BatchRead builds a fresh headers map and sets
// Content-Type only when absent. (Callers that need a different content type
// must use Engine.Do directly rather than BatchRead.)
// This test confirms that BatchRead's own header set is "application/json"
// when no caller override exists — and is a regression guard for the default path.
func TestBatchReadHonorsCallerContentType(t *testing.T) {
	// BatchRead always builds its own headers map with "application/json".
	// Verify the default is stable across multiple chunk requests.
	var seenContentTypes []string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		seenContentTypes = append(seenContentTypes, r.Header.Get("Content-Type"))
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    1,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1", "id-2"}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	for i, ct := range seenContentTypes {
		if ct != "application/json" {
			t.Errorf("chunk %d: Content-Type = %q, want %q", i, ct, "application/json")
		}
	}
}

func containsRange(s string, start, end int) bool {
	return contains(s, fmt.Sprintf("[%d, %d)", start, end))
}

func contains(s, sub string) bool {
	return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
}

func containsStr(s, sub string) bool {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}

// TestBatchReadHonorsCustomAuthHeaders verifies B18: BatchSpec.ExtraHeaders
// are forwarded to every chunk request. AuthTypeCustom callers need this to
// inject an Authorization header that the engine does not set for them.
func TestBatchReadHonorsCustomAuthHeaders(t *testing.T) {
	var receivedAuth string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		receivedAuth = r.Header.Get("Authorization")
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `[{"id":"id1"}]`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		authType: "Custom", // engine does NOT set Authorization for Custom
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc-custom",
		EndpointClass:   "test.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
		ExtraHeaders:    http.Header{"Authorization": []string{"Custom-Token xyz"}},
	}

	policy := RetryPolicy{MaxAttempts: 1, BaseDelay: 0, Multiplier: 1}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id1"}, policy)
	if err != nil {
		t.Fatalf("BatchRead failed: %v", err)
	}
	if receivedAuth != "Custom-Token xyz" {
		t.Errorf("expected Authorization header %q, got %q", "Custom-Token xyz", receivedAuth)
	}
}

// TestBatchRead_NormalizesPaddedMethod verifies B42: BatchSpec{Method:" POST "}
// is accepted and normalized so that http.NewRequestWithContext receives the
// clean "POST" method without leading/trailing whitespace.
func TestBatchRead_NormalizesPaddedMethod(t *testing.T) {
	var gotMethod string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotMethod = r.Method
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          " POST ",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
	if err != nil {
		t.Fatalf("expected padded Method to be normalized and succeed, got: %v", err)
	}
	if gotMethod != "POST" {
		t.Errorf("server received method %q, want %q", gotMethod, "POST")
	}
}

// B25 — BatchRead validates full BatchSpec at engine entry.

// TestBatchRead_RejectsEmptyRequiredSpecField verifies B25: BatchRead validates
// the full BatchSpec at engine entry and rejects any spec missing a required
// field (Method, IDsRequestField, CredentialRef, EndpointClass).
func TestBatchRead_RejectsEmptyRequiredSpecField(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*BatchSpec)
	}{
		{name: "Method", mutate: func(s *BatchSpec) { s.Method = "" }},
		{name: "IDsRequestField", mutate: func(s *BatchSpec) { s.IDsRequestField = "" }},
		{name: "CredentialRef", mutate: func(s *BatchSpec) { s.CredentialRef = "" }},
		{name: "EndpointClass", mutate: func(s *BatchSpec) { s.EndpointClass = "" }},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
			spec := BatchSpec{
				Method:          "POST",
				RelativePath:    "/batch",
				CredentialRef:   "nc",
				EndpointClass:   "api.batch",
				MaxBatchSize:    10,
				IDsRequestField: "ids",
				IDResponseField: "id",
			}
			tt.mutate(&spec)
			_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
			if !errors.Is(err, ErrInvalidBatchSpec) {
				t.Errorf("expected ErrInvalidBatchSpec for empty %s, got %v", tt.name, err)
			}
		})
	}
}

// TestBatchRead_AllowsEmptyRelativePath asserts that BatchSpec.RelativePath=""
// is accepted: Engine.Do treats it as the credential base URL root, which is
// a valid batch endpoint shape (e.g. POST against the NamedCredential base URL).
func TestBatchRead_AllowsEmptyRelativePath(t *testing.T) {
	var gotPath string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(`{}`))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "", // empty: credential base URL root
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
	if err != nil {
		t.Fatalf("BatchRead with empty RelativePath returned error: %v", err)
	}
	if gotPath != "/" {
		t.Errorf("expected request at root path '/', got %q", gotPath)
	}
}

func TestBatchRead_RejectsGETMethod(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := BatchSpec{
		Method:          "GET",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
	if !errors.Is(err, ErrInvalidBatchSpec) {
		t.Errorf("expected ErrInvalidBatchSpec for GET Method, got %v", err)
	}
}

// TestBatchRead_RejectsHEADMethod verifies B36: HEAD drops the response body and
// carries no request body; BatchRead must reject it.
func TestBatchRead_RejectsHEADMethod(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := BatchSpec{
		Method:          "HEAD",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
	if !errors.Is(err, ErrInvalidBatchSpec) {
		t.Errorf("expected ErrInvalidBatchSpec for HEAD Method, got %v", err)
	}
}

// TestValidateBatchSpec_AcceptsDELETE verifies that validateBatchSpec accepts
// DELETE: Engine.Do only strips bodies for GET and HEAD, so DELETE is a valid
// body-bearing method for BatchRead.
func TestValidateBatchSpec_AcceptsDELETE(t *testing.T) {
	spec := BatchSpec{
		Method:          "DELETE",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	if err := validateBatchSpec(spec); err != nil {
		t.Errorf("expected nil error for DELETE method, got: %v", err)
	}
}

// TestBatchRead_WithDELETEMethod verifies end-to-end: BatchRead with
// Method="DELETE" passes validation and the server receives the request body
// containing the chunk IDs via the DELETE method on the wire.
func TestBatchRead_WithDELETEMethod(t *testing.T) {
	var gotMethod string
	var gotBody []byte
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotMethod = r.Method
		var err error
		gotBody, err = io.ReadAll(r.Body)
		if err != nil {
			t.Errorf("read request body: %v", err)
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "DELETE",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1", "id-2"}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if gotMethod != "DELETE" {
		t.Errorf("server received method %q, want %q", gotMethod, "DELETE")
	}
	if len(gotBody) == 0 {
		t.Error("server received empty request body; chunk IDs were not sent")
	}
}

// TestValidateBatchSpec_RejectsGET is a regression guard: GET must still be
// rejected because Engine.Do drops the body for GET requests.
func TestValidateBatchSpec_RejectsGET(t *testing.T) {
	spec := BatchSpec{
		Method:          "GET",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	if err := validateBatchSpec(spec); !errors.Is(err, ErrInvalidBatchSpec) {
		t.Errorf("expected ErrInvalidBatchSpec for GET method, got: %v", err)
	}
}

// TestValidateBatchSpec_RejectsHEAD is a regression guard: HEAD must still be
// rejected because Engine.Do drops the body for HEAD requests.
func TestValidateBatchSpec_RejectsHEAD(t *testing.T) {
	spec := BatchSpec{
		Method:          "HEAD",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	if err := validateBatchSpec(spec); !errors.Is(err, ErrInvalidBatchSpec) {
		t.Errorf("expected ErrInvalidBatchSpec for HEAD method, got: %v", err)
	}
}

// TestBatchRead_ExtraBodyFieldsMergedIntoBody verifies that ExtraBodyFields are
// decoded and merged into each chunk request body alongside the IDs array, and
// that an ExtraBodyFields key equal to IDsRequestField is silently ignored.
func TestBatchRead_ExtraBodyFieldsMergedIntoBody(t *testing.T) {
	var receivedBody []byte
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var err error
		receivedBody, err = io.ReadAll(r.Body)
		if err != nil {
			t.Errorf("read request body: %v", err)
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
		ExtraBodyFields: map[string]json.RawMessage{
			"properties": json.RawMessage(`["email","name"]`),
			"ids":        json.RawMessage(`["should-be-ignored"]`), // collides with IDsRequestField
		},
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1", "id-2"}, noopPolicy())
	if err != nil {
		t.Fatalf("BatchRead failed: %v", err)
	}

	// Unmarshal with json.RawMessage values to avoid any float64 coercion —
	// we want to see the raw JSON tokens exactly as sent on the wire.
	var body map[string]json.RawMessage
	if err := json.Unmarshal(receivedBody, &body); err != nil {
		t.Fatalf("unmarshal request body: %v", err)
	}

	// IDsRequestField must contain the actual chunk IDs, not the ignored colliding entry.
	idsRaw, ok := body["ids"]
	if !ok {
		t.Fatal("request body missing 'ids' key")
	}
	var idsSlice []string
	if err := json.Unmarshal(idsRaw, &idsSlice); err != nil {
		t.Fatalf("'ids' is not a string array: %v", err)
	}
	if len(idsSlice) != 2 || idsSlice[0] != "id-1" || idsSlice[1] != "id-2" {
		t.Errorf("'ids' = %v, want [id-1 id-2]", idsSlice)
	}

	// ExtraBodyField "properties" must appear verbatim — same bytes as authored
	// in ExtraBodyFields (no double-encoding, no float64 coercion).
	propsRaw, ok := body["properties"]
	if !ok {
		t.Fatal("request body missing 'properties' key from ExtraBodyFields")
	}
	var propsSlice []string
	if err := json.Unmarshal(propsRaw, &propsSlice); err != nil {
		t.Fatalf("'properties' is not a string array: %v", err)
	}
	if len(propsSlice) != 2 || propsSlice[0] != "email" || propsSlice[1] != "name" {
		t.Errorf("'properties' = %v, want [email name]", propsSlice)
	}
}

// TestBatchRead_ExtraBodyFieldsLargeIntPreserved verifies that a large integer
// in ExtraBodyFields is preserved byte-for-byte in the outgoing request body.
// The old round-trip (Unmarshal → any → Marshal) coerced large integers to
// float64, silently rewriting e.g. 10000000000000000001 as 1e+19.
func TestBatchRead_ExtraBodyFieldsLargeIntPreserved(t *testing.T) {
	const largeInt = "10000000000000000001"
	var receivedBody []byte
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var err error
		receivedBody, err = io.ReadAll(r.Body)
		if err != nil {
			t.Errorf("read request body: %v", err)
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
		ExtraBodyFields: map[string]json.RawMessage{
			"limit": json.RawMessage(largeInt),
		},
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
	if err != nil {
		t.Fatalf("BatchRead failed: %v", err)
	}

	// The large integer must appear verbatim in the body, not as a float exponent.
	bodyStr := string(receivedBody)
	if !containsStr(bodyStr, largeInt) {
		t.Errorf("expected body to contain %q verbatim, got: %s", largeInt, bodyStr)
	}
}

// TestBatchRead_ExtraBodyFieldsInvalidJSONFailsClosed verifies that an
// ExtraBodyFields entry that is not valid JSON causes BatchRead to return an
// error (fail closed) rather than silently dropping the field.
func TestBatchRead_ExtraBodyFieldsInvalidJSONFailsClosed(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
		ExtraBodyFields: map[string]json.RawMessage{
			"bad": json.RawMessage(`{bad`), // not valid JSON
		},
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
	if err == nil {
		t.Fatal("expected error for invalid ExtraBodyFields JSON, got nil")
	}
}

// TestBatchRead_ExtraBodyFieldsKeyCollidingWithIDsIgnored verifies that an
// ExtraBodyFields entry whose key equals IDsRequestField is silently dropped
// so the actual chunk IDs are always authoritative.
func TestBatchRead_ExtraBodyFieldsKeyCollidingWithIDsIgnored(t *testing.T) {
	var receivedBody []byte
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var err error
		receivedBody, err = io.ReadAll(r.Body)
		if err != nil {
			t.Errorf("read body: %v", err)
		}
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
		ExtraBodyFields: map[string]json.RawMessage{
			"ids": json.RawMessage(`["injected-id"]`), // must be silently ignored
		},
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"real-id"}, noopPolicy())
	if err != nil {
		t.Fatalf("BatchRead failed: %v", err)
	}

	var body map[string]any
	if err := json.Unmarshal(receivedBody, &body); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	idsSlice, ok := body["ids"].([]any)
	if !ok {
		t.Fatalf("body 'ids' not an array: %T", body["ids"])
	}
	if len(idsSlice) != 1 || idsSlice[0] != "real-id" {
		t.Errorf("expected ids=[real-id], got %v", idsSlice)
	}
}

// TestBatchRead_AcceptsPOSTMethod verifies B36 happy path: POST is a body-bearing
// method and must pass validateBatchSpec (existing happy-path coverage).
func TestBatchRead_AcceptsPOSTMethod(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`["ok"]`))
	}))
	defer srv.Close()

	engine := NewEngine(&mockCredentialResolver{endpoint: srv.URL, authType: "Custom"}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id-1"}, noopPolicy())
	if err != nil {
		t.Errorf("expected POST to pass validateBatchSpec, got: %v", err)
	}
}

// TestBatchRead_WithQueryParams asserts that BatchSpec.QueryParams are forwarded
// as query-string parameters on every chunk request. Batch endpoints like
// POST <base>/batch?version=2.0 cannot embed "?" in RelativePath (rejected at
// preflight); QueryParams is the designated carrier.
func TestBatchRead_WithQueryParams(t *testing.T) {
	var gotQuery url.Values
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotQuery = r.URL.Query()
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(`{}`))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
		QueryParams:     url.Values{"version": []string{"2.0"}},
	}
	_, err := engine.BatchRead(context.Background(), spec, []string{"id1", "id2", "id3"}, noopPolicy())
	if err != nil {
		t.Fatalf("BatchRead returned unexpected error: %v", err)
	}
	if got := gotQuery.Get("version"); got != "2.0" {
		t.Errorf("expected query param version=2.0, got %q (full query: %v)", got, gotQuery)
	}
}

// TestValidateBatchSpec_AllowsEmptyRelativePath confirms that validateBatchSpec
// accepts a BatchSpec with an empty RelativePath when all other fields are valid.
func TestValidateBatchSpec_AllowsEmptyRelativePath(t *testing.T) {
	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "", // explicitly empty — must be allowed
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	if err := validateBatchSpec(spec); err != nil {
		t.Errorf("validateBatchSpec returned error for empty RelativePath: %v", err)
	}
}

// TestBatchRead_PreservesChunkResponseOnEngineError verifies that when a chunk
// request fails, BatchRead returns a *BatchChunkError that carries the chunk
// index range, the last CalloutResponse (status code + body), and unwraps the
// underlying Engine.Do error via errors.As / errors.Unwrap.
func TestBatchRead_PreservesChunkResponseOnEngineError(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&count, 1)
		if n == 2 {
			// Second chunk fails with 503 and an error body.
			w.WriteHeader(503)
			_, _ = fmt.Fprint(w, `{"error":"service unavailable"}`)
			return
		}
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "class",
		MaxBatchSize:    100,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}
	// 250 ids → chunk 1: [0,100), chunk 2: [100,200), chunk 3: [200,250)
	// chunk 2 hits the 503.
	ids := makeIDs(250)
	_, err := engine.BatchRead(context.Background(), spec, ids, noopPolicy())
	if err == nil {
		t.Fatal("expected error for chunk 2 failure, got nil")
	}

	var chunkErr *BatchChunkError
	if !errors.As(err, &chunkErr) {
		t.Fatalf("expected *BatchChunkError, got %T: %v", err, err)
	}
	if chunkErr.Start != 100 || chunkErr.End != 200 {
		t.Errorf("expected chunk range [100, 200), got [%d, %d)", chunkErr.Start, chunkErr.End)
	}
	if chunkErr.Response.StatusCode != 503 {
		t.Errorf("expected Response.StatusCode == 503, got %d", chunkErr.Response.StatusCode)
	}
	want := `{"error":"service unavailable"}`
	if string(chunkErr.Response.Body) != want {
		t.Errorf("expected Response.Body == %q, got %q", want, string(chunkErr.Response.Body))
	}
	if errors.Unwrap(err) == nil {
		t.Error("expected errors.Unwrap to return the underlying Engine.Do error, got nil")
	}
}

// TestBatchRead_ErrorUnwrapPreservesUnderlyingError verifies that errors.Is
// continues to work through the BatchChunkError wrap — the underlying sentinel
// error is reachable via the errors chain after wrapping in BatchChunkError.
// Uses a pre-cancelled context so the first chunk immediately returns
// context.Canceled without needing a real server.
func TestBatchRead_ErrorUnwrapPreservesUnderlyingError(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `{}`)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "class",
		MaxBatchSize:    100,
		IDsRequestField: "ids",
		IDResponseField: "id",
	}

	// Pre-cancel the context so Engine.Do on the very first chunk returns
	// context.Canceled without hanging.
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	ids := makeIDs(100)
	_, err := engine.BatchRead(ctx, spec, ids, noopPolicy())
	if err == nil {
		t.Fatal("expected error for cancelled context, got nil")
	}

	var chunkErr *BatchChunkError
	if !errors.As(err, &chunkErr) {
		t.Fatalf("expected *BatchChunkError, got %T: %v", err, err)
	}
	// errors.Is must see context.Canceled through the BatchChunkError wrap chain.
	if !errors.Is(err, context.Canceled) {
		t.Errorf("expected errors.Is(err, context.Canceled) to be true; err = %v", err)
	}
}

// TestValidateBatchSpec_RejectsEmptyIDsRequestField confirms that validateBatchSpec
// still rejects a BatchSpec with an empty IDsRequestField (regression guard).
func TestValidateBatchSpec_RejectsEmptyIDsRequestField(t *testing.T) {
	spec := BatchSpec{
		Method:          "POST",
		RelativePath:    "/batch",
		CredentialRef:   "nc",
		EndpointClass:   "api.batch",
		MaxBatchSize:    10,
		IDsRequestField: "", // missing — must be rejected
	}
	err := validateBatchSpec(spec)
	if err == nil {
		t.Fatal("validateBatchSpec expected error for empty IDsRequestField, got nil")
	}
	if !errors.Is(err, ErrInvalidBatchSpec) {
		t.Errorf("expected ErrInvalidBatchSpec, got: %v", err)
	}
}
