package connector_callout

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestPaginateWalksThreePages(t *testing.T) {
	pages := map[string]string{
		"":   `{"items":["a","b"],"paging":{"next":{"after":"p2"}}}`,
		"p2": `{"items":["c","d"],"paging":{"next":{"after":"p3"}}}`,
		"p3": `{"items":["e","f"]}`,
	}

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cursor := r.URL.Query().Get("after")
		body, ok := pages[cursor]
		if !ok {
			http.Error(w, "unexpected cursor", 400)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, body)
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "after",
	}
	req := CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}
	reader, err := engine.Paginate(context.Background(), req, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	var pageCount int
	for {
		resp, more, err := reader.Next(context.Background())
		if err != nil {
			t.Fatalf("unexpected error on page %d: %v", pageCount+1, err)
		}
		pageCount++
		if resp.StatusCode != 200 {
			t.Errorf("page %d: expected status 200, got %d", pageCount, resp.StatusCode)
		}
		if !more {
			break
		}
	}
	if pageCount != 3 {
		t.Errorf("expected 3 pages, walked %d", pageCount)
	}

	// A 4th call after done should return (_, false, nil).
	_, more, err := reader.Next(context.Background())
	if more || err != nil {
		t.Errorf("expected (false, nil) after done, got (more=%v, err=%v)", more, err)
	}
}

// TestPaginateWalksNumericCursor verifies that pagination works when the vendor
// returns a numeric cursor (e.g. {"paging":{"next":{"after":2}}}). The second
// page's request must carry ?after=2 (integer, not 2.000000).
func TestPaginateWalksNumericCursor(t *testing.T) {
	var page2Cursor string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cursor := r.URL.Query().Get("after")
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		if cursor == "" {
			// Page 1: return a numeric cursor.
			_, _ = fmt.Fprint(w, `{"items":[1,2],"paging":{"next":{"after":2}}}`)
		} else {
			// Page 2: record the cursor received, return no paging.
			page2Cursor = cursor
			_, _ = fmt.Fprint(w, `{"items":[3,4]}`)
		}
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	var pageCount int
	for {
		_, more, err := reader.Next(context.Background())
		if err != nil {
			t.Fatalf("unexpected error on page %d: %v", pageCount+1, err)
		}
		pageCount++
		if !more {
			break
		}
	}
	if pageCount != 2 {
		t.Errorf("expected 2 pages, walked %d", pageCount)
	}
	if page2Cursor != "2" {
		t.Errorf("page2 cursor = %q, want %q", page2Cursor, "2")
	}
}

// TestPaginateWalksFloatCursor verifies that pagination works when the vendor
// returns a floating-point cursor (e.g. {"paging":{"next":{"after":2.5}}}).
func TestPaginateWalksFloatCursor(t *testing.T) {
	var page2Cursor string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cursor := r.URL.Query().Get("after")
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		if cursor == "" {
			_, _ = fmt.Fprint(w, `{"items":[1],"paging":{"next":{"after":2.5}}}`)
		} else {
			page2Cursor = cursor
			_, _ = fmt.Fprint(w, `{"items":[2]}`)
		}
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	_, _, err = reader.Next(context.Background()) // page 1
	if err != nil {
		t.Fatalf("page 1 error: %v", err)
	}
	_, _, err = reader.Next(context.Background()) // page 2
	if err != nil {
		t.Fatalf("page 2 error: %v", err)
	}
	if page2Cursor != "2.5" {
		t.Errorf("page2 cursor = %q, want %q", page2Cursor, "2.5")
	}
}

// TestExtractJSONPath_ArrayCursorReturnsInvalidResponse verifies that an array
// cursor leaf returns ErrInvalidResponse instead of silently stopping pagination.
// Previously this collapsed to "" (silent truncation); now it fails closed.
func TestExtractJSONPath_ArrayCursorReturnsInvalidResponse(t *testing.T) {
	body := []byte(`{"paging":{"next":{"after":[1,2]}}}`)
	_, err := extractJSONPath(body, "paging.next.after")
	if err == nil {
		t.Fatal("expected error for array cursor leaf, got nil")
	}
	if !errors.Is(err, ErrInvalidResponse) {
		t.Errorf("expected ErrInvalidResponse for array cursor, got %v", err)
	}
}

func TestPaginateInvalidJSONPath(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, `not-valid-json`)
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}

	_, _, err = reader.Next(context.Background())
	if err == nil {
		t.Fatal("expected error for invalid JSON, got nil")
	}
	if !errors.Is(err, ErrInvalidResponse) {
		t.Errorf("expected ErrInvalidResponse, got %v", err)
	}
}

// B24 — Paginate PageSpec validation at engine entry.

func TestPaginate_RejectsEmptyNextCursorJSONPath(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "", // missing
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for empty NextCursorJSONPath, got %v", err)
	}
}

func TestPaginate_RejectsEmptyCursorRequestParam(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "", // missing
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for empty CursorRequestParam, got %v", err)
	}
}

func TestPaginate_RejectsWhitespaceOnlyCursorParams(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())

	// whitespace-only NextCursorJSONPath
	spec := PageSpec{
		NextCursorJSONPath: "   ",
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for whitespace NextCursorJSONPath, got %v", err)
	}

	// whitespace-only CursorRequestParam
	spec2 := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "\t",
	}
	_, err = engine.Paginate(context.Background(), CalloutRequest{}, spec2, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for whitespace CursorRequestParam, got %v", err)
	}
}

func TestPaginate_RejectsLeadingDotInNextCursorJSONPath(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: ".foo",
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for leading-dot NextCursorJSONPath, got %v", err)
	}
}

func TestPaginate_RejectsTrailingDotInNextCursorJSONPath(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "foo.",
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for trailing-dot NextCursorJSONPath, got %v", err)
	}
}

func TestPaginate_RejectsConsecutiveDotsInNextCursorJSONPath(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging..after",
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for consecutive-dots NextCursorJSONPath, got %v", err)
	}
}

func TestPaginate_RejectsSingleDotAsNextCursorJSONPath(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: ".",
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for single-dot NextCursorJSONPath, got %v", err)
	}
}

func TestPaginate_RejectsWhitespaceOnlySegmentInNextCursorJSONPath(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "foo. .bar",
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for whitespace-only segment NextCursorJSONPath, got %v", err)
	}
}

func TestPaginate_AcceptsValidDottedPath(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "after",
	}
	pr, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if err != nil {
		t.Errorf("expected no error for valid dotted NextCursorJSONPath, got %v", err)
	}
	if pr == nil {
		t.Error("expected non-nil PaginatedReader for valid NextCursorJSONPath")
	}
}

// TestPaginate_NormalizesPaddedCursorParams verifies B42: padded spec fields
// (" paging.next.after ", " after ") are trimmed before use so that:
//  1. Paginate accepts them (non-empty after trim).
//  2. Next uses the trimmed value as the JSON path and query param name,
//     not the padded version.
func TestPaginate_NormalizesPaddedCursorParams(t *testing.T) {
	pages := map[string]string{
		"":   `{"items":["a"],"paging":{"next":{"after":"p2"}}}`,
		"p2": `{"items":["b"]}`,
	}
	var receivedCursorParam string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Record the actual query param name the engine used for page 2.
		receivedCursorParam = r.URL.Query().Get("after")
		cursor := r.URL.Query().Get("after")
		body, ok := pages[cursor]
		if !ok {
			http.Error(w, "unexpected cursor: "+r.URL.RawQuery, 400)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		_, _ = fmt.Fprint(w, body)
	}))
	defer srv.Close()

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

	// Padded spec fields — should be accepted and used trimmed downstream.
	spec := PageSpec{
		NextCursorJSONPath: "  paging.next.after  ",
		CursorRequestParam: "  after  ",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate rejected padded spec fields, want accepted: %v", err)
	}
	defer reader.Close()

	_, _, err = reader.Next(context.Background()) // page 1
	if err != nil {
		t.Fatalf("page 1 error: %v", err)
	}
	_, _, err = reader.Next(context.Background()) // page 2
	if err != nil {
		t.Fatalf("page 2 error: %v", err)
	}
	// Trimmed cursor param name must be used, not the padded one.
	if receivedCursorParam != "p2" {
		t.Errorf("page 2 received cursor %q via param 'after', want %q; padded param may not have been trimmed", receivedCursorParam, "p2")
	}
}

// --- Fix 1: internal whitespace in CursorRequestParam / PageSizeRequestParam ---

// TestPaginate_RejectsInternalWhitespaceInCursorRequestParam verifies that
// CursorRequestParam values containing internal whitespace (e.g. "af ter")
// are rejected at Paginate time with ErrInvalidPageSpec. url.Values.Set encodes
// "aft er" as "aft+er=cursor" on the wire, so the server never sees the
// configured param name, returns a fresh page-1 cursor, and Next() loops
// without bound. Sibling of B46.
func TestPaginate_RejectsInternalWhitespaceInCursorRequestParam(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "af ter", // internal space — invalid
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for CursorRequestParam with internal space, got %v", err)
	}
}

// TestPaginate_RejectsTabInCursorRequestParam verifies that a CursorRequestParam
// with an embedded tab is rejected. Leading/trailing whitespace is stripped first,
// so "af\tter" (internal tab) must still be rejected.
func TestPaginate_RejectsTabInCursorRequestParam(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "af\tter", // internal tab — invalid
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for CursorRequestParam with internal tab, got %v", err)
	}
}

// TestPaginate_RejectsInternalWhitespaceInPageSizeRequestParam verifies that
// PageSizeRequestParam with internal whitespace is rejected when PageSizeValue > 0.
func TestPaginate_RejectsInternalWhitespaceInPageSizeRequestParam(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath:   "paging.next.after",
		CursorRequestParam:   "after",
		PageSizeRequestParam: "page size", // internal space — invalid
		PageSizeValue:        100,
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for PageSizeRequestParam with internal space, got %v", err)
	}
}

// TestPaginate_AcceptsCleanCursorRequestParam is a regression guard: a valid
// PageSpec (clean dotted NextCursorJSONPath and whitespace-free
// CursorRequestParam) must still be accepted — the whitespace/padding checks
// must not be over-eager.
func TestPaginate_AcceptsCleanCursorRequestParam(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging.next.after",
		CursorRequestParam: "after",
	}
	pr, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if err != nil {
		t.Errorf("expected no error for valid CursorRequestParam, got %v", err)
	}
	if pr == nil {
		t.Error("expected non-nil PaginatedReader for valid CursorRequestParam")
	}
}

// --- Fix 3: bracket syntax in NextCursorJSONPath ---

// TestPaginate_RejectsBracketSyntaxInNextCursorJSONPath verifies that bracket-
// style array indices like "paging[0].next" are rejected at Paginate time.
// extractJSONPath would look up the literal key "paging[0]" in the root map,
// fail to find it, return "", and pagination would silently stop at page 1.
// Same silent-truncation class as the original B46. Sibling of B46 itself.
func TestPaginate_RejectsBracketSyntaxInNextCursorJSONPath(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging[0].next", // bracket syntax — invalid
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for bracket-syntax NextCursorJSONPath, got %v", err)
	}
}

// --- B48 — repeated cursor detection ---

// TestPaginate_RejectsImmediateRepeatedCursor verifies that when the upstream
// returns the same non-empty cursor on every response (e.g. NextCursorJSONPath
// points at a stable field), Next stops with ErrInvalidResponse after the
// second call instead of looping indefinitely.
func TestPaginate_RejectsImmediateRepeatedCursor(t *testing.T) {
	var requestCount int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestCount++
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		// Always return the same cursor "X" — simulates a stable field.
		_, _ = fmt.Fprint(w, `{"items":[1],"paging":{"next":"X"}}`)
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	// First call: cursor "X" is new — should succeed with more=true.
	_, more, err := reader.Next(context.Background())
	if err != nil {
		t.Fatalf("first Next: unexpected error: %v", err)
	}
	if !more {
		t.Fatal("first Next: expected more=true, got false")
	}

	// Second call: cursor "X" is repeated — must fail with ErrInvalidResponse.
	_, more, err = reader.Next(context.Background())
	if more {
		t.Error("second Next: expected more=false for repeated cursor, got true")
	}
	if err == nil {
		t.Fatal("second Next: expected error for repeated cursor, got nil")
	}
	if !errors.Is(err, ErrInvalidResponse) {
		t.Errorf("second Next: expected ErrInvalidResponse, got %v", err)
	}

	// Server must have received exactly 2 requests — proves the loop stopped.
	if requestCount != 2 {
		t.Errorf("expected 2 server requests, got %d", requestCount)
	}
}

// TestPaginate_RejectsCycleCursor verifies that a cursor cycle (A → B → A)
// is detected on the third call and stopped with ErrInvalidResponse.
func TestPaginate_RejectsCycleCursor(t *testing.T) {
	responses := []string{
		`{"items":[1],"paging":{"next":"A"}}`,
		`{"items":[2],"paging":{"next":"B"}}`,
		`{"items":[3],"paging":{"next":"A"}}`, // cycle: A reappears
	}
	var requestCount int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		idx := requestCount
		requestCount++
		if idx < len(responses) {
			_, _ = fmt.Fprint(w, responses[idx])
		} else {
			_, _ = fmt.Fprint(w, `{"items":[]}`)
		}
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	// First call: cursor "A" — new, more=true.
	_, more, err := reader.Next(context.Background())
	if err != nil || !more {
		t.Fatalf("first Next: want (more=true, err=nil), got (more=%v, err=%v)", more, err)
	}

	// Second call: cursor "B" — new, more=true.
	_, more, err = reader.Next(context.Background())
	if err != nil || !more {
		t.Fatalf("second Next: want (more=true, err=nil), got (more=%v, err=%v)", more, err)
	}

	// Third call: cursor "A" repeated — must fail with ErrInvalidResponse.
	_, more, err = reader.Next(context.Background())
	if more {
		t.Error("third Next: expected more=false for cycle cursor, got true")
	}
	if err == nil {
		t.Fatal("third Next: expected error for cycle cursor, got nil")
	}
	if !errors.Is(err, ErrInvalidResponse) {
		t.Errorf("third Next: expected ErrInvalidResponse, got %v", err)
	}

	// Server must have received exactly 3 requests.
	if requestCount != 3 {
		t.Errorf("expected 3 server requests, got %d", requestCount)
	}
}

// --- B1 — large integer cursor precision (UseNumber fix) ---

// TestExtractJSONPath_PreservesLargeIntegerCursors verifies that a JSON number
// cursor larger than 2^53 (9007199254740992) is returned as its exact textual
// representation. Without json.Decoder.UseNumber the value decodes into float64,
// and a 17-digit integer like 12345678901234567 would round to 12345678901234568
// or be formatted in scientific notation — causing the next-page request to carry
// the wrong cursor and skip or duplicate pages.
func TestExtractJSONPath_PreservesLargeIntegerCursors(t *testing.T) {
	body := []byte(`{"paging":{"next":12345678901234567}}`)
	got, err := extractJSONPath(body, "paging.next")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	const want = "12345678901234567"
	if got != want {
		t.Errorf("large integer cursor = %q, want %q (precision loss via float64?)", got, want)
	}
}

// TestExtractJSONPath_NumericCursorRoundtrip verifies that a small integer cursor
// arrives as a plain integer string ("42") rather than with a decimal point
// ("42.000000") or scientific notation.
func TestExtractJSONPath_NumericCursorRoundtrip(t *testing.T) {
	body := []byte(`{"after":42}`)
	got, err := extractJSONPath(body, "after")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != "42" {
		t.Errorf("numeric cursor = %q, want %q", got, "42")
	}
}

// --- B2 — leading/trailing whitespace in path segments ---

// TestPaginate_RejectsLeadingWhitespaceInSegment verifies that a NextCursorJSONPath
// segment with leading whitespace (e.g. "paging. next.after") is rejected at
// Paginate time with ErrInvalidPageSpec. Without this check, validateDottedJSONPath
// would accept the trimmed segment as non-empty, but extractJSONPath would look up
// the untrimmed key " next" in the JSON object, find nothing, and silently stop
// pagination after page 1.
func TestPaginate_RejectsLeadingWhitespaceInSegment(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging. next.after", // leading space in middle segment
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for leading-whitespace segment, got %v", err)
	}
	if err != nil && !strings.Contains(err.Error(), "whitespace") {
		t.Errorf("error message should mention 'whitespace', got %q", err.Error())
	}
}

// TestPaginate_RejectsTrailingWhitespaceInSegment verifies that a NextCursorJSONPath
// segment with trailing whitespace (e.g. "paging.next .after") is rejected at
// Paginate time with ErrInvalidPageSpec — same silent-truncation class as leading
// whitespace.
func TestPaginate_RejectsTrailingWhitespaceInSegment(t *testing.T) {
	engine := NewEngine(&mockCredentialResolver{}, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	spec := PageSpec{
		NextCursorJSONPath: "paging.next .after", // trailing space in middle segment
		CursorRequestParam: "after",
	}
	_, err := engine.Paginate(context.Background(), CalloutRequest{}, spec, noopPolicy())
	if !errors.Is(err, ErrInvalidPageSpec) {
		t.Errorf("expected ErrInvalidPageSpec for trailing-whitespace segment, got %v", err)
	}
	if err != nil && !strings.Contains(err.Error(), "whitespace") {
		t.Errorf("error message should mention 'whitespace', got %q", err.Error())
	}
}

// TestPaginate_AcceptsDistinctCursors is a regression guard ensuring that
// distinct cursors (A → B → "") still walk all pages without error.
func TestPaginate_AcceptsDistinctCursors(t *testing.T) {
	responses := []string{
		`{"items":[1],"paging":{"next":"A"}}`,
		`{"items":[2],"paging":{"next":"B"}}`,
		`{"items":[3]}`, // no cursor — clean terminator
	}
	var requestCount int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		idx := requestCount
		requestCount++
		if idx < len(responses) {
			_, _ = fmt.Fprint(w, responses[idx])
		} else {
			_, _ = fmt.Fprint(w, `{"items":[]}`)
		}
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	// First: cursor "A" — more=true.
	_, more, err := reader.Next(context.Background())
	if err != nil || !more {
		t.Fatalf("first Next: want (more=true, err=nil), got (more=%v, err=%v)", more, err)
	}

	// Second: cursor "B" — more=true.
	_, more, err = reader.Next(context.Background())
	if err != nil || !more {
		t.Fatalf("second Next: want (more=true, err=nil), got (more=%v, err=%v)", more, err)
	}

	// Third: empty cursor — clean terminator, more=false, err=nil.
	_, more, err = reader.Next(context.Background())
	if err != nil {
		t.Fatalf("third Next: unexpected error: %v", err)
	}
	if more {
		t.Error("third Next: expected more=false after empty cursor, got true")
	}

	// Server must have received exactly 3 requests.
	if requestCount != 3 {
		t.Errorf("expected 3 server requests, got %d", requestCount)
	}
}

// --- B49 — boolean cursor values terminate pagination ---

// TestExtractJSONPath_BooleanFalseTerminatesPagination verifies that a boolean
// false at the cursor path is treated as "" (terminator), not forwarded as
// "?after=false". Upstreams that use false as the last-page sentinel must not
// trigger an extra request.
func TestExtractJSONPath_BooleanFalseTerminatesPagination(t *testing.T) {
	body := []byte(`{"paging":{"next":false}}`)
	got, err := extractJSONPath(body, "paging.next")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != "" {
		t.Errorf("boolean false cursor = %q, want %q (should terminate pagination)", got, "")
	}
}

// TestExtractJSONPath_TrueCursorReturnsInvalidResponse verifies that boolean true
// at the cursor path returns ErrInvalidResponse. No realistic API paginates with
// literal true as a cursor token — fail closed instead of silently stopping.
func TestExtractJSONPath_TrueCursorReturnsInvalidResponse(t *testing.T) {
	body := []byte(`{"paging":{"next":true}}`)
	_, err := extractJSONPath(body, "paging.next")
	if err == nil {
		t.Fatal("expected error for boolean true cursor, got nil")
	}
	if !errors.Is(err, ErrInvalidResponse) {
		t.Errorf("expected ErrInvalidResponse for boolean true cursor, got %v", err)
	}
}

// TestPaginate_StopsOnBooleanLastPageSentinel verifies end-to-end that when the
// upstream returns false at the cursor path as the last-page sentinel, Next()
// terminates cleanly and no extra ?after=false request is sent to the server.
func TestPaginate_StopsOnBooleanLastPageSentinel(t *testing.T) {
	var requestCount int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestCount++
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		if requestCount == 1 {
			// Page 1: real string cursor "A".
			_, _ = fmt.Fprint(w, `{"paging":{"next":"A"},"data":[1]}`)
		} else {
			// Page 2: bool false as last-page sentinel.
			_, _ = fmt.Fprint(w, `{"paging":{"next":false},"data":[2]}`)
		}
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	// First call: page 1 with cursor "A" — succeeds, more=true.
	_, more, err := reader.Next(context.Background())
	if err != nil {
		t.Fatalf("first Next: unexpected error: %v", err)
	}
	if !more {
		t.Fatal("first Next: expected more=true, got false")
	}

	// Second call: page 2 with bool-false sentinel — terminates cleanly.
	_, more, err = reader.Next(context.Background())
	if err != nil {
		t.Fatalf("second Next: unexpected error: %v", err)
	}
	if more {
		t.Error("second Next: expected more=false (bool-false sentinel), got true")
	}

	// Server must have received exactly 2 requests — no ?after=false was sent.
	if requestCount != 2 {
		t.Errorf("expected 2 server requests, got %d (extra ?after=false request?)", requestCount)
	}
}

// --- B1 fail-closed: unsupported cursor shapes must return ErrInvalidResponse ---

// TestExtractJSONPath_ObjectCursorReturnsInvalidResponse verifies that an object
// leaf at the cursor path returns ErrInvalidResponse rather than silently stopping
// pagination. An object cursor (e.g. {"paging":{"next":{"a":1}}}) is not a valid
// cursor token — it indicates misconfigured NextCursorJSONPath or schema drift.
func TestExtractJSONPath_ObjectCursorReturnsInvalidResponse(t *testing.T) {
	body := []byte(`{"paging":{"next":{"a":1}}}`)
	_, err := extractJSONPath(body, "paging.next")
	if err == nil {
		t.Fatal("expected error for object cursor leaf, got nil")
	}
	if !errors.Is(err, ErrInvalidResponse) {
		t.Errorf("expected ErrInvalidResponse for object cursor, got %v", err)
	}
}

// TestExtractJSONPath_FalseCursorTerminatesCleanly is a regression guard for the
// intentional false-sentinel behavior: boolean false at the cursor path must
// terminate pagination cleanly (empty cursor, no error) — not return an error.
func TestExtractJSONPath_FalseCursorTerminatesCleanly(t *testing.T) {
	body := []byte(`{"paging":{"next":false}}`)
	got, err := extractJSONPath(body, "paging.next")
	if err != nil {
		t.Fatalf("expected no error for boolean false cursor (sentinel), got: %v", err)
	}
	if got != "" {
		t.Errorf("boolean false cursor = %q, want %q (should terminate pagination cleanly)", got, "")
	}
}

// TestExtractJSONPath_MidPathNonObjectReturnsInvalidResponse verifies that when
// a mid-walk segment descends into a non-object value (e.g. path "paging.next"
// where "paging" resolves to a string "opaque"), ErrInvalidResponse is returned
// instead of silently stopping. This is misconfigured NextCursorJSONPath or schema
// drift and must fail closed.
func TestExtractJSONPath_MidPathNonObjectReturnsInvalidResponse(t *testing.T) {
	body := []byte(`{"paging":"opaque"}`)
	// Path "paging.next" — "paging" resolves to string "opaque", then walk tries
	// to descend into segment "next" but current is not an object.
	_, err := extractJSONPath(body, "paging.next")
	if err == nil {
		t.Fatal("expected error for mid-path non-object value, got nil")
	}
	if !errors.Is(err, ErrInvalidResponse) {
		t.Errorf("expected ErrInvalidResponse for mid-path non-object, got %v", err)
	}
}

// TestPaginate_StopsAndPropagatesInvalidResponseError verifies end-to-end that
// when the upstream returns an object cursor on page 2, Next() returns
// (_, false, ErrInvalidResponse) rather than silently stopping pagination.
func TestPaginate_StopsAndPropagatesInvalidResponseError(t *testing.T) {
	var requestCount int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestCount++
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		if requestCount == 1 {
			// Page 1: valid string cursor.
			_, _ = fmt.Fprint(w, `{"data":[1],"paging":{"next":"tok1"}}`)
		} else {
			// Page 2: object cursor — not a valid cursor token.
			_, _ = fmt.Fprint(w, `{"data":[2],"paging":{"next":{"offset":100}}}`)
		}
	}))
	defer srv.Close()

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

	spec := PageSpec{
		NextCursorJSONPath: "paging.next",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, noopPolicy())
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	// Page 1: valid cursor "tok1" — succeeds with more=true.
	_, more, err := reader.Next(context.Background())
	if err != nil {
		t.Fatalf("first Next: unexpected error: %v", err)
	}
	if !more {
		t.Fatal("first Next: expected more=true, got false")
	}

	// Page 2: object cursor — must return ErrInvalidResponse.
	_, more, err = reader.Next(context.Background())
	if more {
		t.Error("second Next: expected more=false, got true")
	}
	if err == nil {
		t.Fatal("second Next: expected error for object cursor, got nil")
	}
	if !errors.Is(err, ErrInvalidResponse) {
		t.Errorf("second Next: expected ErrInvalidResponse, got %v", err)
	}
}

// --- B2: preserve Engine.Do response on error ---

// TestPaginate_PreservesResponseOnEngineError verifies that when Engine.Do returns
// an error (e.g. retry-exhausted on a 503), PaginatedReader.Next returns the HTTP
// response (with StatusCode) rather than a zero CalloutResponse. This lets callers
// inspect the status code, headers, and vendor error body from page-2+ failures.
func TestPaginate_PreservesResponseOnEngineError(t *testing.T) {
	var requestCount int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestCount++
		w.Header().Set("Content-Type", "application/json")
		if requestCount == 1 {
			// Page 1: success with a valid cursor.
			w.WriteHeader(200)
			_, _ = fmt.Fprint(w, `{"data":[1],"paging":{"next":"tok1"}}`)
		} else {
			// Page 2: 503 Service Unavailable — engine will exhaust retries and
			// return this response alongside the error.
			w.WriteHeader(503)
			_, _ = fmt.Fprint(w, `{"error":"service unavailable"}`)
		}
	}))
	defer srv.Close()

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

	// MaxAttempts=1 so the 503 on page 2 exhausts immediately — no retry delay.
	policy := RetryPolicy{MaxAttempts: 1}
	spec := PageSpec{
		NextCursorJSONPath: "paging.next",
		CursorRequestParam: "after",
	}
	reader, err := engine.Paginate(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "api.read",
	}, spec, policy)
	if err != nil {
		t.Fatalf("Paginate returned unexpected error: %v", err)
	}
	defer reader.Close()

	// Page 1: success.
	_, more, err := reader.Next(context.Background())
	if err != nil {
		t.Fatalf("first Next: unexpected error: %v", err)
	}
	if !more {
		t.Fatal("first Next: expected more=true, got false")
	}

	// Page 2: Engine.Do returns (resp_503, error). Next must return resp (not {})
	// so callers can inspect StatusCode / Body for diagnostics.
	resp, more, err := reader.Next(context.Background())
	if more {
		t.Error("second Next: expected more=false on engine error, got true")
	}
	if err == nil {
		t.Fatal("second Next: expected non-nil error, got nil")
	}
	// The response must carry the 503 status — not be a zero-value CalloutResponse.
	if resp.StatusCode != 503 {
		t.Errorf("second Next: resp.StatusCode = %d, want 503 (response must be preserved on Engine.Do error)", resp.StatusCode)
	}
}
