package connector_callout

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
	"time"
)

func TestEngineDoRetryAfterSecondsHonored(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&count, 1)
		if n == 1 {
			w.Header().Set("Retry-After", "1")
			w.WriteHeader(429)
			return
		}
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	policy := RetryPolicy{
		MaxAttempts:          2,
		BaseDelay:            10 * time.Millisecond,
		Multiplier:           1,
		RetryableStatusCodes: []int{429},
	}

	start := time.Now()
	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, policy)
	elapsed := time.Since(start)

	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	if elapsed < 1*time.Second {
		t.Errorf("expected elapsed >= 1s (Retry-After honored), got %v", elapsed)
	}
}

func TestEngineDoRetryAfterHTTPDateHonored(t *testing.T) {

	// Use a fixed future offset large enough to survive sub-second rounding in
	// the HTTP-date format and any server-to-engine clock delta. 3 seconds gives
	// enough headroom while keeping the test fast on success.
	const waitSeconds = 3

	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&count, 1)
		if n == 1 {
			// Round up to the next full second boundary to ensure the HTTP-date
			// is strictly in the future when the engine parses it.
			retryDate := time.Now().Add(waitSeconds * time.Second).UTC().Truncate(time.Second).Format(http.TimeFormat)
			w.Header().Set("Retry-After", retryDate)
			w.WriteHeader(429)
			return
		}
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	policy := RetryPolicy{
		MaxAttempts:          2,
		BaseDelay:            10 * time.Millisecond,
		Multiplier:           1,
		RetryableStatusCodes: []int{429},
	}

	start := time.Now()
	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, policy)
	elapsed := time.Since(start)

	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	// Allow generous lower bound: date is waitSeconds in future, but engine clock
	// may observe it slightly later. At least (waitSeconds-1)s should have elapsed.
	lowerBound := time.Duration(waitSeconds-1) * time.Second
	if elapsed < lowerBound {
		t.Errorf("expected elapsed >= %v (HTTP-date Retry-After honored), got %v", lowerBound, elapsed)
	}
}

func TestEngineDoMaxAttemptsExceeded(t *testing.T) {

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(429)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	policy := RetryPolicy{
		MaxAttempts:          3,
		BaseDelay:            1 * time.Millisecond,
		Multiplier:           1,
		RetryableStatusCodes: []int{429},
	}

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, policy)
	if !errors.Is(err, ErrMaxAttemptsExceeded) {
		t.Fatalf("expected ErrMaxAttemptsExceeded, got %v", err)
	}
}

func TestEngineDoNonRetryable400(t *testing.T) {

	var callCount int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&callCount, 1)
		w.WriteHeader(400)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	policy := RetryPolicy{
		MaxAttempts:          3,
		BaseDelay:            1 * time.Millisecond,
		Multiplier:           1,
		RetryableStatusCodes: []int{429, 500, 502, 503, 504},
	}

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, policy)
	if !errors.Is(err, ErrTerminalNonRetryable) {
		t.Fatalf("expected ErrTerminalNonRetryable, got %v", err)
	}
	if n := atomic.LoadInt32(&callCount); n != 1 {
		t.Errorf("expected exactly 1 call (no retry), got %d", n)
	}
}

func TestEngineDoRetryable503Backoff(t *testing.T) {

	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&count, 1)
		if n < 3 {
			w.WriteHeader(503)
			return
		}
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	policy := RetryPolicy{
		MaxAttempts:          3,
		BaseDelay:            20 * time.Millisecond,
		Multiplier:           2,
		RetryableStatusCodes: []int{503},
	}

	start := time.Now()
	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, policy)
	elapsed := time.Since(start)

	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	// First retry delay = 20ms, second retry delay = 40ms → ≥ 60ms total
	if elapsed < 50*time.Millisecond {
		t.Errorf("expected backoff delays, but elapsed only %v", elapsed)
	}
}

// TestEngineDoRetryAfterIgnoredOn503 proves the engine ignores Retry-After on
// non-429 retryable statuses. The issue spec says "honor Retry-After on 429
// ONLY"; an upstream sending Retry-After: 60 alongside a 503 must NOT stall
// the engine for 60 seconds. Pure exponential backoff applies instead.
func TestEngineDoRetryAfterIgnoredOn503(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&count, 1)
		if n == 1 {
			// 60-second Retry-After would stall the test if honored.
			w.Header().Set("Retry-After", "60")
			w.WriteHeader(503)
			return
		}
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	policy := RetryPolicy{
		MaxAttempts:          2,
		BaseDelay:            10 * time.Millisecond,
		Multiplier:           1,
		RetryableStatusCodes: []int{503},
	}

	start := time.Now()
	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, policy)
	elapsed := time.Since(start)

	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	// Engine must NOT honor the 60s Retry-After on 503. Allow ample headroom
	// for CI but stay well under 60s.
	if elapsed > 5*time.Second {
		t.Errorf("engine appears to have honored Retry-After on 503 (elapsed %v ≥ 5s)", elapsed)
	}
}

// TestParseRetryAfter_CappedAtAbsoluteCeiling verifies that parseRetryAfter
// never returns more than absoluteMaxRetryAfter (5 minutes), regardless of the
// header value. This closes the "Retry-After: 999999 stalls caller for 11 days"
// vulnerability when the caller uses context.Background().
func TestParseRetryAfter_CappedAtAbsoluteCeiling(t *testing.T) {
	now := time.Now()

	t.Run("large integer seconds clamped", func(t *testing.T) {
		got := parseRetryAfter("999999", now)
		if got > absoluteMaxRetryAfter {
			t.Errorf("parseRetryAfter(999999) = %v; want ≤ %v", got, absoluteMaxRetryAfter)
		}
		if got != absoluteMaxRetryAfter {
			t.Errorf("parseRetryAfter(999999) = %v; want exactly %v", got, absoluteMaxRetryAfter)
		}
	})

	t.Run("legitimate small seconds not clamped", func(t *testing.T) {
		got := parseRetryAfter("30", now)
		want := 30 * time.Second
		if got != want {
			t.Errorf("parseRetryAfter(30) = %v; want %v", got, want)
		}
	})

	t.Run("http-date far future clamped", func(t *testing.T) {
		// A date 24 hours in the future should be clamped to 5 minutes.
		farFuture := now.Add(24 * time.Hour).UTC().Format(http.TimeFormat)
		got := parseRetryAfter(farFuture, now)
		if got > absoluteMaxRetryAfter {
			t.Errorf("parseRetryAfter(24h future http-date) = %v; want ≤ %v", got, absoluteMaxRetryAfter)
		}
		if got != absoluteMaxRetryAfter {
			t.Errorf("parseRetryAfter(24h future http-date) = %v; want exactly %v", got, absoluteMaxRetryAfter)
		}
	})

	t.Run("http-date 2 minutes in future not clamped", func(t *testing.T) {
		twoMin := now.Add(2 * time.Minute).UTC().Truncate(time.Second)
		got := parseRetryAfter(twoMin.Format(http.TimeFormat), now.Truncate(time.Second))
		want := 2 * time.Minute
		// Allow ±2s for truncation/rounding in time formatting.
		if got < want-2*time.Second || got > want+2*time.Second {
			t.Errorf("parseRetryAfter(2min future) = %v; want ~%v (±2s)", got, want)
		}
	})
}

// TestEngineDoReleasesThrottleTokenPerAttempt verifies that the throttle token
// is released after each attempt (at closure return), not accumulated until
// Do() exits. A 5-attempt retry must never hold more than 1 token concurrently.
func TestEngineDoReleasesThrottleTokenPerAttempt(t *testing.T) {
	var (
		currentHeld   int32 // current tokens concurrently held
		maxHeld       int32 // peak concurrent held tokens
		totalReleased int32
	)

	countingThrottler := &countingThrottlerImpl{
		onThrottle: func() func() {
			current := atomic.AddInt32(&currentHeld, 1)
			for {
				old := atomic.LoadInt32(&maxHeld)
				if current <= old {
					break
				}
				if atomic.CompareAndSwapInt32(&maxHeld, old, current) {
					break
				}
			}
			return func() {
				atomic.AddInt32(&currentHeld, -1)
				atomic.AddInt32(&totalReleased, 1)
			}
		},
	}

	var attemptCount int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&attemptCount, 1)
		if n < 5 {
			w.WriteHeader(429)
			return
		}
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, countingThrottler, loopbackHTTPClient(), testAllowlistForEngine())
	policy := RetryPolicy{
		MaxAttempts:          5,
		BaseDelay:            1 * time.Millisecond,
		Multiplier:           1,
		RetryableStatusCodes: []int{429},
	}

	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, policy)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}

	// After Do() returns, all tokens must be released.
	if held := atomic.LoadInt32(&currentHeld); held != 0 {
		t.Errorf("tokens still held after Do(): %d (want 0)", held)
	}

	// Peak concurrent holds must be 1 — no attempt should stack with a prior one.
	if peak := atomic.LoadInt32(&maxHeld); peak != 1 {
		t.Errorf("max concurrent tokens held = %d; want 1 (got stacking)", peak)
	}

	// Total released must equal total throttle calls (one per attempt = 5).
	if n := atomic.LoadInt32(&attemptCount); n != 5 {
		t.Errorf("expected 5 server calls, got %d", n)
	}
	if r := atomic.LoadInt32(&totalReleased); r != 5 {
		t.Errorf("total tokens released = %d; want 5", r)
	}
}

// countingThrottlerImpl is a Throttler whose behavior is injected per-call.
type countingThrottlerImpl struct {
	onThrottle func() func() // called on each Throttle; returns the release func
}

func (c *countingThrottlerImpl) Throttle(_ context.Context, _, _ string) (time.Duration, func(), error) {
	release := c.onThrottle()
	return 0, release, nil
}
