package atlassian

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/aisupport/services/jsm-syncer/config"
)

func TestFindAccountIDByEmail(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/rest/api/3/user/search", r.URL.Path)
		require.Equal(t, "user@example.com", r.URL.Query().Get("query"))

		username, password, ok := r.BasicAuth()
		require.True(t, ok)
		require.Equal(t, "jira-user", username)
		require.Equal(t, "jira-token", password)

		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[
			{"accountId":"other","emailAddress":"other@example.com","active":true},
			{"accountId":"acct-1","emailAddress":"User@Example.com","active":true}
		]`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	accountID, found, err := client.FindAccountIDByEmail(context.Background(), "user@example.com")
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, "acct-1", accountID)
}

func TestFindAccountIDByEmailDoesNotUseFuzzyFallback(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[
			{"accountId":"first","emailAddress":"","active":true},
			{"accountId":"second","emailAddress":"second@example.com","active":true}
		]`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	accountID, found, err := client.FindAccountIDByEmail(context.Background(), "user@example.com")
	require.NoError(t, err)
	require.False(t, found)
	require.Empty(t, accountID)
}

func TestFindAccountIDByEmailRejectsInactiveExactMatch(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[
			{"accountId":"inactive","emailAddress":"user@example.com","active":false}
		]`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	accountID, found, err := client.FindAccountIDByEmail(context.Background(), "user@example.com")
	require.NoError(t, err)
	require.False(t, found)
	require.Empty(t, accountID)
}

func TestFindAccountIDByEmailNotFoundWhenResultsAreEmpty(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[]`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	accountID, found, err := client.FindAccountIDByEmail(context.Background(), "user@example.com")
	require.NoError(t, err)
	require.False(t, found)
	require.Empty(t, accountID)
}

func TestFindAccountIDByEmailReturnsStatusError(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = w.Write([]byte(`{"errorMessages":["invalid API token"]}`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	_, _, err := client.FindAccountIDByEmail(context.Background(), "user@example.com")
	require.EqualError(t, err, `search atlassian user failed: status 401 Unauthorized: response {"errorMessages":["invalid API token"]}`)
}

func TestAddUsersToOrganizationClassifiesOrganizationNotFound(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/rest/servicedeskapi/organization/165406/user", r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusNotFound)
		_, _ = w.Write([]byte(`{"errorMessage":"sd.customer.organisation.ids.error.not.found","i18nErrorMessage":{"i18nKey":"sd.customer.organisation.ids.error.not.found","parameters":["165406"]}}`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	err := client.AddUsersToOrganization(context.Background(), "165406", []string{"acct-1"})
	require.ErrorIs(t, err, ErrJSMOrganizationNotFound)
	require.EqualError(
		t,
		err,
		`add JSM organization user failed: status 404 Not Found: response {"errorMessage":"sd.customer.organisation.ids.error.not.found","i18nErrorMessage":{"i18nKey":"sd.customer.organisation.ids.error.not.found","parameters":["165406"]}}`,
	)
}

func TestFindAccountIDByEmailReturnsRateLimitMetadata(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Retry-After", "7")
		w.Header().Set("RateLimit-Reason", "jira-quota-global-based")
		w.WriteHeader(http.StatusTooManyRequests)
		_, _ = w.Write([]byte(`{"errorMessages":["rate limited"]}`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	_, _, err := client.FindAccountIDByEmail(context.Background(), "user@example.com")
	var limitErr *RateLimitError
	require.ErrorAs(t, err, &limitErr)
	require.Equal(t, 7*time.Second, limitErr.RetryAfter)
	require.Equal(t, "jira-quota-global-based", limitErr.Reason)
	require.EqualError(t, err, `search atlassian user failed: status 429 Too Many Requests: response {"errorMessages":["rate limited"]}`)
}

func TestFindAccountIDByEmailRetriesWithEndpointPolicy(t *testing.T) {
	t.Parallel()

	var calls atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		if calls.Add(1) == 1 {
			w.Header().Set("Content-Type", "application/json")
			w.Header().Set("Retry-After", "1")
			w.Header().Set("RateLimit-Reason", "jira-burst-based")
			w.WriteHeader(http.StatusTooManyRequests)
			_, _ = w.Write([]byte(`{"errorMessages":["rate limited"]}`))
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[{"accountId":"acct-1","emailAddress":"user@example.com","active":true}]`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
		RateLimit: config.AtlassianRateLimitConfig{
			UserSearch: config.AtlassianRequestPolicyConfig{
				RequestsPerSecond: 1_000,
				Burst:             1,
				MaxRetries:        1,
				Cooldown:          time.Millisecond,
			},
		},
	}, server.Client())
	const retryDelay = 30 * time.Millisecond
	client.userSearchRateLimit.retryDelay = func(_ int, _ time.Duration) time.Duration {
		return retryDelay
	}
	startedAt := time.Now()

	accountID, found, err := client.FindAccountIDByEmail(context.Background(), "user@example.com")
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, "acct-1", accountID)
	require.Equal(t, int32(2), calls.Load())
	require.GreaterOrEqual(t, time.Since(startedAt), retryDelay)
}

func TestGlobalRateLimitCooldownAppliesToOtherEndpoints(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[]`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())
	const cooldown = 30 * time.Millisecond
	startedAt := time.Now()
	client.globalRateLimitCooldown.Extend(cooldown)

	_, err := client.FindOrganizationsByTiDBCloudOrgID(context.Background(), "org-1")
	require.NoError(t, err)
	require.GreaterOrEqual(t, time.Since(startedAt), cooldown)
}

func TestRateLimitCooldownConfiguration(t *testing.T) {
	t.Parallel()

	t.Run("defaults", func(t *testing.T) {
		t.Parallel()

		client := newClient(config.AtlassianConfig{}, http.DefaultClient)
		require.Equal(t, time.Minute, client.globalRateLimitCooldownDuration)
		require.Equal(t, time.Minute, client.userSearchRateLimit.cooldownDuration)
		require.Equal(t, time.Minute, client.findOrganizationRateLimit.cooldownDuration)
		require.Equal(t, time.Minute, client.createOrganizationRateLimit.cooldownDuration)
		require.Equal(t, time.Minute, client.addOrganizationToServiceDeskProjectRateLimit.cooldownDuration)
		require.Equal(t, time.Minute, client.listOrganizationUsersRateLimit.cooldownDuration)
		require.Equal(t, time.Minute, client.addUsersToOrganizationRateLimit.cooldownDuration)
	})

	t.Run("custom values", func(t *testing.T) {
		t.Parallel()

		client := newClient(config.AtlassianConfig{
			RateLimit: config.AtlassianRateLimitConfig{
				Global: config.AtlassianGlobalRateLimitConfig{
					Cooldown: 2 * time.Minute,
				},
				UserSearch: config.AtlassianRequestPolicyConfig{
					Cooldown: 10 * time.Second,
				},
				FindOrganization: config.AtlassianRequestPolicyConfig{
					Cooldown: 20 * time.Second,
				},
				CreateOrganization: config.AtlassianRequestPolicyConfig{
					Cooldown: 30 * time.Second,
				},
				AddOrganizationToServiceDeskProject: config.AtlassianRequestPolicyConfig{
					Cooldown: 40 * time.Second,
				},
				ListOrganizationUsers: config.AtlassianRequestPolicyConfig{
					Cooldown: 50 * time.Second,
				},
				AddUsersToOrganization: config.AtlassianRequestPolicyConfig{
					Cooldown: 60 * time.Second,
				},
			},
		}, http.DefaultClient)

		require.Equal(t, 2*time.Minute, client.globalRateLimitCooldownDuration)
		require.Equal(t, 10*time.Second, client.userSearchRateLimit.cooldownDuration)
		require.Equal(t, 20*time.Second, client.findOrganizationRateLimit.cooldownDuration)
		require.Equal(t, 30*time.Second, client.createOrganizationRateLimit.cooldownDuration)
		require.Equal(t, 40*time.Second, client.addOrganizationToServiceDeskProjectRateLimit.cooldownDuration)
		require.Equal(t, 50*time.Second, client.listOrganizationUsersRateLimit.cooldownDuration)
		require.Equal(t, 60*time.Second, client.addUsersToOrganizationRateLimit.cooldownDuration)
	})
}

func TestGlobalRateLimitUsesDefaultCooldownWithoutRetryAfter(t *testing.T) {
	t.Parallel()

	client := newClient(config.AtlassianConfig{}, http.DefaultClient)
	resp := &http.Response{
		Status:     "429 Too Many Requests",
		StatusCode: http.StatusTooManyRequests,
		Header:     make(http.Header),
		Body:       http.NoBody,
	}
	resp.Header.Set("RateLimit-Reason", rateLimitReasonGlobalQuota)

	earliestCooldownUntil := time.Now().Add(time.Minute)
	err := client.statusError("test global rate limit", resp)
	latestCooldownUntil := time.Now().Add(time.Minute)
	var limitErr *RateLimitError
	require.ErrorAs(t, err, &limitErr)

	client.globalRateLimitCooldown.mu.Lock()
	cooldownUntil := client.globalRateLimitCooldown.until
	client.globalRateLimitCooldown.mu.Unlock()
	require.False(t, cooldownUntil.Before(earliestCooldownUntil))
	require.False(t, cooldownUntil.After(latestCooldownUntil))
}

func TestEndpointPoliciesRetryRateLimitedRequests(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name      string
		handler   func(*testing.T, *atomic.Int32, http.ResponseWriter, *http.Request)
		policy    func(*client) *requestRateLimitPolicy
		invoke    func(context.Context, *client) error
		wantCalls int32
	}{
		{
			name: "find organization",
			handler: func(t *testing.T, calls *atomic.Int32, w http.ResponseWriter, r *http.Request) {
				require.Equal(t, "/rest/servicedesk/1/organisations/search", r.URL.Path)
				if calls.Add(1) == 1 {
					writeBurstRateLimitResponse(w)
					return
				}
				w.Header().Set("Content-Type", "application/json")
				_, _ = w.Write([]byte(`[{"id":42,"name":"Acme (org-1)"}]`))
			},
			policy: func(client *client) *requestRateLimitPolicy {
				return client.findOrganizationRateLimit
			},
			invoke: func(ctx context.Context, client *client) error {
				_, _, err := client.FindOrganizationByTiDBCloudOrgID(ctx, "org-1")
				return err
			},
			wantCalls: 2,
		},
		{
			name: "create organization",
			handler: func(t *testing.T, calls *atomic.Int32, w http.ResponseWriter, r *http.Request) {
				require.Equal(t, http.MethodPost, r.Method)
				require.Equal(t, "/rest/servicedeskapi/organization", r.URL.Path)
				if calls.Add(1) == 1 {
					writeBurstRateLimitResponse(w)
					return
				}
				w.Header().Set("Content-Type", "application/json")
				_, _ = w.Write([]byte(`{"id":"42","name":"Acme"}`))
			},
			policy: func(client *client) *requestRateLimitPolicy {
				return client.createOrganizationRateLimit
			},
			invoke: func(ctx context.Context, client *client) error {
				_, err := client.CreateOrganization(ctx, "Acme")
				return err
			},
			wantCalls: 2,
		},
		{
			name: "add organization to service desk project",
			handler: func(t *testing.T, calls *atomic.Int32, w http.ResponseWriter, r *http.Request) {
				switch r.URL.Path {
				case "/rest/servicedeskapi/servicedesk":
					w.Header().Set("Content-Type", "application/json")
					_, _ = w.Write([]byte(`{
						"start": 0,
						"limit": 100,
						"size": 1,
						"isLastPage": true,
						"values": [{"id":"7","projectKey":"TCID"}]
					}`))
				case "/rest/servicedeskapi/servicedesk/7/organization":
					if calls.Add(1) == 1 {
						writeBurstRateLimitResponse(w)
						return
					}
					w.WriteHeader(http.StatusNoContent)
				default:
					t.Fatalf("unexpected path: %s", r.URL.Path)
				}
			},
			policy: func(client *client) *requestRateLimitPolicy {
				return client.addOrganizationToServiceDeskProjectRateLimit
			},
			invoke: func(ctx context.Context, client *client) error {
				return client.AddOrganizationToServiceDeskProject(ctx, "TCID", "42")
			},
			wantCalls: 2,
		},
		{
			name: "list organization users",
			handler: func(t *testing.T, calls *atomic.Int32, w http.ResponseWriter, r *http.Request) {
				require.Equal(t, http.MethodGet, r.Method)
				require.Equal(t, "/rest/servicedeskapi/organization/42/user", r.URL.Path)
				if calls.Add(1) == 1 {
					writeBurstRateLimitResponse(w)
					return
				}
				w.Header().Set("Content-Type", "application/json")
				_, _ = w.Write([]byte(`{"start":0,"limit":100,"size":0,"isLastPage":true,"values":[]}`))
			},
			policy: func(client *client) *requestRateLimitPolicy {
				return client.listOrganizationUsersRateLimit
			},
			invoke: func(ctx context.Context, client *client) error {
				_, err := client.ListOrganizationUserAccountIDs(ctx, "42")
				return err
			},
			wantCalls: 2,
		},
		{
			name: "add users to organization",
			handler: func(t *testing.T, calls *atomic.Int32, w http.ResponseWriter, r *http.Request) {
				require.Equal(t, http.MethodPost, r.Method)
				require.Equal(t, "/rest/servicedeskapi/organization/42/user", r.URL.Path)
				if calls.Add(1) == 1 {
					writeBurstRateLimitResponse(w)
					return
				}
				w.WriteHeader(http.StatusNoContent)
			},
			policy: func(client *client) *requestRateLimitPolicy {
				return client.addUsersToOrganizationRateLimit
			},
			invoke: func(ctx context.Context, client *client) error {
				return client.AddUsersToOrganization(ctx, "42", []string{"acct-1"})
			},
			wantCalls: 2,
		},
	}

	for _, test := range tests {
		test := test
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()

			var calls atomic.Int32
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				test.handler(t, &calls, w, r)
			}))
			defer server.Close()

			client := newClient(config.AtlassianConfig{
				BaseURL:  server.URL,
				Username: "jira-user",
				APIToken: "jira-token",
				RateLimit: config.AtlassianRateLimitConfig{
					FindOrganization:                    fastRequestPolicyConfig(),
					CreateOrganization:                  fastRequestPolicyConfig(),
					AddOrganizationToServiceDeskProject: fastRequestPolicyConfig(),
					ListOrganizationUsers:               fastRequestPolicyConfig(),
					AddUsersToOrganization:              fastRequestPolicyConfig(),
				},
			}, server.Client())
			test.policy(client).retryDelay = func(_ int, _ time.Duration) time.Duration {
				return 2 * time.Millisecond
			}

			require.NoError(t, test.invoke(context.Background(), client))
			require.Equal(t, test.wantCalls, calls.Load())
		})
	}
}

func TestSharedCooldownUsesLatestRetryTime(t *testing.T) {
	t.Parallel()

	var cooldown sharedCooldown
	cooldown.Extend(20 * time.Millisecond)
	cooldown.Extend(60 * time.Millisecond)
	startedAt := time.Now()

	require.NoError(t, cooldown.Wait(context.Background()))
	require.GreaterOrEqual(t, time.Since(startedAt), 40*time.Millisecond)
}

func TestSharedCooldownWaitStopsWhenContextIsCanceled(t *testing.T) {
	t.Parallel()

	var cooldown sharedCooldown
	cooldown.Extend(time.Second)
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	require.ErrorIs(t, cooldown.Wait(ctx), context.Canceled)
}

func TestParseRetryAfterHTTPDate(t *testing.T) {
	t.Parallel()

	now := time.Date(2026, time.July, 15, 12, 0, 0, 0, time.UTC)
	retryAt := now.Add(15 * time.Second)
	require.Equal(t, 15*time.Second, parseRetryAfter(retryAt.Format(http.TimeFormat), now))
}

func TestFindOrganizationByTiDBCloudOrgIDOnlySearchesFirstPage(t *testing.T) {
	t.Parallel()

	var calls atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "/rest/servicedesk/1/organisations/search", r.URL.Path)
		require.Equal(t, "org-1", r.URL.Query().Get("query"))
		require.Equal(t, "0", r.URL.Query().Get("start"))
		require.Equal(t, "50", r.URL.Query().Get("limit"))
		w.Header().Set("Content-Type", "application/json")
		results := make([]organizationSearchResult, 50)
		for i := range results {
			results[i] = organizationSearchResult{ID: int64(i + 100), Name: "Fuzzy Result"}
		}
		results[49] = organizationSearchResult{ID: 42, Name: "Old Customer Name (org-1)"}
		require.NoError(t, json.NewEncoder(w).Encode(results))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	organizationID, found, err := client.FindOrganizationByTiDBCloudOrgID(
		context.Background(),
		" org-1 ",
	)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, "42", organizationID)
	require.Equal(t, int32(1), calls.Load())
}

func TestFindOrganizationByTiDBCloudOrgIDNotFound(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "/rest/servicedesk/1/organisations/search", r.URL.Path)
		require.Equal(t, "org-1", r.URL.Query().Get("query"))
		require.Equal(t, "0", r.URL.Query().Get("start"))
		require.Equal(t, "50", r.URL.Query().Get("limit"))
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[]`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	organizationID, found, err := client.FindOrganizationByTiDBCloudOrgID(context.Background(), "org-1")
	require.NoError(t, err)
	require.False(t, found)
	require.Empty(t, organizationID)
}

func TestFindOrganizationByTiDBCloudOrgIDReturnsSmallestIDForDuplicates(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`[
			{"id":159808,"name":"Data's Org (1372813089209250455)","numberOfUsers":15},
			{"id":55986,"name":"Lucy's Org (1372813089209250455)","numberOfUsers":17}
		]`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	organizationID, found, err := client.FindOrganizationByTiDBCloudOrgID(
		context.Background(),
		"1372813089209250455",
	)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, "55986", organizationID)
}

func TestListOrganizationUserAccountIDsPaginates(t *testing.T) {
	t.Parallel()

	var calls atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		call := calls.Add(1)
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "/rest/servicedeskapi/organization/42/user", r.URL.Path)
		require.Equal(t, "100", r.URL.Query().Get("limit"))
		w.Header().Set("Content-Type", "application/json")
		if call == 1 {
			require.Equal(t, "0", r.URL.Query().Get("start"))
			_, _ = w.Write([]byte(`{
				"start":0,"limit":2,"size":2,"isLastPage":false,
				"values":[{"accountId":"acct-1"},{"accountId":" acct-2 "}]
			}`))
			return
		}
		require.Equal(t, int32(2), call)
		require.Equal(t, "2", r.URL.Query().Get("start"))
		_, _ = w.Write([]byte(`{
			"start":2,"limit":2,"size":2,"isLastPage":true,
			"values":[{"accountId":"acct-2"},{"accountId":"acct-3"}]
		}`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	accountIDs, err := client.ListOrganizationUserAccountIDs(context.Background(), " 42 ")
	require.NoError(t, err)
	require.Equal(t, map[string]struct{}{
		"acct-1": {},
		"acct-2": {},
		"acct-3": {},
	}, accountIDs)
	require.Equal(t, int32(2), calls.Load())
}

func TestAddProjectRoleActorReturnsAlreadyMemberError(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodPost, r.Method)
		require.Equal(t, "/rest/api/3/project/TCID/role/10058", r.URL.Path)
		var body map[string][]string
		require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
		require.Equal(t, []string{"acct-1"}, body["user"])

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		_, _ = w.Write([]byte(`{"errorMessages":["'acct-1' is already a member of the project role."],"errors":{}}`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	err := client.AddProjectRoleActor(context.Background(), "TCID", "10058", "acct-1")
	require.ErrorIs(t, err, ErrProjectRoleActorAlreadyMember)
	require.ErrorContains(t, err, `response {"errorMessages":["'acct-1' is already a member of the project role."],"errors":{}}`)
}

func TestAddProjectRoleActorsIsolatesAlreadyPresentActorInBatch(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var body map[string][]string
		require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
		if len(body["user"]) == 1 && body["user"][0] == "acct-2" {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		_, _ = w.Write([]byte(`{"errorMessages":["'acct-1' is already a member of the project role."],"errors":{}}`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	result, err := client.AddProjectRoleActors(context.Background(), "TCID", "10058", []string{"acct-1", "acct-2"})
	require.NoError(t, err)
	require.Equal(t, AddProjectRoleActorsResult{Added: 1, AlreadyPresent: 1}, result)
}

func TestGetUsersByAccountIDsBatchesAndReturnsInactiveUsers(t *testing.T) {
	t.Parallel()

	var calls atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "/rest/api/3/user/bulk", r.URL.Path)
		accountIDs := r.URL.Query()["accountId"]
		require.NotEmpty(t, accountIDs)
		require.LessOrEqual(t, len(accountIDs), atlassianBatchSize)
		values := make([]userSearchResult, 0, len(accountIDs))
		for _, accountID := range accountIDs {
			values = append(values, userSearchResult{
				AccountID:    accountID,
				EmailAddress: accountID + "@example.com",
				AccountType:  "atlassian",
				Active:       accountID != "acct-050",
			})
		}
		w.Header().Set("Content-Type", "application/json")
		require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"values": values}))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())
	accountIDs := make([]string, 0, 101)
	for i := range 101 {
		accountIDs = append(accountIDs, fmt.Sprintf("acct-%03d", i))
	}

	users, err := client.GetUsersByAccountIDs(context.Background(), accountIDs)
	require.NoError(t, err)
	require.Len(t, users, 101)
	require.False(t, users["acct-050"].Active)
	require.True(t, users["acct-100"].Active)
	require.Equal(t, int32(2), calls.Load())
}

func TestGetUsersByAccountIDsLimitsEncodedRequestTargetLength(t *testing.T) {
	t.Parallel()

	var calls atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, jiraBulkUsersPath, r.URL.Path)
		require.LessOrEqual(t, len(r.URL.RequestURI()), jiraBulkUsersMaxRequestTargetLength)

		accountIDs := r.URL.Query()["accountId"]
		require.NotEmpty(t, accountIDs)
		require.LessOrEqual(t, len(accountIDs), atlassianBatchSize)
		values := make([]userSearchResult, 0, len(accountIDs))
		for _, accountID := range accountIDs {
			values = append(values, userSearchResult{AccountID: accountID, Active: true})
		}
		w.Header().Set("Content-Type", "application/json")
		require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"values": values}))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())
	accountIDs := make([]string, 0, atlassianBatchSize)
	for i := range atlassianBatchSize {
		accountIDs = append(accountIDs, fmt.Sprintf("acct-%03d-%s", i, strings.Repeat("x", 120)))
	}

	users, err := client.GetUsersByAccountIDs(context.Background(), accountIDs)
	require.NoError(t, err)
	require.Len(t, users, len(accountIDs))
	require.Greater(t, calls.Load(), int32(1))
}

func TestAddProjectRoleActorsIsolatesUserFailureAndContinuesLaterBatches(t *testing.T) {
	t.Parallel()

	const failedAccountID = "acct-050"
	var mu sync.Mutex
	added := make(map[string]struct{})
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var body map[string][]string
		require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
		accountIDs := body["user"]
		for _, accountID := range accountIDs {
			if accountID == failedAccountID {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusNotFound)
				_, _ = w.Write([]byte(`{"errorMessages":["We can't find 'rk:internal-user-key' in any accessible user directory. Check they're still active and available, and try again."],"errors":{}}`))
				return
			}
		}
		mu.Lock()
		for _, accountID := range accountIDs {
			added[accountID] = struct{}{}
		}
		mu.Unlock()
		w.WriteHeader(http.StatusNoContent)
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())
	accountIDs := make([]string, 0, 101)
	for i := range 101 {
		accountIDs = append(accountIDs, fmt.Sprintf("acct-%03d", i))
	}

	result, err := client.AddProjectRoleActors(context.Background(), "TCID", "10058", accountIDs)
	var userErr *ProjectRoleActorUserError
	require.ErrorAs(t, err, &userErr)
	require.Equal(t, []string{failedAccountID}, userErr.AccountIDs())
	require.Equal(t, AddProjectRoleActorsResult{Added: 100}, result)
	require.Len(t, added, 100)
	require.NotContains(t, added, failedAccountID)
	require.Contains(t, added, "acct-100")
}

func TestAddProjectRoleActorsStopsOnSystemicFailure(t *testing.T) {
	t.Parallel()

	var calls atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		calls.Add(1)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = w.Write([]byte(`{"errorMessages":["temporary Jira failure"],"errors":{}}`))
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())
	accountIDs := make([]string, 0, 101)
	for i := range 101 {
		accountIDs = append(accountIDs, fmt.Sprintf("acct-%03d", i))
	}

	result, err := client.AddProjectRoleActors(context.Background(), "TCID", "10058", accountIDs)
	require.ErrorContains(t, err, "500 Internal Server Error")
	require.Equal(t, AddProjectRoleActorsResult{}, result)
	require.Equal(t, int32(1), calls.Load())
}

func TestAddOrganizationToServiceDeskProject(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/rest/servicedeskapi/servicedesk":
			require.Equal(t, "0", r.URL.Query().Get("start"))
			require.Equal(t, "100", r.URL.Query().Get("limit"))
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{
				"start": 0,
				"limit": 100,
				"size": 2,
				"isLastPage": true,
				"values": [
					{"id":"1","projectKey":"OTHER"},
					{"id":"7","projectKey":"TCID"}
				]
			}`))
		case "/rest/servicedeskapi/servicedesk/7/organization":
			require.Equal(t, http.MethodPost, r.Method)
			var body map[string]any
			require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
			require.Equal(t, float64(123), body["organizationId"])
			w.WriteHeader(http.StatusNoContent)
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	err := client.AddOrganizationToServiceDeskProject(context.Background(), "TCID", "123")
	require.NoError(t, err)
}

func TestAddOrganizationToServiceDeskProjectCachesServiceDeskID(t *testing.T) {
	t.Parallel()

	var listCalls atomic.Int32
	var addCalls atomic.Int32
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/rest/servicedeskapi/servicedesk":
			listCalls.Add(1)
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{
				"start": 0,
				"limit": 100,
				"size": 1,
				"isLastPage": true,
				"values": [
					{"id":"7","projectKey":"TCID"}
				]
			}`))
		case "/rest/servicedeskapi/servicedesk/7/organization":
			addCalls.Add(1)
			require.Equal(t, http.MethodPost, r.Method)
			w.WriteHeader(http.StatusNoContent)
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	require.NoError(t, client.AddOrganizationToServiceDeskProject(context.Background(), "TCID", "123"))
	require.NoError(t, client.AddOrganizationToServiceDeskProject(context.Background(), "tcid", "456"))
	require.Equal(t, int32(1), listCalls.Load())
	require.Equal(t, int32(2), addCalls.Load())
}

func TestOrganizationMaintenanceOperations(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		username, password, ok := r.BasicAuth()
		require.True(t, ok)
		require.Equal(t, "jira-user", username)
		require.Equal(t, "jira-token", password)

		switch r.URL.Path {
		case "/rest/servicedesk/1/organisations/search":
			require.Equal(t, http.MethodGet, r.Method)
			require.Equal(t, "1372813089209250455", r.URL.Query().Get("query"))
			require.Equal(t, "0", r.URL.Query().Get("start"))
			require.Equal(t, "50", r.URL.Query().Get("limit"))
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`[
				{"id":22570,"name":"old-1 (1372813089209250455)"},
				{"id":22568,"name":"old-2 (1372813089209250455)"},
				{"id":100,"name":"fuzzy result"}
			]`))
		case "/rest/servicedeskapi/organization/22570":
			require.Equal(t, http.MethodDelete, r.Method)
			require.Equal(t, "application/json", r.Header.Get("Content-Type"))
			w.WriteHeader(http.StatusNoContent)
		case "/rest/servicedesk/1/organisations/22568/update":
			require.Equal(t, http.MethodPut, r.Method)
			var body map[string]string
			require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
			require.Equal(t, map[string]string{"newName": "Acme (1372813089209250455)"}, body)
			w.WriteHeader(http.StatusOK)
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer server.Close()

	client := newClient(config.AtlassianConfig{
		BaseURL:  server.URL,
		Username: "jira-user",
		APIToken: "jira-token",
	}, server.Client())

	organizations, err := client.FindOrganizationsByTiDBCloudOrgID(context.Background(), "1372813089209250455")
	require.NoError(t, err)
	require.Equal(t, []Organization{
		{ID: "22570", Name: "old-1 (1372813089209250455)"},
		{ID: "22568", Name: "old-2 (1372813089209250455)"},
	}, organizations)
	require.NoError(t, client.DeleteOrganization(context.Background(), "22570"))
	require.NoError(t, client.RenameOrganization(context.Background(), "22568", "Acme (1372813089209250455)"))
}

func fastRequestPolicyConfig() config.AtlassianRequestPolicyConfig {
	return config.AtlassianRequestPolicyConfig{
		RequestsPerSecond: 10_000,
		Burst:             10,
		MaxRetries:        1,
		Cooldown:          time.Millisecond,
	}
}

func writeBurstRateLimitResponse(w http.ResponseWriter) {
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("RateLimit-Reason", "jira-burst-based")
	w.WriteHeader(http.StatusTooManyRequests)
	_, _ = w.Write([]byte(`{"errorMessages":["rate limited"]}`))
}
