package apiclient

import (
	"context"
	"errors"
	"io"
	"net/http"
	"strings"
	"sync/atomic"
	"testing"
	"time"

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

func deterministicRetryConfig(delays []time.Duration, waits *[]time.Duration) *retryConfig {
	return &retryConfig{
		delays: append([]time.Duration(nil), delays...),
		now:    func() time.Time { return time.Unix(1_700_000_000, 0) },
		jitter: func(max time.Duration) time.Duration { return max },
		wait: func(_ context.Context, delay time.Duration) error {
			if waits != nil {
				*waits = append(*waits, delay)
			}
			return nil
		},
	}
}

func TestReplaySafeRetryUsesBackoffBudgetAndMetric(t *testing.T) {
	var attempts atomic.Int32
	var retries atomic.Int32
	var waits []time.Duration
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			attempts.Add(1)
			return &http.Response{
				StatusCode: http.StatusServiceUnavailable,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"error":"retry"}`)),
				Request:    req,
			}, nil
		})},
		RecordAPIRetry: func() { retries.Add(1) },
		retry: deterministicRetryConfig(
			[]time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second},
			&waits,
		),
	}

	err := client.Get(context.Background(), "/status", nil)
	require.Error(t, err)
	require.Equal(t, int32(6), attempts.Load())
	require.Equal(t, int32(5), retries.Load())
	require.Equal(t, []time.Duration{
		250 * time.Millisecond,
		500 * time.Millisecond,
		time.Second,
		2 * time.Second,
		4 * time.Second,
	}, waits)
}

func TestReplaySafeRetryStatusMatrix(t *testing.T) {
	for _, status := range []int{
		http.StatusRequestTimeout,
		http.StatusTooManyRequests,
		http.StatusInternalServerError,
		http.StatusBadGateway,
		http.StatusServiceUnavailable,
		http.StatusGatewayTimeout,
	} {
		t.Run(http.StatusText(status), func(t *testing.T) {
			var attempts atomic.Int32
			client := &Client{
				ServerURL: "http://agentteam.test",
				AuthToken: "token",
				HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
					current := attempts.Add(1)
					responseStatus := http.StatusOK
					body := `{"ok":true}`
					if current == 1 {
						responseStatus = status
						body = `{"error":"retry"}`
					}
					return &http.Response{
						StatusCode: responseStatus,
						Header:     make(http.Header),
						Body:       io.NopCloser(strings.NewReader(body)),
						Request:    req,
					}, nil
				})},
				retry: deterministicRetryConfig([]time.Duration{0}, nil),
			}
			var result struct {
				OK bool `json:"ok"`
			}
			require.NoError(t, client.Get(context.Background(), "/status", &result))
			require.True(t, result.OK)
			require.Equal(t, int32(2), attempts.Load())
		})
	}

	for _, status := range []int{
		http.StatusBadRequest,
		http.StatusUnauthorized,
		http.StatusForbidden,
		http.StatusNotFound,
		http.StatusConflict,
		http.StatusUnprocessableEntity,
		http.StatusNotImplemented,
	} {
		t.Run(http.StatusText(status), func(t *testing.T) {
			var attempts atomic.Int32
			client := &Client{
				ServerURL: "http://agentteam.test",
				AuthToken: "token",
				HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
					attempts.Add(1)
					return &http.Response{
						StatusCode: status,
						Header:     make(http.Header),
						Body:       io.NopCloser(strings.NewReader(`{"error":"terminal"}`)),
						Request:    req,
					}, nil
				})},
				retry: deterministicRetryConfig([]time.Duration{0}, nil),
			}
			require.Error(t, client.Get(context.Background(), "/status", nil))
			require.Equal(t, int32(1), attempts.Load())
		})
	}
}

func TestReplaySafeDecodeErrorIsTerminalAndAtomic(t *testing.T) {
	var attempts atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			attempts.Add(1)
			return &http.Response{
				StatusCode: http.StatusOK,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"a":"attempt","b":`)),
				Request:    req,
			}, nil
		})},
		retry: deterministicRetryConfig([]time.Duration{0}, nil),
	}
	result := postDecodePayload{A: "caller"}

	err := client.Get(context.Background(), "/status", &result)
	require.Error(t, err)
	require.Equal(t, int32(1), attempts.Load())
	require.Equal(t, postDecodePayload{A: "caller"}, result)
}

func TestResolveTargetReplayReusesEncodedBody(t *testing.T) {
	var attempts atomic.Int32
	var firstBody string
	client := &Client{
		ServerURL:        "http://agentteam.test",
		RouteWorkspaceID: "wsp_1",
		AgentID:          "agt_1",
		AuthToken:        "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			body, err := io.ReadAll(req.Body)
			require.NoError(t, err)
			current := attempts.Add(1)
			if current == 1 {
				firstBody = string(body)
				return &http.Response{
					StatusCode: http.StatusBadGateway,
					Header:     make(http.Header),
					Body:       io.NopCloser(strings.NewReader(`{"error":"retry"}`)),
					Request:    req,
				}, nil
			}
			require.Equal(t, firstBody, string(body))
			return &http.Response{
				StatusCode: http.StatusOK,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"scopeId":"chn_1"}`)),
				Request:    req,
			}, nil
		})},
		TargetResolver: NewTargetResolver(),
		retry:          deterministicRetryConfig([]time.Duration{0}, nil),
	}

	resolved, err := client.ResolveTarget(
		context.Background(),
		"#ops",
		ResolvePurposeWritable,
	)
	require.NoError(t, err)
	require.Equal(t, "chn_1", resolved.ScopeID)
	require.Equal(t, int32(2), attempts.Load())
}

func TestReplaySafeAuthRefreshAndTransientRetriesShareAttemptCap(t *testing.T) {
	var attempts atomic.Int32
	var retries atomic.Int32
	var refreshes atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "stale",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			current := attempts.Add(1)
			status := http.StatusServiceUnavailable
			body := `{"error":"retry"}`
			switch current {
			case 1:
				require.Equal(t, "Bearer stale", req.Header.Get("Authorization"))
				status = http.StatusUnauthorized
				body = `{"error":"stale"}`
			default:
				require.Equal(t, "Bearer fresh", req.Header.Get("Authorization"))
			}
			return &http.Response{
				StatusCode: status,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(body)),
				Request:    req,
			}, nil
		})},
		RecordAPIRetry: func() { retries.Add(1) },
		RefreshAuthToken: func(_ context.Context, stale string) (string, error) {
			require.Equal(t, "stale", stale)
			refreshes.Add(1)
			return "fresh", nil
		},
		retry: deterministicRetryConfig(
			[]time.Duration{0, 0, 0, 0, 0},
			nil,
		),
	}
	var result struct {
		OK bool `json:"ok"`
	}

	err := client.Get(context.Background(), "/status", &result)
	require.Error(t, err)
	require.False(t, result.OK)
	require.Equal(t, int32(6), attempts.Load())
	require.Equal(t, int32(4), retries.Load())
	require.Equal(t, int32(1), refreshes.Load())
}

func TestReplaySafeAuthRefreshDoesNotConsumeTransientRetryBudget(t *testing.T) {
	var attempts atomic.Int32
	var retries atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "stale",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			status := http.StatusOK
			body := `{"ok":true}`
			switch attempts.Add(1) {
			case 1:
				status = http.StatusUnauthorized
				body = `{"error":"stale"}`
			case 2:
				status = http.StatusServiceUnavailable
				body = `{"error":"retry"}`
			}
			return &http.Response{
				StatusCode: status,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(body)),
				Request:    req,
			}, nil
		})},
		RecordAPIRetry: func() { retries.Add(1) },
		RefreshAuthToken: func(context.Context, string) (string, error) {
			return "fresh", nil
		},
		retry: deterministicRetryConfig([]time.Duration{0}, nil),
	}
	var result struct {
		OK bool `json:"ok"`
	}

	require.NoError(t, client.Get(context.Background(), "/status", &result))
	require.True(t, result.OK)
	require.Equal(t, int32(3), attempts.Load())
	require.Equal(t, int32(1), retries.Load())
}

func TestReplaySafeAttemptCapDoesNotRefreshWithoutReplayBudget(t *testing.T) {
	var attempts atomic.Int32
	var refreshes atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "stale",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			current := attempts.Add(1)
			status := http.StatusServiceUnavailable
			if current == maxReplaySafeAttempts {
				status = http.StatusUnauthorized
			}
			return &http.Response{
				StatusCode: status,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"error":"retry"}`)),
				Request:    req,
			}, nil
		})},
		RefreshAuthToken: func(context.Context, string) (string, error) {
			refreshes.Add(1)
			return "fresh", nil
		},
		retry: deterministicRetryConfig(
			[]time.Duration{0, 0, 0, 0, 0, 0, 0},
			nil,
		),
	}

	err := client.Get(context.Background(), "/status", nil)
	require.Error(t, err)
	require.Equal(t, int32(maxReplaySafeAttempts), attempts.Load())
	require.Zero(t, refreshes.Load())
}

func TestGetAttachmentUsesReplaySafeRetry(t *testing.T) {
	var attempts atomic.Int32
	client := &Client{
		ServerURL:        "http://agentteam.test",
		RouteWorkspaceID: "wsp_1",
		AuthToken:        "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			status := http.StatusServiceUnavailable
			body := `{"error":"retry"}`
			if attempts.Add(1) == 2 {
				status = http.StatusOK
				body = "payload"
			}
			return &http.Response{
				StatusCode: status,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(body)),
				Request:    req,
			}, nil
		})},
		retry: deterministicRetryConfig([]time.Duration{0}, nil),
	}

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

func TestRetryAfterParsingAndDelayFloor(t *testing.T) {
	now := time.Date(2026, time.August, 19, 13, 0, 0, 0, time.UTC)
	delay, ok := parseRetryAfter("3", now)
	require.True(t, ok)
	require.Equal(t, 3*time.Second, delay)

	delay, ok = parseRetryAfter(now.Add(7*time.Second).Format(http.TimeFormat), now)
	require.True(t, ok)
	require.Equal(t, 7*time.Second, delay)

	_, ok = parseRetryAfter("invalid", now)
	require.False(t, ok)

	_, ok = parseRetryAfter("9223372036854775807", now)
	require.False(t, ok)

	err := &APIError{StatusCode: http.StatusTooManyRequests, retryAfter: "3"}
	config := deterministicRetryConfig([]time.Duration{250 * time.Millisecond}, nil)
	require.Equal(t, 3*time.Second, config.delayFor(err, 0))
}

func TestReplaySafeRetryHonorsRetryAfterHeader(t *testing.T) {
	var attempts atomic.Int32
	var waits []time.Duration
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			status := http.StatusOK
			body := `{"ok":true}`
			header := make(http.Header)
			if attempts.Add(1) == 1 {
				status = http.StatusTooManyRequests
				body = `{"error":"slow down"}`
				header.Set("Retry-After", "3")
			}
			return &http.Response{
				StatusCode: status,
				Header:     header,
				Body:       io.NopCloser(strings.NewReader(body)),
				Request:    req,
			}, nil
		})},
		retry: deterministicRetryConfig([]time.Duration{250 * time.Millisecond}, &waits),
	}
	var result struct {
		OK bool `json:"ok"`
	}

	require.NoError(t, client.Get(context.Background(), "/status", &result))
	require.True(t, result.OK)
	require.Equal(t, []time.Duration{3 * time.Second}, waits)
}

func TestFullJitterStaysWithinConfiguredDelay(t *testing.T) {
	const maxDelay = 250 * time.Millisecond
	for range 100 {
		delay := fullJitter(maxDelay)
		require.GreaterOrEqual(t, delay, time.Duration(0))
		require.LessOrEqual(t, delay, maxDelay)
	}
}

func TestReplaySafeRetryStopsWhenContextIsCanceled(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	var attempts atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AuthToken: "token",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			attempts.Add(1)
			return nil, errors.New("network unavailable")
		})},
		retry: &retryConfig{
			delays: []time.Duration{time.Second},
			jitter: func(max time.Duration) time.Duration { return max },
			wait: func(ctx context.Context, _ time.Duration) error {
				cancel()
				<-ctx.Done()
				return ctx.Err()
			},
		},
	}

	err := client.Get(ctx, "/status", nil)
	require.ErrorIs(t, err, context.Canceled)
	require.Equal(t, int32(1), attempts.Load())
}

func TestAuthRefreshSecondRoundHealsStaleCachedToken(t *testing.T) {
	// The daemon-side refresh may return another cached token that the server
	// already rotated out. One bounded second refresh must recover the call.
	var attempts atomic.Int32
	var refreshes atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AgentID:   "agt-1",
		AuthToken: "stale-snapshot",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			status := http.StatusOK
			body := `{"ok":true}`
			switch req.Header.Get("Authorization") {
			case "Bearer stale-snapshot", "Bearer stale-cache":
				status = http.StatusUnauthorized
				body = `{"error":"unauthorized"}`
			}
			attempts.Add(1)
			return &http.Response{
				StatusCode: status,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(body)),
				Request:    req,
			}, nil
		})},
		RefreshAuthToken: func(_ context.Context, stale string) (string, error) {
			refreshes.Add(1)
			if stale == "stale-snapshot" {
				return "stale-cache", nil
			}
			return "fresh", nil
		},
	}
	var result struct {
		OK bool `json:"ok"`
	}

	err := client.Post(context.Background(), "/invoke", nil, &result)

	require.NoError(t, err)
	require.True(t, result.OK)
	require.Equal(t, int32(3), attempts.Load())
	require.Equal(t, int32(2), refreshes.Load())
}

func TestAuthRefreshRoundsAreBounded(t *testing.T) {
	var attempts atomic.Int32
	var refreshes atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AgentID:   "agt-1",
		AuthToken: "stale",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			attempts.Add(1)
			return &http.Response{
				StatusCode: http.StatusUnauthorized,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)),
				Request:    req,
			}, nil
		})},
		RefreshAuthToken: func(_ context.Context, stale string) (string, error) {
			refreshes.Add(1)
			return "fresh-" + stale, nil
		},
	}

	err := client.Post(context.Background(), "/invoke", nil, nil)

	var apiErr *APIError
	require.ErrorAs(t, err, &apiErr)
	require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode)
	require.Equal(t, int32(3), attempts.Load())
	require.Equal(t, int32(2), refreshes.Load())
}

func TestAuthRefreshStopsWhenRefreshReturnsSameToken(t *testing.T) {
	var attempts atomic.Int32
	var refreshes atomic.Int32
	client := &Client{
		ServerURL: "http://agentteam.test",
		AgentID:   "agt-1",
		AuthToken: "stale",
		HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
			attempts.Add(1)
			return &http.Response{
				StatusCode: http.StatusUnauthorized,
				Header:     make(http.Header),
				Body:       io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)),
				Request:    req,
			}, nil
		})},
		RefreshAuthToken: func(_ context.Context, _ string) (string, error) {
			refreshes.Add(1)
			return "stale", nil
		},
	}

	err := client.Post(context.Background(), "/invoke", nil, nil)

	require.Error(t, err)
	require.Equal(t, int32(1), attempts.Load())
	require.Equal(t, int32(1), refreshes.Load())
}
