package openwa

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

// recordTransport is a mock http.RoundTripper that records the last request and
// replies with a canned response. It is injected via WithTransport — no network,
// no global state.
type recordTransport struct {
	status  int
	body    string
	header  http.Header
	lastReq *http.Request
	lastRaw []byte
}

func (t *recordTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	if err := req.Context().Err(); err != nil {
		return nil, err
	}
	t.lastReq = req
	if req.Body != nil {
		t.lastRaw, _ = io.ReadAll(req.Body)
	}
	h := t.header
	if h == nil {
		h = http.Header{}
	}
	return &http.Response{
		StatusCode: t.status,
		Body:       io.NopCloser(strings.NewReader(t.body)),
		Header:     h,
		Request:    req,
	}, nil
}

func newTestClient(t *testing.T, rt http.RoundTripper, opts ...Option) *Client {
	t.Helper()
	all := append([]Option{WithTransport(rt)}, opts...)
	c, err := New("https://api.example.com", "owa_k1_test", all...)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	return c
}

func TestNewValidation(t *testing.T) {
	if _, err := New("", "key"); err == nil {
		t.Fatal("expected error for empty baseURL")
	}
	if _, err := New("https://x", ""); err == nil {
		t.Fatal("expected error for empty apiKey")
	}
}

func TestSendTextHitsCorrectPath(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"messageId":"m1","timestamp":123}`}
	c := newTestClient(t, rt)

	res, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{
		ChatID: "628123@c.us",
		Text:   "hi",
	})
	if err != nil {
		t.Fatalf("SendText: %v", err)
	}
	if res.MessageID != "m1" || res.Timestamp != 123 {
		t.Fatalf("unexpected response: %+v", res)
	}

	// The historically-broken path was /messages/text; the real one is send-text.
	wantPath := "/api/sessions/s1/messages/send-text"
	if got := rt.lastReq.URL.Path; got != wantPath {
		t.Fatalf("path = %q, want %q", got, wantPath)
	}
	if rt.lastReq.Method != "POST" {
		t.Fatalf("method = %q, want POST", rt.lastReq.Method)
	}
	if got := rt.lastReq.Header.Get("X-API-Key"); got != "owa_k1_test" {
		t.Fatalf("X-API-Key = %q", got)
	}

	var sent SendTextRequest
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	if sent.ChatID != "628123@c.us" || sent.Text != "hi" {
		t.Fatalf("sent body = %+v", sent)
	}
}

func TestJIDPathIsReadable(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{}`}
	c := newTestClient(t, rt)

	_, _ = c.Contacts.Check(context.Background(), "s1", "628999@c.us")
	// @ stays readable, not percent-encoded.
	want := "/api/sessions/s1/contacts/check/628999@c.us"
	if got := rt.lastReq.URL.EscapedPath(); got != want {
		t.Fatalf("escaped path = %q, want %q", got, want)
	}
}

func TestEmptyAndDotSegmentsRefused(t *testing.T) {
	var hits int32
	rt := RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
		atomic.AddInt32(&hits, 1)
		return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}, Request: req}, nil
	})
	c := newTestClient(t, rt)
	ctx := context.Background()

	refused := map[string]error{
		`Webhooks.Delete ".."`: c.Webhooks.Delete(ctx, "s1", ".."),
		`Webhooks.Delete "."`:  c.Webhooks.Delete(ctx, "s1", "."),
		`Webhooks.Delete ""`:   c.Webhooks.Delete(ctx, "s1", ""),
		`Do %2E%2e`:            c.Do(ctx, "DELETE", "/api/sessions/s1/labels/%2E%2e", nil, nil, nil),
		`Do ..`:                c.Do(ctx, "GET", "/api/sessions/s1/..?x=1", nil, nil, nil),
	}
	_, err := c.Sessions.Get(ctx, "")
	refused[`Sessions.Get ""`] = err
	_, err = c.Messages.Media(ctx, "s1", "..", "m1")
	refused[`Messages.Media ".."`] = err
	_, err = c.Status.Media(ctx, "s1", ".")
	refused[`Status.Media "."`] = err
	for name, err := range refused {
		if err == nil || !strings.Contains(err.Error(), "path segment") {
			t.Errorf("%s: err = %v, want a path segment error", name, err)
		}
	}
	if n := atomic.LoadInt32(&hits); n != 0 {
		t.Fatalf("%d requests sent, want 0", n)
	}

	// Dots inside an id, a dot-only query value, and a hand-written trailing or
	// double slash are not refused.
	for _, id := range []string{"a.b", "...", "628123@c.us"} {
		if err := c.Webhooks.Delete(ctx, "s1", id); err != nil {
			t.Errorf("Webhooks.Delete %q: %v", id, err)
		}
	}
	for _, path := range []string{"/api/sessions/", "/api/sessions//x", "/", "/api/labels/a.b?x=/.."} {
		if err := c.Do(ctx, "GET", path, nil, nil, nil); err != nil {
			t.Errorf("Do %q: %v", path, err)
		}
	}
	if n := atomic.LoadInt32(&hits); n != 7 {
		t.Fatalf("%d requests sent, want 7", n)
	}
}

func TestListSessionsQueryName(t *testing.T) {
	rt := &recordTransport{status: 200, body: `[]`}
	c := newTestClient(t, rt)

	if _, err := c.Sessions.List(context.Background(), &ListSessionsQuery{Name: Ptr("my-bot")}); err != nil {
		t.Fatalf("List: %v", err)
	}
	if got := rt.lastReq.URL.RawQuery; got != "name=my-bot" {
		t.Fatalf("query = %q, want %q", got, "name=my-bot")
	}
}

func TestQueryEncoding(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"messages":[],"total":0,"unknownTimestampTotal":2}`}
	c := newTestClient(t, rt)

	page, err := c.Messages.List(context.Background(), "s1", &ListMessagesQuery{
		ChatID: Ptr("628@c.us"),
		Limit:  Ptr(10),
		Since:  Ptr(1789855200000.5), Until: Ptr(1789941600000.0),
		Direction: Ptr("incoming"), OrderBy: Ptr("timestamp"), Type: Ptr("image"), MessageID: Ptr("M1"),
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	q := rt.lastReq.URL.Query()
	if q.Get("chatId") != "628@c.us" || q.Get("limit") != "10" {
		t.Fatalf("query = %v", rt.lastReq.URL.RawQuery)
	}
	if _, ok := q["offset"]; ok {
		t.Fatal("nil offset should not appear in query")
	}
	for key, expected := range map[string]string{"since": "1789855200000.5", "until": "1789941600000", "direction": "incoming", "orderBy": "timestamp", "type": "image", "messageId": "M1"} {
		if q.Get(key) != expected {
			t.Fatalf("%s = %q, want %q", key, q.Get(key), expected)
		}
	}
	if page.UnknownTimestampTotal == nil || *page.UnknownTimestampTotal != 2 {
		t.Fatalf("unknown time count = %v", page.UnknownTimestampTotal)
	}
}

func TestNilQueryOmitted(t *testing.T) {
	rt := &recordTransport{status: 200, body: `[]`}
	c := newTestClient(t, rt)

	// Typed-nil pointer must not panic and must send no query string.
	var q *ListContactsQuery
	if _, err := c.Contacts.List(context.Background(), "s1", q); err != nil {
		t.Fatalf("List: %v", err)
	}
	if rt.lastReq.URL.RawQuery != "" {
		t.Fatalf("expected empty query, got %q", rt.lastReq.URL.RawQuery)
	}
}

func TestTypedErrors(t *testing.T) {
	rt := &recordTransport{
		status: 409,
		body:   `{"statusCode":409,"message":"engine not ready","error":"Conflict"}`,
	}
	c := newTestClient(t, rt)

	_, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{ChatID: "x", Text: "y"})
	if err == nil {
		t.Fatal("expected error")
	}
	if !errors.Is(err, ErrConflict) {
		t.Fatalf("errors.Is ErrConflict = false for %v", err)
	}
	var apiErr *APIError
	if !errors.As(err, &apiErr) {
		t.Fatalf("errors.As *APIError failed for %v", err)
	}
	if apiErr.StatusCode != 409 || apiErr.Kind != "Conflict" || apiErr.Message != "engine not ready" {
		t.Fatalf("APIError = %+v", apiErr)
	}
}

func TestAPIErrorCodeRetryAfterAndHeader(t *testing.T) {
	fail := func(status int, body string, header http.Header) *APIError {
		t.Helper()
		c := newTestClient(t, &recordTransport{status: status, body: body, header: header})
		_, err := c.Sessions.List(context.Background(), nil)
		var apiErr *APIError
		if !errors.As(err, &apiErr) {
			t.Fatalf("errors.As *APIError failed for %v", err)
		}
		return apiErr
	}

	throttled := fail(429, `{"statusCode":429,"message":"ThrottlerException: Too Many Requests"}`, http.Header{"Retry-After": {"7"}})
	if !errors.Is(throttled, ErrRateLimited) || throttled.RetryAfter != 7*time.Second || throttled.Code != "" {
		t.Fatalf("throttled = %+v", throttled)
	}
	if throttled.Header.Get("Retry-After") != "7" {
		t.Fatalf("Header = %v", throttled.Header)
	}

	// Send pacing puts its wait in the body; a header must not shorten it.
	pacing := `{"statusCode":429,"error":"Too Many Requests","message":"Daily send cap reached","code":"SEND_PACING_LIMITED","retryAfterSeconds":34521}`
	for _, h := range []http.Header{nil, {"Retry-After": {"1"}}} {
		e := fail(429, pacing, h)
		if e.Code != "SEND_PACING_LIMITED" || e.RetryAfter != 34521*time.Second {
			t.Fatalf("pacing with header %v = %+v", h, e)
		}
	}

	dated := fail(503, "", http.Header{"Retry-After": {time.Now().Add(2 * time.Second).UTC().Format(http.TimeFormat)}})
	if dated.RetryAfter <= 0 || dated.RetryAfter > 3*time.Second {
		t.Fatalf("HTTP-date RetryAfter = %v", dated.RetryAfter)
	}
	for _, v := range []string{"soon", "-5"} {
		if e := fail(503, "", http.Header{"Retry-After": {v}}); e.RetryAfter != 0 {
			t.Fatalf("Retry-After %q gave %v", v, e.RetryAfter)
		}
	}

	if e := fail(502, `{"statusCode":502,"message":"x","code":"SESSION_LOGOUT_INCOMPLETE"}`, nil); e.Code != "SESSION_LOGOUT_INCOMPLETE" {
		t.Fatalf("Code = %q", e.Code)
	}
	if e := fail(500, "oops", nil); e.Code != "" || e.RetryAfter != 0 {
		t.Fatalf("plain-text error = %+v", e)
	}
}

func TestArrayMessageError(t *testing.T) {
	rt := &recordTransport{
		status: 400,
		body:   `{"statusCode":400,"message":["chatId must be a string","text should not be empty"],"error":"Bad Request"}`,
	}
	c := newTestClient(t, rt)

	_, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{})
	var apiErr *APIError
	if !errors.As(err, &apiErr) {
		t.Fatalf("expected APIError, got %v", err)
	}
	if !strings.Contains(apiErr.Message, "chatId must be a string, text should not be empty") {
		t.Fatalf("message = %q", apiErr.Message)
	}
}

func TestRedirectNotFollowed(t *testing.T) {
	rt := &recordTransport{status: 302, body: "", header: http.Header{"Location": {"https://evil.example"}}}
	c := newTestClient(t, rt)
	_, err := c.Sessions.List(context.Background(), nil)
	var apiErr *APIError
	if !errors.As(err, &apiErr) || apiErr.StatusCode != 302 {
		t.Fatalf("expected 302 APIError, got %v", err)
	}
}

func TestDeleteReturnsNoBody(t *testing.T) {
	rt := &recordTransport{status: 204, body: ""}
	c := newTestClient(t, rt)
	if err := c.Sessions.Delete(context.Background(), "s1"); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	if rt.lastReq.Method != "DELETE" {
		t.Fatalf("method = %q", rt.lastReq.Method)
	}
}

// retryTransport fails the first N calls with a 503, then succeeds. It rewinds
// and asserts the body is present on every attempt.
type retryTransport struct {
	failuresLeft int32
	calls        int32
	gotBodies    []string
}

func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	atomic.AddInt32(&t.calls, 1)
	var b []byte
	if req.Body != nil {
		b, _ = io.ReadAll(req.Body)
	}
	t.gotBodies = append(t.gotBodies, string(b))
	if atomic.AddInt32(&t.failuresLeft, -1) >= 0 {
		return &http.Response{StatusCode: 503, Body: io.NopCloser(bytes.NewReader(nil)), Header: http.Header{}, Request: req}, nil
	}
	return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"messageId":"ok","timestamp":1}`)), Header: http.Header{}, Request: req}, nil
}

func TestRetryPolicy(t *testing.T) {
	rt := &retryTransport{failuresLeft: 2}
	c := newTestClient(t, rt, WithRetry(RetryPolicy{
		MaxRetries: 3,
		BaseDelay:  time.Millisecond,
		MaxDelay:   5 * time.Millisecond,
	}))

	res, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{ChatID: "x", Text: "y"})
	if err != nil {
		t.Fatalf("SendText with retry: %v", err)
	}
	if res.MessageID != "ok" {
		t.Fatalf("res = %+v", res)
	}
	if rt.calls != 3 {
		t.Fatalf("expected 3 attempts, got %d", rt.calls)
	}
	// Body must be re-sent (rewound) on every attempt.
	for i, b := range rt.gotBodies {
		if !strings.Contains(b, `"chatId":"x"`) {
			t.Fatalf("attempt %d had empty/rewound-broken body: %q", i, b)
		}
	}
}

// statusTransport always replies with the same status, counting attempts.
type statusTransport struct {
	status int
	calls  int32
}

func (t *statusTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	atomic.AddInt32(&t.calls, 1)
	if req.Body != nil {
		io.Copy(io.Discard, req.Body)
	}
	return &http.Response{
		StatusCode: t.status,
		Body:       io.NopCloser(strings.NewReader(`{"statusCode":500,"message":"boom","error":"Internal"}`)),
		Header:     http.Header{},
		Request:    req,
	}, nil
}

// A 500/502/504 can be returned after the gateway already sent the message, so
// replaying a POST on those would double-send. Only 429/503 — which prove the
// server declined before acting — may retry a non-idempotent request.
func TestRetryDoesNotReplayPostOnAmbiguousStatus(t *testing.T) {
	for _, status := range []int{500, 502, 504} {
		t.Run(http.StatusText(status), func(t *testing.T) {
			rt := &statusTransport{status: status}
			c := newTestClient(t, rt, WithRetry(RetryPolicy{
				MaxRetries: 3, BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond,
				RetryableStatuses: []int{429, 500, 502, 503, 504},
			}))

			_, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{ChatID: "x", Text: "y"})
			if err == nil {
				t.Fatal("expected an error")
			}
			if rt.calls != 1 {
				t.Fatalf("POST on %d must not be replayed: got %d attempts, want 1", status, rt.calls)
			}
		})
	}
}

// The same statuses ARE retried for an idempotent method — the conservative
// rule must not blunt retries where replay is safe.
func TestRetryStillReplaysIdempotentOnAmbiguousStatus(t *testing.T) {
	rt := &statusTransport{status: 500}
	c := newTestClient(t, rt, WithRetry(RetryPolicy{
		MaxRetries: 2, BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond,
		RetryableStatuses: []int{429, 500, 502, 503, 504},
	}))

	_, err := c.Sessions.List(context.Background(), nil)
	if err == nil {
		t.Fatal("expected an error")
	}
	if rt.calls != 3 {
		t.Fatalf("GET on 500 should retry: got %d attempts, want 3", rt.calls)
	}
}

// 429/503 mean the server declined before acting, so a POST may still retry.
func TestRetryReplaysPostOnBackpressure(t *testing.T) {
	for _, status := range []int{429, 503} {
		t.Run(http.StatusText(status), func(t *testing.T) {
			rt := &statusTransport{status: status}
			c := newTestClient(t, rt, WithRetry(RetryPolicy{
				MaxRetries: 2, BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond,
				RetryableStatuses: []int{429, 500, 502, 503, 504},
			}))

			_, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{ChatID: "x", Text: "y"})
			if err == nil {
				t.Fatal("expected an error")
			}
			if rt.calls != 3 {
				t.Fatalf("POST on %d is backpressure and should retry: got %d attempts, want 3", status, rt.calls)
			}
		})
	}
}

// pacingTransport replies with the gateway's send-pacing refusal: a 429 with no
// Retry-After whose body carries the real delay, which can be hours.
type pacingTransport struct{ calls int32 }

func (t *pacingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	atomic.AddInt32(&t.calls, 1)
	if req.Body != nil {
		_, _ = io.Copy(io.Discard, req.Body)
	}
	return &http.Response{
		StatusCode: 429,
		Body: io.NopCloser(strings.NewReader(
			`{"statusCode":429,"message":"Daily send cap reached","code":"SEND_PACING_LIMITED","retryAfterSeconds":34521}`)),
		Header:  http.Header{},
		Request: req,
	}, nil
}

// A send-pacing refusal must not be retried before retryAfterSeconds, so the
// policy returns it after one attempt, with the body intact for the caller.
func TestRetryDoesNotReplaySendPacingRefusal(t *testing.T) {
	rt := &pacingTransport{}
	c := newTestClient(t, rt, WithRetry(RetryPolicy{
		MaxRetries: 3, BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond,
		RetryableStatuses: []int{429, 500, 502, 503, 504}, RespectRetryAfter: true,
	}))

	_, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{ChatID: "x", Text: "y"})
	var apiErr *APIError
	if !errors.As(err, &apiErr) || !errors.Is(err, ErrRateLimited) {
		t.Fatalf("expected a 429 APIError, got %v", err)
	}
	if rt.calls != 1 {
		t.Fatalf("a SEND_PACING_LIMITED 429 must not be retried: got %d attempts, want 1", rt.calls)
	}
	body, _ := apiErr.Body.(map[string]any)
	if body["code"] != "SEND_PACING_LIMITED" || body["retryAfterSeconds"] != float64(34521) {
		t.Fatalf("expected the refusal body to reach the caller, got %#v", apiErr.Body)
	}
}

func TestMiddlewarePipeline(t *testing.T) {
	rt := &recordTransport{status: 200, body: `[]`}
	var hits int32
	mw := func(next http.RoundTripper) http.RoundTripper {
		return RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
			atomic.AddInt32(&hits, 1)
			req.Header.Set("X-Trace", "on")
			return next.RoundTrip(req)
		})
	}
	c := newTestClient(t, rt, WithMiddleware(mw))
	if _, err := c.Sessions.List(context.Background(), nil); err != nil {
		t.Fatalf("List: %v", err)
	}
	if hits != 1 {
		t.Fatalf("middleware hits = %d, want 1", hits)
	}
	if rt.lastReq.Header.Get("X-Trace") != "on" {
		t.Fatal("middleware header not propagated")
	}
}

// retryAfterTransport replies once with 429 + Retry-After, then success. It
// records the delay between the two calls so a test can assert Retry-After was
// honored.
type retryAfterTransport struct {
	calls  int32
	stamps []time.Time
	header string
}

func (t *retryAfterTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	atomic.AddInt32(&t.calls, 1)
	t.stamps = append(t.stamps, time.Now())
	if req.Body != nil {
		_, _ = io.ReadAll(req.Body)
	}
	if t.calls == 1 {
		return &http.Response{
			StatusCode: 429,
			Body:       io.NopCloser(strings.NewReader("")),
			Header:     http.Header{"Retry-After": {t.header}},
			Request:    req,
		}, nil
	}
	return &http.Response{
		StatusCode: 200,
		Body:       io.NopCloser(strings.NewReader(`{"messageId":"ok","timestamp":1}`)),
		Header:     http.Header{},
		Request:    req,
	}, nil
}

func TestRetryHonorsRetryAfter(t *testing.T) {
	rt := &retryAfterTransport{header: "1"}
	c := newTestClient(t, rt, WithRetry(RetryPolicy{
		MaxRetries:        2,
		BaseDelay:         time.Millisecond, // computed backoff = 1ms; Retry-After = 1s dominates
		MaxDelay:          10 * time.Millisecond,
		RetryableStatuses: []int{429},
		RespectRetryAfter: true,
	}))

	if _, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{ChatID: "x", Text: "y"}); err != nil {
		t.Fatalf("SendText: %v", err)
	}
	if rt.calls != 2 {
		t.Fatalf("expected 2 attempts, got %d", rt.calls)
	}
	waited := rt.stamps[1].Sub(rt.stamps[0])
	if waited < 900*time.Millisecond {
		t.Fatalf("expected to wait ~1s for Retry-After, waited %s", waited)
	}
}

// A Retry-After longer than the time left on the request cannot be honored, so
// the 429 goes back to the caller at once instead of sleeping out the timeout
// and surfacing as a *TimeoutError that hides the rate limit.
func TestRetryReturnsResponseWhenRetryAfterOutlastsDeadline(t *testing.T) {
	rt := &retryAfterTransport{header: "60"}
	c := newTestClient(t, rt, WithTimeout(500*time.Millisecond), WithRetry(DefaultRetryPolicy()))

	start := time.Now()
	_, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{ChatID: "x", Text: "y"})
	if !errors.Is(err, ErrRateLimited) {
		t.Fatalf("expected ErrRateLimited, got %v", err)
	}
	if elapsed := time.Since(start); elapsed > 250*time.Millisecond {
		t.Fatalf("expected the 429 back at once, waited %s", elapsed)
	}
	if rt.calls != 1 {
		t.Fatalf("expected 1 attempt, got %d", rt.calls)
	}
}

func TestParseRetryAfterHTTPDate(t *testing.T) {
	resp := &http.Response{Header: http.Header{"Retry-After": {time.Now().Add(2 * time.Second).UTC().Format(http.TimeFormat)}}}
	d, ok := parseRetryAfter(resp)
	if !ok {
		t.Fatal("expected HTTP-date Retry-After to parse")
	}
	if d < time.Second || d > 3*time.Second {
		t.Fatalf("unexpected duration: %s", d)
	}
}

// networkErrTransport always returns a network error, so it exercises the
// "retry on network error" branch instead of the "retry on status code" branch.
type networkErrTransport struct{ calls int32 }

func (t *networkErrTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	atomic.AddInt32(&t.calls, 1)
	return nil, errors.New("simulated network failure")
}

func TestRetrySkipsNonIdempotentOnNetworkError(t *testing.T) {
	rt := &networkErrTransport{}
	c := newTestClient(t, rt, WithRetry(RetryPolicy{
		MaxRetries: 3,
		BaseDelay:  time.Millisecond,
		MaxDelay:   time.Millisecond,
	}))

	// POST is not idempotent: a network error must NOT retry, or a message
	// could be double-sent.
	_, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{ChatID: "x", Text: "y"})
	if err == nil {
		t.Fatal("expected error")
	}
	if rt.calls != 1 {
		t.Fatalf("expected 1 attempt for POST on network error, got %d", rt.calls)
	}
}

func TestRetryIdempotentOnNetworkError(t *testing.T) {
	rt := &networkErrTransport{}
	c := newTestClient(t, rt, WithRetry(RetryPolicy{
		MaxRetries: 2,
		BaseDelay:  time.Millisecond,
		MaxDelay:   time.Millisecond,
	}))

	// GET is idempotent: a network error is safe to retry.
	_, err := c.Sessions.List(context.Background(), nil)
	if err == nil {
		t.Fatal("expected error")
	}
	if rt.calls != 3 {
		t.Fatalf("expected 3 attempts (1 + 2 retries) for GET on network error, got %d", rt.calls)
	}
}

func TestBadRequestSentinel(t *testing.T) {
	rt := &recordTransport{
		status: 400,
		body:   `{"statusCode":400,"message":"invalid","error":"Bad Request"}`,
	}
	c := newTestClient(t, rt)
	_, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{})
	if err == nil {
		t.Fatal("expected error")
	}
	if !errors.Is(err, ErrBadRequest) {
		t.Fatalf("errors.Is ErrBadRequest = false for %v", err)
	}
}

func TestStatusListEnvelope(t *testing.T) {
	rt := &recordTransport{
		status: 200,
		body:   `{"statuses":[{"id":"s","contact":{"id":"c@c.us","name":"Cat"},"type":"text","mediaUrl":"","caption":"hi"}]}`,
	}
	c := newTestClient(t, rt)

	got, err := c.Status.List(context.Background(), "s1")
	if err != nil {
		t.Fatalf("Status.List: %v", err)
	}
	if len(got) != 1 || got[0].ID != "s" || got[0].Contact.Name != "Cat" {
		t.Fatalf("unexpected decode: %+v", got)
	}
}

func TestHealthReadyDependencyDecode(t *testing.T) {
	rt := &recordTransport{
		status: 200,
		body:   `{"status":"ok","details":{"mainDatabase":{"status":"up"},"dataDatabase":{"status":"up"}}}`,
	}
	c := newTestClient(t, rt)

	res, err := c.Health.Ready(context.Background())
	if err != nil {
		t.Fatalf("Health.Ready: %v", err)
	}
	if res.Details["mainDatabase"].Status != "up" || res.Details["dataDatabase"].Status != "up" {
		t.Fatalf("unexpected details: %+v", res.Details)
	}
}

func TestWithHeaderMultiValue(t *testing.T) {
	rt := &recordTransport{status: 200, body: `[]`}
	c := newTestClient(t, rt,
		WithHeader("X-Trace", "a"),
		WithHeader("X-Trace", "b"),
	)
	if _, err := c.Sessions.List(context.Background(), nil); err != nil {
		t.Fatalf("List: %v", err)
	}
	got := rt.lastReq.Header.Values("X-Trace")
	if len(got) != 2 || got[0] != "a" || got[1] != "b" {
		t.Fatalf("X-Trace values = %v, want [a b]", got)
	}
}

func TestWithTimeoutZeroUsesDefault(t *testing.T) {
	rt := &recordTransport{status: 200, body: `[]`}
	c := newTestClient(t, rt, WithTimeout(0))
	if c.timeout != DefaultTimeout {
		t.Fatalf("timeout = %s, want %s", c.timeout, DefaultTimeout)
	}
}

func TestInjectedHTTPClientTimeoutIsHonoredUnlessWithTimeoutIsExplicit(t *testing.T) {
	injected := &http.Client{Timeout: 45 * time.Second}
	c, err := New("http://localhost:2785", "k", WithHTTPClient(injected))
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	if c.timeout != 45*time.Second {
		t.Fatalf("injected timeout = %s, want 45s", c.timeout)
	}

	c, err = New("http://localhost:2785", "k", WithHTTPClient(injected), WithTimeout(5*time.Second))
	if err != nil {
		t.Fatalf("New explicit: %v", err)
	}
	if c.timeout != 5*time.Second {
		t.Fatalf("explicit timeout = %s, want 5s", c.timeout)
	}
}

func TestBulkMediaContentOmitsChatID(t *testing.T) {
	// The bulk media block must not emit a chatId key — the outer item carries
	// it, and BulkMediaDto rejects a stray empty one via forbidNonWhitelisted.
	body := SendBulkRequest{
		Messages: []BulkMessageItem{{
			ChatID: "628@c.us",
			Type:   "image",
			Content: BulkMessageContent{
				Image: &BulkMediaContent{URL: "https://x/y.png"},
			},
		}},
	}
	raw, err := json.Marshal(body)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	if strings.Contains(string(raw), `"chatId":""`) {
		t.Fatalf("bulk media block must not emit empty chatId; got: %s", raw)
	}
}

func TestBulkAudioSerializesPTT(t *testing.T) {
	// Bulk audio with PTT must emit "ptt":true so the server sends it as a voice
	// note; the media block must not emit a "caption" key (not whitelisted by
	// BulkMediaDto — caption lives at the content level).
	body := SendBulkRequest{
		Messages: []BulkMessageItem{{
			ChatID: "628@c.us",
			Type:   "audio",
			Content: BulkMessageContent{
				Audio:   &BulkMediaContent{URL: "https://x/v.ogg", PTT: true},
				Caption: "voicenote",
			},
		}},
	}
	raw, err := json.Marshal(body)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	if !strings.Contains(string(raw), `"ptt":true`) {
		t.Fatalf("bulk audio must emit ptt:true; got: %s", raw)
	}
	audioBlock := string(raw)
	if i := strings.Index(audioBlock, `"audio":{`); i >= 0 {
		if j := strings.Index(audioBlock[i:], "}"); j >= 0 && strings.Contains(audioBlock[i:i+j], `"caption"`) {
			t.Fatalf("bulk media block must not emit caption; got: %s", raw)
		}
	}
}

// capturingLogger records what the SDK logs.
type capturingLogger struct{ msgs []string }

func (l *capturingLogger) Log(_ context.Context, _ string, msg string, _ ...any) {
	l.msgs = append(l.msgs, msg)
}

// The insecure-http warning must reach an injected logger...
func TestInsecureHTTPWarnsInjectedLogger(t *testing.T) {
	lg := &capturingLogger{}
	if _, err := New("http://wa.example.com:2785", "k", WithLogger(lg)); err != nil {
		t.Fatalf("New: %v", err)
	}
	if len(lg.msgs) != 1 || !strings.Contains(lg.msgs[0], "insecure http://") {
		t.Fatalf("expected an insecure-http warning, got %v", lg.msgs)
	}
}

// ...and must stay silent for https, localhost, or when explicitly suppressed.
func TestInsecureHTTPWarningSuppressed(t *testing.T) {
	cases := []struct {
		name string
		url  string
		opts []Option
	}{
		{"https", "https://wa.example.com", nil},
		{"localhost", "http://localhost:2785", nil},
		{"loopback ip", "http://127.0.0.1:2785", nil},
		{"explicitly allowed", "http://wa.example.com", []Option{WithInsecureHTTP()}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			lg := &capturingLogger{}
			if _, err := New(tc.url, "k", append([]Option{WithLogger(lg)}, tc.opts...)...); err != nil {
				t.Fatalf("New: %v", err)
			}
			if len(lg.msgs) != 0 {
				t.Fatalf("expected no warning, got %v", lg.msgs)
			}
		})
	}
}

// The server types status timestamps as Date, which serializes to RFC 3339.
func TestStatusTimestampsDecodeRFC3339(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"statuses":[{"id":"s1","contact":{"id":"62@c.us"},"timestamp":"2026-07-17T10:30:00.000Z","expiresAt":"2026-07-18T10:30:00.000Z"}]}`}
	c := newTestClient(t, rt)

	out, err := c.Status.List(context.Background(), "s1")
	if err != nil {
		t.Fatalf("Status.List: %v", err)
	}
	if len(out) != 1 {
		t.Fatalf("want 1 status, got %d", len(out))
	}
	got := out[0]
	if want := time.Date(2026, 7, 17, 10, 30, 0, 0, time.UTC); !got.Timestamp.Equal(want) {
		t.Fatalf("Timestamp = %v, want %v", got.Timestamp, want)
	}
	if want := time.Date(2026, 7, 18, 10, 30, 0, 0, time.UTC); !got.ExpiresAt.Equal(want) {
		t.Fatalf("ExpiresAt = %v, want %v", got.ExpiresAt, want)
	}
}

func TestContextCancel(t *testing.T) {
	rt := &recordTransport{status: 200, body: `[]`}
	c := newTestClient(t, rt)
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	_, err := c.Sessions.List(ctx, nil)
	if err == nil {
		t.Fatal("expected context cancellation error")
	}
}

// The channel routes return the engine-neutral Channel verbatim (no DTO), so the record must carry
// inviteCode/picture/verified/createdAt and must not invent pictureUrl/role (#754).
func TestChannelListDecodesWireShape(t *testing.T) {
	rt := &recordTransport{
		status: 200,
		body: `[{"id":"123@newsletter","name":"News","description":"d","inviteCode":"abc123",` +
			`"subscriberCount":7,"picture":"https://x/p.jpg","verified":true,"createdAt":1700000000}]`,
	}
	c := newTestClient(t, rt)

	got, err := c.Channels.List(context.Background(), "s1")
	if err != nil {
		t.Fatalf("Channels.List: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("want 1 channel, got %d", len(got))
	}
	ch := got[0]
	if ch.InviteCode == nil || *ch.InviteCode != "abc123" {
		t.Errorf("InviteCode: %+v", ch.InviteCode)
	}
	if ch.Picture == nil || *ch.Picture != "https://x/p.jpg" {
		t.Errorf("Picture: %+v", ch.Picture)
	}
	if ch.Verified == nil || !*ch.Verified {
		t.Errorf("Verified: %+v", ch.Verified)
	}
	if ch.CreatedAt == nil || *ch.CreatedAt != 1700000000 {
		t.Errorf("CreatedAt: %+v", ch.CreatedAt)
	}
}

// Channel messages are the live engine payload, not the persisted MessageRecord (#754).
func TestChannelMessagesDecodeEngineShape(t *testing.T) {
	rt := &recordTransport{
		status: 200,
		body:   `[{"id":"m1","body":"hi","timestamp":1700000000,"hasMedia":true,"mediaUrl":"https://x/m.jpg"}]`,
	}
	c := newTestClient(t, rt)

	got, err := c.Channels.Messages(context.Background(), "s1", "123@newsletter", nil)
	if err != nil {
		t.Fatalf("Channels.Messages: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("want 1 message, got %d", len(got))
	}
	msg := got[0]
	if msg.ID != "m1" || msg.Body != "hi" || msg.Timestamp != 1700000000 {
		t.Errorf("scalar decode: %+v", msg)
	}
	if !msg.HasMedia {
		t.Errorf("HasMedia: %+v", msg.HasMedia)
	}
	if msg.MediaURL != "https://x/m.jpg" {
		t.Errorf("MediaURL: %q", msg.MediaURL)
	}
}

func TestEditMessage(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"messageId":"m1","timestamp":1700000001}`}
	c := newTestClient(t, rt)

	res, err := c.Messages.EditMessage(context.Background(), "s1", EditMessageRequest{
		ChatID:    "628123@c.us",
		MessageID: "m1",
		Body:      "edited",
	})
	if err != nil {
		t.Fatalf("EditMessage: %v", err)
	}
	if res.MessageID != "m1" || res.Timestamp != 1700000001 {
		t.Fatalf("unexpected response: %+v", res)
	}

	wantPath := "/api/sessions/s1/messages/edit"
	if got := rt.lastReq.URL.Path; got != wantPath {
		t.Fatalf("path = %q, want %q", got, wantPath)
	}
	if rt.lastReq.Method != "POST" {
		t.Fatalf("method = %q, want POST", rt.lastReq.Method)
	}

	var sent EditMessageRequest
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	if sent.ChatID != "628123@c.us" || sent.MessageID != "m1" || sent.Body != "edited" {
		t.Fatalf("sent body = %+v", sent)
	}
}

func TestEditMessageNotFound(t *testing.T) {
	rt := &recordTransport{
		status: 404,
		body:   `{"statusCode":404,"message":"Message not found","error":"Not Found"}`,
	}
	c := newTestClient(t, rt)

	_, err := c.Messages.EditMessage(context.Background(), "s1", EditMessageRequest{
		ChatID: "x", MessageID: "nope", Body: "y",
	})
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("errors.Is ErrNotFound = false for %v", err)
	}
}

func TestJoinGroup(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"success":true,"groupId":"120363@g.us"}`}
	c := newTestClient(t, rt)

	res, err := c.Groups.JoinGroup(context.Background(), "s1", JoinGroupRequest{InviteCode: "abc123"})
	if err != nil {
		t.Fatalf("JoinGroup: %v", err)
	}
	if !res.Success || res.GroupID != "120363@g.us" {
		t.Fatalf("unexpected response: %+v", res)
	}

	var sent JoinGroupRequest
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	if sent.InviteCode != "abc123" {
		t.Fatalf("sent body = %+v", sent)
	}
}

func TestGetGroupSettings(t *testing.T) {
	// ephemeralSeconds is absent when the engine does not report it — it must
	// decode as nil, not as a zero-valued present field.
	rt := &recordTransport{status: 200, body: `{"announce":true,"locked":false}`}
	c := newTestClient(t, rt)

	got, err := c.Groups.GetGroupSettings(context.Background(), "s1", "g1")
	if err != nil {
		t.Fatalf("GetGroupSettings: %v", err)
	}
	if got.Announce == nil || !*got.Announce {
		t.Errorf("Announce: %+v", got.Announce)
	}
	if got.Locked == nil || *got.Locked {
		t.Errorf("Locked: %+v", got.Locked)
	}
	if got.EphemeralSeconds != nil {
		t.Errorf("EphemeralSeconds should be nil when absent, got %+v", *got.EphemeralSeconds)
	}
}

// An update must only touch the settings that were set — unset pointer fields
// are omitted from the body so the server leaves them alone.
func TestUpdateGroupSettingsOmitsUnsetFields(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"success":true,"message":"Group settings updated"}`}
	c := newTestClient(t, rt)

	res, err := c.Groups.UpdateGroupSettings(context.Background(), "s1", "g1", GroupSettings{Announce: Ptr(true)})
	if err != nil {
		t.Fatalf("UpdateGroupSettings: %v", err)
	}
	if !res.Success || res.Message != "Group settings updated" {
		t.Fatalf("unexpected response: %+v", res)
	}
	if rt.lastReq.Method != "PUT" {
		t.Fatalf("method = %q, want PUT", rt.lastReq.Method)
	}

	var sent map[string]any
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	if sent["announce"] != true {
		t.Fatalf("announce = %v", sent["announce"])
	}
	if _, ok := sent["locked"]; ok {
		t.Fatal("unset locked should be omitted from body")
	}
	if _, ok := sent["ephemeralSeconds"]; ok {
		t.Fatal("unset ephemeralSeconds should be omitted from body")
	}
}

// A 503 is the gateway's answer when the engine never confirmed an operation: a transport failure,
// which is worth retrying, as a 429 is. It used to have no sentinel, while the permanent 501 did.
func TestServiceUnavailableIsRetryableSentinel(t *testing.T) {
	rt := &recordTransport{
		status: 503,
		body:   `{"statusCode":503,"message":"WhatsApp did not answer in time","error":"Service Unavailable"}`,
	}
	c := newTestClient(t, rt)

	_, err := c.Groups.Get(context.Background(), "s1", "g1")
	if !errors.Is(err, ErrServiceUnavailable) {
		t.Fatalf("errors.Is ErrServiceUnavailable = false for %v", err)
	}
	// It must not also satisfy a sentinel that would mislead a caller into NOT retrying.
	if errors.Is(err, ErrNotImplemented) {
		t.Fatal("a 503 must not match ErrNotImplemented")
	}
}

// Setting ephemeralSeconds on the whatsapp-web.js engine surfaces as 501.
func TestUpdateGroupSettingsNotImplemented(t *testing.T) {
	rt := &recordTransport{
		status: 501,
		body:   `{"statusCode":501,"message":"Engine does not support ephemeral messages","error":"Not Implemented"}`,
	}
	c := newTestClient(t, rt)

	_, err := c.Groups.UpdateGroupSettings(context.Background(), "s1", "g1", GroupSettings{EphemeralSeconds: Ptr(86400)})
	if !errors.Is(err, ErrNotImplemented) {
		t.Fatalf("errors.Is ErrNotImplemented = false for %v", err)
	}
}

func TestSetProfileName(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"success":true,"message":"Profile name updated"}`}
	c := newTestClient(t, rt)

	res, err := c.Profile.SetProfileName(context.Background(), "s1", SetProfileNameRequest{Name: "Acme"})
	if err != nil {
		t.Fatalf("SetProfileName: %v", err)
	}
	if !res.Success {
		t.Fatalf("unexpected response: %+v", res)
	}

	wantPath := "/api/sessions/s1/profile/name"
	if got := rt.lastReq.URL.Path; got != wantPath {
		t.Fatalf("path = %q, want %q", got, wantPath)
	}
	var sent SetProfileNameRequest
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	if sent.Name != "Acme" {
		t.Fatalf("sent body = %+v", sent)
	}
}

// An empty status clears the about text — the field must still be sent.
func TestSetProfileStatusEmptyClears(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"success":true,"message":"Profile status updated"}`}
	c := newTestClient(t, rt)

	if _, err := c.Profile.SetProfileStatus(context.Background(), "s1", SetProfileStatusRequest{Status: ""}); err != nil {
		t.Fatalf("SetProfileStatus: %v", err)
	}
	if !strings.Contains(string(rt.lastRaw), `"status":""`) {
		t.Fatalf("empty status must be sent, body = %s", rt.lastRaw)
	}
}

// The picture body is either {url} or {base64, mimetype} — never a mix, and
// empty alternates are omitted.
func TestSetProfilePictureBody(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"success":true,"message":"Profile picture updated"}`}
	c := newTestClient(t, rt)

	if _, err := c.Profile.SetProfilePicture(context.Background(), "s1", SetProfilePictureRequest{
		Base64:   "aW1hZ2U=",
		Mimetype: "image/jpeg",
	}); err != nil {
		t.Fatalf("SetProfilePicture: %v", err)
	}

	var sent map[string]any
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	if sent["base64"] != "aW1hZ2U=" || sent["mimetype"] != "image/jpeg" {
		t.Fatalf("sent body = %v", sent)
	}
	if _, ok := sent["url"]; ok {
		t.Fatal("empty url should be omitted from body")
	}
}

func TestRejectCall(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"success":true}`}
	c := newTestClient(t, rt)

	res, err := c.Calls.RejectCall(context.Background(), "s1", "call-1")
	if err != nil {
		t.Fatalf("RejectCall: %v", err)
	}
	if !res.Success {
		t.Fatalf("unexpected response: %+v", res)
	}

	wantPath := "/api/sessions/s1/calls/call-1/reject"
	if got := rt.lastReq.URL.Path; got != wantPath {
		t.Fatalf("path = %q, want %q", got, wantPath)
	}
	if rt.lastReq.Method != "POST" {
		t.Fatalf("method = %q, want POST", rt.lastReq.Method)
	}
}

// A call that is no longer ringing surfaces as 404.
func TestRejectCallNotRinging(t *testing.T) {
	rt := &recordTransport{
		status: 404,
		body:   `{"statusCode":404,"message":"Call not found or no longer ringing","error":"Not Found"}`,
	}
	c := newTestClient(t, rt)

	_, err := c.Calls.RejectCall(context.Background(), "s1", "call-1")
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("errors.Is ErrNotFound = false for %v", err)
	}
}

// The Event* constants are the wire values of the server's WEBHOOK_EVENTS
// catalog — a typo here silently subscribes to nothing.
func TestWebhookEventWireValues(t *testing.T) {
	want := map[WebhookEvent]string{
		EventMessageReceived:      "message.received",
		EventMessageSent:          "message.sent",
		EventMessageAck:           "message.ack",
		EventMessageFailed:        "message.failed",
		EventMessageRevoked:       "message.revoked",
		EventMessageReaction:      "message.reaction",
		EventMessageEdited:        "message.edited",
		EventSessionStatus:        "session.status",
		EventSessionQR:            "session.qr",
		EventSessionAuthenticated: "session.authenticated",
		EventSessionDisconnected:  "session.disconnected",
		EventSessionReconnectLoop: "session.reconnect_loop",
		EventSessionRestriction:   "session.restriction",
		EventPresenceUpdate:       "presence.update",
		EventCallAccepted:         "call.accepted",
		EventCallRejected:         "call.rejected",
		EventCallMissed:           "call.missed",
		EventGroupJoin:            "group.join",
		EventGroupLeave:           "group.leave",
		EventGroupUpdate:          "group.update",
		EventGroupJoinRequest:     "group.join_request",
		EventCallReceived:         "call.received",
		EventStatusReceived:       "status.received",
		EventAll:                  "*",
	}
	for got, w := range want {
		if got != w {
			t.Errorf("event constant = %q, want %q", got, w)
		}
	}
}

// The group event payload decodes join/leave (participants, no changes) and
// update (changes delta, empty participants) shapes alike.
func TestGroupEventPayloadDecodes(t *testing.T) {
	join := `{"groupId":"120363@g.us","actorId":"628@c.us","participantIds":["629@c.us"],"timestamp":1700000000}`
	var j GroupEventPayload
	if err := json.Unmarshal([]byte(join), &j); err != nil {
		t.Fatalf("join payload: %v", err)
	}
	if j.GroupID != "120363@g.us" || j.ActorID == nil || *j.ActorID != "628@c.us" {
		t.Fatalf("join decode: %+v", j)
	}
	if len(j.ParticipantIDs) != 1 || j.ParticipantIDs[0] != "629@c.us" {
		t.Fatalf("join participants: %+v", j.ParticipantIDs)
	}
	if j.Changes != nil {
		t.Fatalf("join should carry no changes, got %+v", j.Changes)
	}

	update := `{"groupId":"120363@g.us","participantIds":[],"changes":{"subject":"New","locked":true},"timestamp":1700000001}`
	var u GroupEventPayload
	if err := json.Unmarshal([]byte(update), &u); err != nil {
		t.Fatalf("update payload: %v", err)
	}
	if u.Changes == nil || u.Changes.Subject == nil || *u.Changes.Subject != "New" {
		t.Fatalf("update changes: %+v", u.Changes)
	}
	if u.Changes.Locked == nil || !*u.Changes.Locked {
		t.Fatalf("update locked: %+v", u.Changes.Locked)
	}
	if u.Changes.Announce != nil || u.Changes.Description != nil {
		t.Fatalf("unchanged settings must stay nil: %+v", u.Changes)
	}
}

func TestCallReceivedPayloadDecodes(t *testing.T) {
	body := `{"callId":"call-1","from":"628@c.us","isVideo":true,"isGroup":false,"timestamp":1700000000}`
	var p CallReceivedPayload
	if err := json.Unmarshal([]byte(body), &p); err != nil {
		t.Fatalf("call payload: %v", err)
	}
	if p.CallID != "call-1" || p.From != "628@c.us" || !p.IsVideo || p.IsGroup || p.Timestamp != 1700000000 {
		t.Fatalf("call decode: %+v", p)
	}
}

// SendAudioRequest is flattened rather than embedding SendMediaRequest, because ptt is accepted on
// this route alone. The flattening is what dropped mentions here while every other send carried it,
// so this asserts the wire body rather than the call.
func TestSendAudioForwardsMentions(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"messageId":"m1","timestamp":1}`}
	c := newTestClient(t, rt)

	if _, err := c.Messages.SendAudio(context.Background(), "s1", SendAudioRequest{
		ChatID:   "g@g.us",
		URL:      "http://u",
		Mentions: []string{"628123@c.us"},
	}); err != nil {
		t.Fatalf("SendAudio: %v", err)
	}

	var sent map[string]any
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	mentions, ok := sent["mentions"].([]any)
	if !ok || len(mentions) != 1 || mentions[0] != "628123@c.us" {
		t.Fatalf("sent body = %v", sent)
	}
}

func TestSendTextForwardsMentions(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"messageId":"m1","timestamp":1}`}
	c := newTestClient(t, rt)

	if _, err := c.Messages.SendText(context.Background(), "s1", SendTextRequest{
		ChatID:   "g@g.us",
		Text:     "hi @628123",
		Mentions: []string{"628123@c.us"},
	}); err != nil {
		t.Fatalf("SendText: %v", err)
	}

	var sent map[string]any
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	mentions, ok := sent["mentions"].([]any)
	if !ok || len(mentions) != 1 || mentions[0] != "628123@c.us" {
		t.Fatalf("sent body = %v", sent)
	}
}

func TestSendPollHitsCorrectPath(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"messageId":"m2","timestamp":2}`}
	c := newTestClient(t, rt)

	res, err := c.Messages.SendPoll(context.Background(), "s1", SendPollRequest{
		ChatID:               "a@c.us",
		Name:                 "Where?",
		Options:              []string{"Park", "Beach"},
		AllowMultipleAnswers: Ptr(true),
	})
	if err != nil {
		t.Fatalf("SendPoll: %v", err)
	}
	if res.MessageID != "m2" {
		t.Fatalf("unexpected response: %+v", res)
	}

	wantPath := "/api/sessions/s1/messages/send-poll"
	if got := rt.lastReq.URL.Path; got != wantPath {
		t.Fatalf("path = %q, want %q", got, wantPath)
	}
	if rt.lastReq.Method != "POST" {
		t.Fatalf("method = %q, want POST", rt.lastReq.Method)
	}

	var sent map[string]any
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	if sent["name"] != "Where?" || sent["allowMultipleAnswers"] != true {
		t.Fatalf("sent body = %v", sent)
	}
	options, ok := sent["options"].([]any)
	if !ok || len(options) != 2 || options[0] != "Park" || options[1] != "Beach" {
		t.Fatalf("sent options = %v", sent["options"])
	}
}

func TestProfilePicturesBatchQuery(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"pictures":{"a@c.us":"http://p/a","b@c.us":null}}`}
	c := newTestClient(t, rt)

	res, err := c.Contacts.ProfilePictures(context.Background(), "s1", []string{"a@c.us", "b@c.us"})
	if err != nil {
		t.Fatalf("ProfilePictures: %v", err)
	}

	if rt.lastReq.Method != "GET" {
		t.Fatalf("method = %q, want GET", rt.lastReq.Method)
	}
	wantPath := "/api/sessions/s1/contacts/profile-pictures"
	if got := rt.lastReq.URL.Path; got != wantPath {
		t.Fatalf("path = %q, want %q", got, wantPath)
	}
	if got := rt.lastReq.URL.Query().Get("ids"); got != "a@c.us,b@c.us" {
		t.Fatalf("ids query = %q", got)
	}

	if res.Pictures["a@c.us"] == nil || *res.Pictures["a@c.us"] != "http://p/a" {
		t.Fatalf("pictures[a@c.us] = %v", res.Pictures["a@c.us"])
	}
	if url, present := res.Pictures["b@c.us"]; !present || url != nil {
		t.Fatalf("pictures[b@c.us] should decode as an explicit null, got %v (present=%v)", url, present)
	}
}

func TestStatusMediaReturnsBytes(t *testing.T) {
	rt := &recordTransport{
		status: 200,
		body:   "PNG_BYTES",
		header: http.Header{"Content-Type": []string{"image/png"}},
	}
	c := newTestClient(t, rt)

	media, err := c.Status.Media(context.Background(), "s1", "w1")
	if err != nil {
		t.Fatalf("Media: %v", err)
	}
	if string(media.Data) != "PNG_BYTES" || media.ContentType != "image/png" {
		t.Fatalf("media = %q (%q)", media.Data, media.ContentType)
	}

	wantPath := "/api/sessions/s1/status/w1/media"
	if got := rt.lastReq.URL.Path; got != wantPath {
		t.Fatalf("path = %q, want %q", got, wantPath)
	}
	if rt.lastReq.Method != "GET" {
		t.Fatalf("method = %q, want GET", rt.lastReq.Method)
	}
}

// A status with no stored media surfaces as 404.
func TestStatusMediaNotFound(t *testing.T) {
	rt := &recordTransport{
		status: 404,
		body:   `{"statusCode":404,"message":"Status media not found or expired","error":"Not Found"}`,
	}
	c := newTestClient(t, rt)

	_, err := c.Status.Media(context.Background(), "s1", "w1")
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("errors.Is ErrNotFound = false for %v", err)
	}
}

// The escape hatch mirrors the JS/Python/PHP raw-text fallback for non-JSON
// 2xx bodies: *[]byte takes the body verbatim, *string falls back to raw text,
// and a typed target still requires JSON.
func TestDoRawFallbacks(t *testing.T) {
	ctx := context.Background()

	t.Run("bytes take the body verbatim", func(t *testing.T) {
		rt := &recordTransport{status: 200, body: "PNG_BYTES", header: http.Header{"Content-Type": []string{"image/png"}}}
		c := newTestClient(t, rt)
		var raw []byte
		if err := c.Do(ctx, "GET", "/api/x", nil, nil, &raw); err != nil {
			t.Fatalf("Do: %v", err)
		}
		if string(raw) != "PNG_BYTES" {
			t.Fatalf("raw = %q", raw)
		}
	})

	t.Run("string falls back to raw text on non-JSON", func(t *testing.T) {
		rt := &recordTransport{status: 200, body: "plain text"}
		c := newTestClient(t, rt)
		var s string
		if err := c.Do(ctx, "GET", "/api/x", nil, nil, &s); err != nil {
			t.Fatalf("Do: %v", err)
		}
		if s != "plain text" {
			t.Fatalf("s = %q", s)
		}
	})

	t.Run("string still decodes a JSON string body", func(t *testing.T) {
		rt := &recordTransport{status: 200, body: `"quoted"`}
		c := newTestClient(t, rt)
		var s string
		if err := c.Do(ctx, "GET", "/api/x", nil, nil, &s); err != nil {
			t.Fatalf("Do: %v", err)
		}
		if s != "quoted" {
			t.Fatalf("s = %q", s)
		}
	})

	t.Run("typed target still requires JSON", func(t *testing.T) {
		rt := &recordTransport{status: 200, body: "plain text"}
		c := newTestClient(t, rt)
		var out MessageResponse
		if err := c.Do(ctx, "GET", "/api/x", nil, nil, &out); err == nil {
			t.Fatal("expected a decode error for a non-JSON body into a typed target")
		}
	})
}

// The filter value is polymorphic on the wire: a string for text fields, a
// string array for id/enum fields, a bool for boolean fields, plus the
// caseSensitive flag — all must serialize verbatim.
func TestWebhookFiltersPolymorphicWireShape(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"id":"w1"}`}
	c := newTestClient(t, rt)

	_, err := c.Webhooks.Create(context.Background(), "s1", CreateWebhookRequest{
		URL:    "https://example.com/hook",
		Events: []string{EventMessageReceived},
		Filters: &WebhookFilters{
			Conditions: []WebhookFilterCondition{
				{Field: "sender", Operator: "is", Value: []string{"123@c.us"}},
				{Field: "body", Operator: "contains", Value: "invoice", CaseSensitive: Ptr(true)},
				{Field: "isGroup", Operator: "is", Value: false},
			},
		},
	})
	if err != nil {
		t.Fatalf("Create: %v", err)
	}

	var sent map[string]any
	if err := json.Unmarshal(rt.lastRaw, &sent); err != nil {
		t.Fatalf("body not JSON: %v", err)
	}
	conditions := sent["filters"].(map[string]any)["conditions"].([]any)
	if len(conditions) != 3 {
		t.Fatalf("conditions = %v", conditions)
	}
	first := conditions[0].(map[string]any)
	if values, ok := first["value"].([]any); !ok || len(values) != 1 || values[0] != "123@c.us" {
		t.Fatalf("id condition value = %v", first["value"])
	}
	second := conditions[1].(map[string]any)
	if second["value"] != "invoice" || second["caseSensitive"] != true {
		t.Fatalf("text condition = %v", second)
	}
	third := conditions[2].(map[string]any)
	if third["value"] != false {
		t.Fatalf("boolean condition value = %v", third["value"])
	}
	if _, present := first["caseSensitive"]; present {
		t.Fatal("caseSensitive should be omitted when unset")
	}
}

func TestMediaConversionStatus(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"available":true}`}
	c := newTestClient(t, rt)

	res, err := c.Media.ConversionStatus(context.Background(), "s1")
	if err != nil {
		t.Fatalf("ConversionStatus: %v", err)
	}
	if !res.Available {
		t.Fatalf("unexpected response: %+v", res)
	}
	if got, want := rt.lastReq.URL.Path, "/api/sessions/s1/media/convert"; got != want {
		t.Fatalf("path = %q, want %q", got, want)
	}
	if rt.lastReq.Method != "GET" {
		t.Fatalf("method = %q, want GET", rt.lastReq.Method)
	}
}

func TestConvertVoice(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"base64":"T2dnUw==","mimetype":"audio/ogg; codecs=opus","bytes":8}`}
	c := newTestClient(t, rt)

	res, err := c.Media.ConvertVoice(context.Background(), "s1", ConvertMediaInput{Base64: "SUQz"})
	if err != nil {
		t.Fatalf("ConvertVoice: %v", err)
	}
	if res.Mimetype != "audio/ogg; codecs=opus" || res.Bytes != 8 {
		t.Fatalf("unexpected response: %+v", res)
	}
	if got, want := rt.lastReq.URL.Path, "/api/sessions/s1/media/convert/voice"; got != want {
		t.Fatalf("path = %q, want %q", got, want)
	}

	// An unset URL must be omitted rather than sent as an empty string, which the
	// server would read as a supplied-but-blank field.
	if got := string(rt.lastRaw); got != `{"base64":"SUQz"}` {
		t.Fatalf("body = %s, want {\"base64\":\"SUQz\"}", got)
	}
}

func TestConvertVideo(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"base64":"AAAA","mimetype":"video/mp4","bytes":4}`}
	c := newTestClient(t, rt)

	if _, err := c.Media.ConvertVideo(context.Background(), "s1", ConvertMediaInput{URL: "https://example.com/c.mov"}); err != nil {
		t.Fatalf("ConvertVideo: %v", err)
	}
	if got, want := rt.lastReq.URL.Path, "/api/sessions/s1/media/convert/video"; got != want {
		t.Fatalf("path = %q, want %q", got, want)
	}
	if got := string(rt.lastRaw); got != `{"url":"https://example.com/c.mov"}` {
		t.Fatalf("body = %s, want the url only", got)
	}
}

// The config route needs three states per field: absent leaves it unchanged, explicit null clears it
// to the default, a value sets it. A *int with omitempty could only ever express two — a nil pointer
// was OMITTED, so restoring unlimited reconnect attempts was unreachable through this SDK.
func TestUpdateSessionConfigEmitsThreeStates(t *testing.T) {
	cases := []struct {
		name string
		req  UpdateSessionConfigRequest
		want string
	}{
		{"absent leaves everything unchanged", UpdateSessionConfigRequest{}, `{}`},
		{"a value sets it", UpdateSessionConfigRequest{MaxReconnectAttempts: Ptr(5)}, `{"maxReconnectAttempts":5}`},
		{"clear sends explicit null", UpdateSessionConfigRequest{ClearMaxReconnectAttempts: true}, `{"maxReconnectAttempts":null}`},
		{"clear wins over a value", UpdateSessionConfigRequest{MaxReconnectAttempts: Ptr(5), ClearMaxReconnectAttempts: true}, `{"maxReconnectAttempts":null}`},
	}
	for _, c := range cases {
		b, err := json.Marshal(c.req)
		if err != nil {
			t.Fatalf("%s: %v", c.name, err)
		}
		if string(b) != c.want {
			t.Fatalf("%s: got %s, want %s", c.name, b, c.want)
		}
	}
}

// The request enums are named types, so a Go value can no longer be an arbitrary string. What the
// compiler cannot check is the value each constant CARRIES: these fields travel as their
// underlying string or number, and a typo would be refused by the server, not by the build.
func TestRequestEnumWireValues(t *testing.T) {
	wireStrings := map[string]string{
		string(CallLinkAudio):             "audio",
		string(CallLinkVideo):             "video",
		string(ProxyHTTP):                 "http",
		string(ProxyHTTPS):                "https",
		string(ProxySOCKS4):               "socks4",
		string(ProxySOCKS5):               "socks5",
		string(MembershipInviteLink):      "invite_link",
		string(MembershipLinkedGroupJoin): "linked_group_join",
		string(MembershipNonAdminAdd):     "non_admin_add",
		string(ChatStateTyping):           "typing",
		string(ChatStateRecording):        "recording",
		string(ChatStatePaused):           "paused",
	}
	for got, want := range wireStrings {
		if got != want {
			t.Errorf("wire value = %q, want %q", got, want)
		}
	}
	wireNumbers := map[int]int{
		int(PinOneDay):         86400,
		int(PinSevenDays):      604800,
		int(PinThirtyDays):     2592000,
		int(StatusFontDefault): 0,
		int(StatusFontBold):    6,
		int(StatusFontBryndan): 10,
	}
	for got, want := range wireNumbers {
		if got != want {
			t.Errorf("wire value = %d, want %d", got, want)
		}
	}
	// The named types must still marshal as bare JSON scalars, not as objects or quoted numbers.
	body, err := json.Marshal(SendChatStateRequest{ChatID: "x@c.us", State: ChatStateTyping})
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	if !strings.Contains(string(body), `"state":"typing"`) {
		t.Errorf("state did not marshal as a bare string: %s", body)
	}
	pin, err := json.Marshal(PinMessageRequest{ChatID: "x@c.us", MessageID: "m", DurationSeconds: PinOneDay})
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	if !strings.Contains(string(pin), `"durationSeconds":86400`) {
		t.Errorf("durationSeconds did not marshal as a bare number: %s", pin)
	}
}

// A Do path that does not begin with "/" would be appended to the base URL's
// authority, so "@evil.example/x" sends the API key to evil.example.
func TestDoPathWithoutLeadingSlashRefused(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{}`}
	c := newTestClient(t, rt)
	for _, path := range []string{"@evil.example/api/x", ".evil.example/api/x", "api/x", ""} {
		if err := c.Do(context.Background(), "GET", path, nil, nil, nil); err == nil {
			t.Errorf("Do %q: want an error", path)
		}
	}
	if rt.lastReq != nil {
		t.Fatalf("request sent to %s, want none", rt.lastReq.URL.Host)
	}
}

// stallServer stalls every request before answering; with flush set it sends
// the headers first and stalls the body instead. It blocks until the request is
// cancelled or the test ends.
func stallServer(t *testing.T, flush bool) *httptest.Server {
	t.Helper()
	release := make(chan struct{})
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if flush {
			w.Header().Set("Content-Type", "application/octet-stream")
			w.WriteHeader(http.StatusOK)
			_, _ = w.Write([]byte("partial"))
			w.(http.Flusher).Flush()
		}
		select {
		case <-r.Context().Done():
		case <-release:
		}
	}))
	t.Cleanup(srv.Close)
	t.Cleanup(func() { close(release) })
	return srv
}

func TestTimeoutReportsTheBudgetThatExpired(t *testing.T) {
	for _, flush := range []bool{false, true} {
		srv := stallServer(t, flush)

		// The client timeout fired: the error names it.
		c, err := New(srv.URL, "k", WithTimeout(100*time.Millisecond))
		if err != nil {
			t.Fatalf("New: %v", err)
		}
		_, err = c.Messages.Media(context.Background(), "s1", "c1", "m1")
		var te *TimeoutError
		if !errors.As(err, &te) || te.Timeout != 100*time.Millisecond {
			t.Errorf("flush=%v client timeout: err = %v, want a *TimeoutError after 100ms", flush, err)
		}

		// The caller's shorter deadline fired: the 30s client timeout is not the
		// budget that ran out, so the error does not name it.
		c, err = New(srv.URL, "k")
		if err != nil {
			t.Fatalf("New: %v", err)
		}
		ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
		_, err = c.Messages.Media(ctx, "s1", "c1", "m1")
		cancel()
		te = nil
		if !errors.As(err, &te) || te.Timeout != 0 || err.Error() != "openwa: request timed out" {
			t.Errorf("flush=%v caller deadline: err = %v, want a *TimeoutError without a duration", flush, err)
		}
	}
}

// argsLogger records every logged key-value argument as text.
type argsLogger struct{ text strings.Builder }

func (l *argsLogger) Log(_ context.Context, _ string, msg string, args ...any) {
	fmt.Fprintln(&l.text, msg, args)
}

// A base URL may carry basic-auth credentials for a fronting proxy; the
// password must not reach the request log.
func TestRequestLogRedactsBaseURLPassword(t *testing.T) {
	ok := &recordTransport{status: 200, body: `{}`}
	failing := RoundTripperFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("refused") })
	for _, rt := range []http.RoundTripper{ok, failing} {
		lg := &argsLogger{}
		c, err := New("https://proxyuser:s3cret@api.example.com", "k", WithTransport(rt), WithLogger(lg))
		if err != nil {
			t.Fatalf("New: %v", err)
		}
		_, _ = c.Health.Check(context.Background())
		if got := lg.text.String(); !strings.Contains(got, "api.example.com") || strings.Contains(got, "s3cret") {
			t.Errorf("request log = %q, want the URL without the password", got)
		}
	}

	// The retry layer logs the URL of every request it replays.
	lg := &argsLogger{}
	policy := RetryPolicy{MaxRetries: 1, BaseDelay: time.Millisecond, MaxDelay: time.Millisecond}
	c, err := New("https://proxyuser:s3cret@api.example.com", "k",
		WithTransport(&statusTransport{status: 503}), WithLogger(lg), WithRetry(policy))
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	_, _ = c.Health.Check(context.Background())
	if got := lg.text.String(); !strings.Contains(got, "openwa retrying request") || strings.Contains(got, "s3cret") {
		t.Errorf("retry log = %q, want the retry logged without the password", got)
	}
}

// A Do path that already carries a query string keeps it; the query values are appended with "&"
// rather than a second "?", which the server would read as part of the first value.
func TestDoPathWithQueryAppendsQueryValues(t *testing.T) {
	rt := &recordTransport{status: 200, body: `[]`}
	c := newTestClient(t, rt)
	if err := c.Do(context.Background(), "GET", "/api/sessions?limit=5", url.Values{"name": {"x"}}, nil, nil); err != nil {
		t.Fatal(err)
	}
	if got := rt.lastReq.URL.RawQuery; got != "limit=5&name=x" {
		t.Errorf("query = %q, want %q", got, "limit=5&name=x")
	}
}

func TestSendIdempotencyKey(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{"messageId":"m","timestamp":1}`}
	c := newTestClient(t, rt, WithHeader("idempotency-key", "default"))
	for _, segment := range []string{"send-text", "send-image", "send-video", "send-audio", "send-document", "send-sticker", "send-location", "send-contact", "send-template", "send-poll", "reply", "forward"} {
		ctx := WithIdempotencyKey(context.Background(), segment)
		if _, err := c.Messages.send(ctx, "s", segment, map[string]string{"chatId": "x"}); err != nil {
			t.Fatal(err)
		}
		if got := rt.lastReq.Header.Values("Idempotency-Key"); len(got) != 1 || got[0] != segment {
			t.Fatalf("%s: %v", segment, got)
		}
		if string(rt.lastRaw) != `{"chatId":"x"}` {
			t.Fatalf("body changed: %s", rt.lastRaw)
		}
	}
	if _, err := c.Messages.SendText(context.Background(), "s", SendTextRequest{}); err != nil {
		t.Fatal(err)
	}
	if got := rt.lastReq.Header.Get("Idempotency-Key"); got != "default" {
		t.Fatalf("default mutated: %s", got)
	}
	for _, key := range []string{"", "a b", "a\n", "é", strings.Repeat("x", 256)} {
		rt.lastReq = nil
		if _, err := c.Messages.SendText(WithIdempotencyKey(context.Background(), key), "s", SendTextRequest{}); err == nil {
			t.Fatalf("accepted %q", key)
		}
		if rt.lastReq != nil {
			t.Fatalf("transport called for %q", key)
		}
	}
}

func TestRedrivePreservesAnEmptyIDsFilter(t *testing.T) {
	rt := &recordTransport{status: 200, body: `{}`}
	c := newTestClient(t, rt)
	var zero []string
	empty := []string{}
	selected := []string{"f1"}
	for _, tc := range []struct {
		name string
		ids  *[]string
		want string
	}{
		{"omitted", nil, `{}`},
		{"nil slice", &zero, `{"ids":[]}`},
		{"empty slice", &empty, `{"ids":[]}`},
		{"selected rows", &selected, `{"ids":["f1"]}`},
	} {
		t.Run(tc.name, func(t *testing.T) {
			body := &RedriveWebhookDeliveriesRequest{IDs: tc.ids}
			if _, err := c.Webhooks.RedriveDeliveryFailures(context.Background(), body); err != nil {
				t.Fatal(err)
			}
			if string(rt.lastRaw) != tc.want {
				t.Fatalf("body = %s, want %s", rt.lastRaw, tc.want)
			}
			if body.IDs != tc.ids || zero != nil {
				t.Fatal("request IDs mutated")
			}
		})
	}
}

func TestRetryKeepsIdempotencyKey(t *testing.T) {
	var keys []string
	rt := RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
		keys = append(keys, req.Header.Get("Idempotency-Key"))
		return &http.Response{StatusCode: 503, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
	})
	c := newTestClient(t, rt, WithRetry(RetryPolicy{MaxRetries: 2, BaseDelay: time.Millisecond, MaxDelay: time.Millisecond, RetryableStatuses: []int{503}}))
	_, _ = c.Messages.SendText(WithIdempotencyKey(context.Background(), "stable"), "s", SendTextRequest{})
	if len(keys) != 3 {
		t.Fatalf("attempts: %v", keys)
	}
	for _, key := range keys {
		if key != "stable" {
			t.Fatalf("keys: %v", keys)
		}
	}
}
