package atlassian

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

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

var (
	// ErrProjectRoleActorAlreadyMember identifies Jira's duplicate role-actor response.
	ErrProjectRoleActorAlreadyMember = errors.New("jira project role actor is already a member")
	// ErrJSMOrganizationNotFound identifies JSM's organization-not-found response.
	ErrJSMOrganizationNotFound = errors.New("JSM organization is not found")
)

const (
	maxErrorResponseBodyBytes  = 16 * 1024
	defaultRequestsPerSecond   = 10
	defaultRequestBurst        = 1
	defaultRequestMaxRetries   = 4
	defaultRateLimitCooldown   = time.Minute
	rateLimitBackoffBase       = time.Second
	rateLimitBackoffMax        = 30 * time.Second
	rateLimitReasonGlobalQuota = "jira-quota-global-based"
	rateLimitReasonTenantQuota = "jira-quota-tenant-based"
	jsmOrganizationNotFoundKey = "sd.customer.organisation.ids.error.not.found"
)

type responseError struct {
	action     string
	status     string
	statusCode int
	body       string
	errorKey   string
}

func (e *responseError) Error() string {
	if e.body == "" {
		return fmt.Sprintf("%s failed: status %s", e.action, e.status)
	}
	return fmt.Sprintf("%s failed: status %s: response %s", e.action, e.status, e.body)
}

func (e *responseError) Is(target error) bool {
	return target == ErrJSMOrganizationNotFound &&
		e.statusCode == http.StatusNotFound &&
		e.errorKey == jsmOrganizationNotFoundKey
}

type AccountLookupClient interface {
	FindAccountIDByEmail(ctx context.Context, email string) (string, bool, error)
}

type User struct {
	AccountID    string
	EmailAddress string
	AccountType  string
	Active       bool
}

type AddProjectRoleActorsResult struct {
	Added          int
	AlreadyPresent int
}

type ProjectRoleActorFailure struct {
	AccountID string
	Err       error
}

type ProjectRoleActorUserError struct {
	Failures []ProjectRoleActorFailure
}

func (e *ProjectRoleActorUserError) Error() string {
	details := make([]string, 0, len(e.Failures))
	for _, failure := range e.Failures {
		details = append(details, fmt.Sprintf("%s: %v", failure.AccountID, failure.Err))
	}
	return fmt.Sprintf("failed to add %d Jira project role actors: %s", len(e.Failures), strings.Join(details, "; "))
}

func (e *ProjectRoleActorUserError) Unwrap() []error {
	errs := make([]error, 0, len(e.Failures))
	for _, failure := range e.Failures {
		errs = append(errs, failure.Err)
	}
	return errs
}

func (e *ProjectRoleActorUserError) AccountIDs() []string {
	accountIDs := make([]string, 0, len(e.Failures))
	for _, failure := range e.Failures {
		accountIDs = append(accountIDs, failure.AccountID)
	}
	return accountIDs
}

// OrganizationMaintenanceClient contains the destructive organization APIs
// used by one-off maintenance commands. It is intentionally separate from
// Client so these operations are not exposed to the long-running sync service.
type OrganizationMaintenanceClient interface {
	FindOrganizationsByTiDBCloudOrgID(ctx context.Context, tidbcloudOrgID string) ([]Organization, error)
	DeleteOrganization(ctx context.Context, organizationID string) error
	RenameOrganization(ctx context.Context, organizationID string, newName string) error
}

type Organization struct {
	ID   string
	Name string
}

type Client interface {
	AccountLookupClient
	FindOrganizationByTiDBCloudOrgID(ctx context.Context, tidbcloudOrgID string) (string, bool, error)
	CreateOrganization(ctx context.Context, name string) (string, error)
	AddOrganizationToServiceDeskProject(ctx context.Context, projectKey string, organizationID string) error
	ListOrganizationUserAccountIDs(ctx context.Context, organizationID string) (map[string]struct{}, error)
	AddUsersToOrganization(ctx context.Context, organizationID string, accountIDs []string) error
	AddUserToOrganization(ctx context.Context, organizationID string, accountID string) error
	RemoveUsersFromOrganization(ctx context.Context, organizationID string, accountIDs []string) error
	RemoveUserFromOrganization(ctx context.Context, organizationID string, accountID string) error
	GetUsersByAccountIDs(ctx context.Context, accountIDs []string) (map[string]User, error)
	ListProjectRoleActorAccountIDs(ctx context.Context, projectKey string, roleID string) (map[string]struct{}, error)
	AddProjectRoleActors(
		ctx context.Context,
		projectKey string,
		roleID string,
		accountIDs []string,
	) (AddProjectRoleActorsResult, error)
	AddProjectRoleActor(ctx context.Context, projectKey string, roleID string, accountID string) error
	RemoveProjectRoleActor(ctx context.Context, projectKey string, roleID string, accountID string) error
}

const (
	atlassianBatchSize = 100
	// Leave headroom below CloudFront's request-URI limit for the base URL and proxy rewriting.
	jiraBulkUsersMaxRequestTargetLength = 7 * 1024
	jiraBulkUsersPath                   = "/rest/api/3/user/bulk"
)

type client struct {
	baseURL                                      string
	username                                     string
	apiToken                                     string
	httpClient                                   *http.Client
	globalRateLimitCooldown                      sharedCooldown
	globalRateLimitCooldownDuration              time.Duration
	userSearchRateLimit                          *requestRateLimitPolicy
	findOrganizationRateLimit                    *requestRateLimitPolicy
	createOrganizationRateLimit                  *requestRateLimitPolicy
	addOrganizationToServiceDeskProjectRateLimit *requestRateLimitPolicy
	listOrganizationUsersRateLimit               *requestRateLimitPolicy
	addUsersToOrganizationRateLimit              *requestRateLimitPolicy
	serviceDeskIDs                               map[string]string
	serviceDeskMu                                sync.RWMutex
}

func NewClient(conf config.AtlassianConfig) Client {
	return newClient(conf, &http.Client{
		Timeout: 10 * time.Second,
	})
}

func NewOrganizationMaintenanceClient(conf config.AtlassianConfig) OrganizationMaintenanceClient {
	return newClient(conf, &http.Client{
		Timeout: 10 * time.Second,
	})
}

func newClient(conf config.AtlassianConfig, httpClient *http.Client) *client {
	return &client{
		baseURL:    strings.TrimRight(conf.BaseURL, "/"),
		username:   conf.Username,
		apiToken:   conf.APIToken,
		httpClient: httpClient,
		globalRateLimitCooldownDuration: positiveDurationOrDefault(
			conf.RateLimit.Global.Cooldown,
			defaultRateLimitCooldown,
		),
		userSearchRateLimit: newDefaultRequestRateLimitPolicy(conf.RateLimit.UserSearch),
		findOrganizationRateLimit: newDefaultRequestRateLimitPolicy(
			conf.RateLimit.FindOrganization,
		),
		createOrganizationRateLimit: newDefaultRequestRateLimitPolicy(
			conf.RateLimit.CreateOrganization,
		),
		addOrganizationToServiceDeskProjectRateLimit: newDefaultRequestRateLimitPolicy(
			conf.RateLimit.AddOrganizationToServiceDeskProject,
		),
		listOrganizationUsersRateLimit: newDefaultRequestRateLimitPolicy(
			conf.RateLimit.ListOrganizationUsers,
		),
		addUsersToOrganizationRateLimit: newDefaultRequestRateLimitPolicy(
			conf.RateLimit.AddUsersToOrganization,
		),
		serviceDeskIDs: make(map[string]string),
	}
}

type userSearchResult struct {
	AccountID    string `json:"accountId"`
	EmailAddress string `json:"emailAddress"`
	AccountType  string `json:"accountType"`
	Active       bool   `json:"active"`
}

type organization struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type organizationSearchResult struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
}

type organizationUserPage struct {
	Values     []organizationUser `json:"values"`
	Start      int                `json:"start"`
	Limit      int                `json:"limit"`
	IsLastPage bool               `json:"isLastPage"`
}

type organizationUser struct {
	AccountID string `json:"accountId"`
}

type serviceDeskPage struct {
	Values     []serviceDesk `json:"values"`
	Start      int           `json:"start"`
	Limit      int           `json:"limit"`
	Size       int           `json:"size"`
	IsLastPage bool          `json:"isLastPage"`
}

type serviceDesk struct {
	ID         string `json:"id"`
	ProjectID  string `json:"projectId"`
	ProjectKey string `json:"projectKey"`
}

type projectRole struct {
	Actors []projectRoleActor `json:"actors"`
}

type projectRoleActor struct {
	ActorUser *projectRoleActorUser `json:"actorUser"`
}

type projectRoleActorUser struct {
	AccountID string `json:"accountId"`
}

func (c *client) FindAccountIDByEmail(ctx context.Context, email string) (string, bool, error) {
	email = strings.TrimSpace(email)
	if email == "" {
		return "", false, nil
	}
	values := url.Values{}
	values.Set("query", email)
	values.Set("maxResults", "10")
	path := "/rest/api/3/user/search?" + values.Encode()
	resp, err := c.doWithRateLimitPolicy(
		ctx,
		c.userSearchRateLimit,
		"search atlassian user",
		func() (*http.Response, error) {
			return c.do(ctx, http.MethodGet, path, nil)
		},
	)
	if err != nil {
		return "", false, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return "", false, c.statusError("search atlassian user", resp)
	}

	var users []userSearchResult
	if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
		return "", false, err
	}
	for _, user := range users {
		if strings.EqualFold(user.EmailAddress, email) && user.AccountID != "" && user.Active {
			return user.AccountID, true, nil
		}
	}
	return "", false, nil
}

func (c *client) FindOrganizationByTiDBCloudOrgID(
	ctx context.Context,
	tidbcloudOrgID string,
) (string, bool, error) {
	organizations, err := c.FindOrganizationsByTiDBCloudOrgID(ctx, tidbcloudOrgID)
	if err != nil {
		return "", false, err
	}

	var smallestMatchedID int64
	for _, org := range organizations {
		organizationID, err := strconv.ParseInt(org.ID, 10, 64)
		if err != nil {
			return "", false, fmt.Errorf("parse JSM organization ID %q: %w", org.ID, err)
		}
		if smallestMatchedID == 0 || organizationID < smallestMatchedID {
			smallestMatchedID = organizationID
		}
	}

	if smallestMatchedID == 0 {
		return "", false, nil
	}
	return strconv.FormatInt(smallestMatchedID, 10), true, nil
}

func (c *client) FindOrganizationsByTiDBCloudOrgID(
	ctx context.Context,
	tidbcloudOrgID string,
) ([]Organization, error) {
	tidbcloudOrgID = strings.TrimSpace(tidbcloudOrgID)
	if tidbcloudOrgID == "" {
		return nil, nil
	}

	// This JSM endpoint performs a fuzzy name search. Search only the first page
	// and accept only the exact TiDB Cloud org ID suffix in the display name.
	values := url.Values{}
	values.Set("query", tidbcloudOrgID)
	values.Set("start", "0")
	values.Set("limit", "50")
	path := "/rest/servicedesk/1/organisations/search?" + values.Encode()
	resp, err := c.doWithRateLimitPolicy(
		ctx,
		c.findOrganizationRateLimit,
		"search JSM organizations",
		func() (*http.Response, error) {
			return c.do(ctx, http.MethodGet, path, nil)
		},
	)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, c.statusError("search JSM organizations", resp)
	}

	var results []organizationSearchResult
	if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
		return nil, err
	}

	organizations := make([]Organization, 0, len(results))
	for _, org := range results {
		if org.ID <= 0 || !organizationNameHasTiDBCloudOrgID(org.Name, tidbcloudOrgID) {
			continue
		}
		organizations = append(organizations, Organization{
			ID:   strconv.FormatInt(org.ID, 10),
			Name: org.Name,
		})
	}
	return organizations, nil
}

func organizationNameHasTiDBCloudOrgID(name string, tidbcloudOrgID string) bool {
	return strings.HasSuffix(strings.TrimSpace(name), "("+tidbcloudOrgID+")")
}

func (c *client) CreateOrganization(ctx context.Context, name string) (string, error) {
	body := map[string]string{"name": strings.TrimSpace(name)}
	resp, err := c.doWithRateLimitPolicy(
		ctx,
		c.createOrganizationRateLimit,
		"create JSM organization",
		func() (*http.Response, error) {
			return c.doJSON(ctx, http.MethodPost, "/rest/servicedeskapi/organization", body)
		},
	)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return "", c.statusError("create JSM organization", resp)
	}

	var org organization
	if err := json.NewDecoder(resp.Body).Decode(&org); err != nil {
		return "", err
	}
	if org.ID == "" {
		return "", fmt.Errorf("create JSM organization returned empty ID")
	}
	return org.ID, nil
}

func (c *client) DeleteOrganization(ctx context.Context, organizationID string) error {
	organizationID = strings.TrimSpace(organizationID)
	if organizationID == "" {
		return fmt.Errorf("JSM organization ID is empty")
	}

	resp, err := c.do(
		ctx,
		http.MethodDelete,
		"/rest/servicedeskapi/organization/"+url.PathEscape(organizationID),
		bytes.NewReader(nil),
	)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return c.statusError("delete JSM organization", resp)
	}
	return nil
}

func (c *client) RenameOrganization(ctx context.Context, organizationID string, newName string) error {
	organizationID = strings.TrimSpace(organizationID)
	newName = strings.TrimSpace(newName)
	if organizationID == "" {
		return fmt.Errorf("JSM organization ID is empty")
	}
	if newName == "" {
		return fmt.Errorf("JSM organization name is empty")
	}

	resp, err := c.doJSON(
		ctx,
		http.MethodPut,
		fmt.Sprintf("/rest/servicedesk/1/organisations/%s/update", url.PathEscape(organizationID)),
		map[string]string{"newName": newName},
	)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return c.statusError("rename JSM organization", resp)
	}
	return nil
}

func (c *client) AddOrganizationToServiceDeskProject(ctx context.Context, projectKey string, organizationID string) error {
	projectKey = strings.TrimSpace(projectKey)
	organizationID = strings.TrimSpace(organizationID)
	if projectKey == "" || organizationID == "" {
		return nil
	}

	serviceDeskID, found, err := c.findServiceDeskIDByProjectKey(ctx, projectKey)
	if err != nil {
		return err
	}
	if !found {
		return fmt.Errorf("JSM service desk for project %s is not found", projectKey)
	}

	path := fmt.Sprintf(
		"/rest/servicedeskapi/servicedesk/%s/organization",
		url.PathEscape(serviceDeskID),
	)
	body := map[string]any{"organizationId": organizationIDValue(organizationID)}
	resp, err := c.doWithRateLimitPolicy(
		ctx,
		c.addOrganizationToServiceDeskProjectRateLimit,
		"add JSM organization to service desk",
		func() (*http.Response, error) {
			return c.doJSON(ctx, http.MethodPost, path, body)
		},
	)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusConflict {
		return nil
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return c.statusError("add JSM organization to service desk", resp)
	}
	return nil
}

func organizationIDValue(organizationID string) any {
	id, err := strconv.ParseInt(organizationID, 10, 64)
	if err != nil {
		return organizationID
	}
	return id
}

func (c *client) findServiceDeskIDByProjectKey(ctx context.Context, projectKey string) (string, bool, error) {
	projectKey = strings.TrimSpace(projectKey)
	cacheKey := strings.ToUpper(projectKey)
	c.serviceDeskMu.RLock()
	if serviceDeskID := c.serviceDeskIDs[cacheKey]; serviceDeskID != "" {
		c.serviceDeskMu.RUnlock()
		return serviceDeskID, true, nil
	}
	c.serviceDeskMu.RUnlock()

	start := 0
	limit := 100
	for {
		values := url.Values{}
		values.Set("start", fmt.Sprintf("%d", start))
		values.Set("limit", fmt.Sprintf("%d", limit))

		path := "/rest/servicedeskapi/servicedesk?" + values.Encode()
		resp, err := c.doWithRateLimitPolicy(
			ctx,
			c.addOrganizationToServiceDeskProjectRateLimit,
			"list JSM service desks",
			func() (*http.Response, error) {
				return c.do(ctx, http.MethodGet, path, nil)
			},
		)
		if err != nil {
			return "", false, err
		}

		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
			err := c.statusError("list JSM service desks", resp)
			_ = resp.Body.Close()
			return "", false, err
		}

		var page serviceDeskPage
		decodeErr := json.NewDecoder(resp.Body).Decode(&page)
		closeErr := resp.Body.Close()
		if decodeErr != nil {
			return "", false, decodeErr
		}
		if closeErr != nil {
			return "", false, closeErr
		}

		for _, item := range page.Values {
			if item.ID != "" && strings.EqualFold(item.ProjectKey, projectKey) {
				c.serviceDeskMu.Lock()
				c.serviceDeskIDs[cacheKey] = item.ID
				c.serviceDeskMu.Unlock()
				return item.ID, true, nil
			}
		}
		if page.IsLastPage || len(page.Values) == 0 {
			return "", false, nil
		}

		nextStart := page.Start + page.Limit
		if page.Limit <= 0 {
			nextStart = start + len(page.Values)
		}
		if nextStart <= start {
			return "", false, fmt.Errorf("JSM service desk pagination did not advance")
		}
		start = nextStart
	}
}

func (c *client) AddUserToOrganization(ctx context.Context, organizationID string, accountID string) error {
	return c.AddUsersToOrganization(ctx, organizationID, []string{accountID})
}

func (c *client) ListOrganizationUserAccountIDs(ctx context.Context, organizationID string) (map[string]struct{}, error) {
	organizationID = strings.TrimSpace(organizationID)
	accounts := make(map[string]struct{})
	if organizationID == "" {
		return accounts, nil
	}

	for start := 0; ; {
		values := url.Values{}
		values.Set("start", strconv.Itoa(start))
		values.Set("limit", strconv.Itoa(atlassianBatchSize))
		path := fmt.Sprintf(
			"/rest/servicedeskapi/organization/%s/user?%s",
			url.PathEscape(organizationID),
			values.Encode(),
		)
		resp, err := c.doWithRateLimitPolicy(
			ctx,
			c.listOrganizationUsersRateLimit,
			"list JSM organization users",
			func() (*http.Response, error) {
				return c.do(ctx, http.MethodGet, path, nil)
			},
		)
		if err != nil {
			return nil, err
		}

		var page organizationUserPage
		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
			err = json.NewDecoder(resp.Body).Decode(&page)
		} else {
			err = c.statusError("list JSM organization users", resp)
		}
		_ = resp.Body.Close()
		if err != nil {
			return nil, err
		}

		for _, user := range page.Values {
			if accountID := strings.TrimSpace(user.AccountID); accountID != "" {
				accounts[accountID] = struct{}{}
			}
		}
		if page.IsLastPage || len(page.Values) == 0 {
			return accounts, nil
		}

		nextStart := page.Start + page.Limit
		if page.Limit <= 0 {
			nextStart = start + len(page.Values)
		}
		if nextStart <= start {
			return nil, fmt.Errorf("JSM organization user pagination did not advance")
		}
		start = nextStart
	}
}

func (c *client) AddUsersToOrganization(ctx context.Context, organizationID string, accountIDs []string) error {
	accountIDs = cleanUniqueStrings(accountIDs)
	if strings.TrimSpace(organizationID) == "" || len(accountIDs) == 0 {
		return nil
	}
	for _, batch := range chunkStrings(accountIDs, atlassianBatchSize) {
		if err := c.addUsersToOrganizationBatch(ctx, organizationID, batch); err != nil {
			return err
		}
	}
	return nil
}

func (c *client) addUsersToOrganizationBatch(ctx context.Context, organizationID string, accountIDs []string) error {
	path := fmt.Sprintf(
		"/rest/servicedeskapi/organization/%s/user",
		url.PathEscape(organizationID),
	)
	body := map[string][]string{"accountIds": accountIDs}
	resp, err := c.doWithRateLimitPolicy(
		ctx,
		c.addUsersToOrganizationRateLimit,
		"add JSM organization user",
		func() (*http.Response, error) {
			return c.doJSON(ctx, http.MethodPost, path, body)
		},
	)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusConflict {
		return nil
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return c.statusError("add JSM organization user", resp)
	}
	return nil
}

func (c *client) RemoveUserFromOrganization(ctx context.Context, organizationID string, accountID string) error {
	return c.RemoveUsersFromOrganization(ctx, organizationID, []string{accountID})
}

func (c *client) RemoveUsersFromOrganization(ctx context.Context, organizationID string, accountIDs []string) error {
	accountIDs = cleanUniqueStrings(accountIDs)
	if strings.TrimSpace(organizationID) == "" || len(accountIDs) == 0 {
		return nil
	}
	for _, batch := range chunkStrings(accountIDs, atlassianBatchSize) {
		if err := c.removeUsersFromOrganizationBatch(ctx, organizationID, batch); err != nil {
			return err
		}
	}
	return nil
}

func (c *client) removeUsersFromOrganizationBatch(ctx context.Context, organizationID string, accountIDs []string) error {
	resp, err := c.doJSON(ctx, http.MethodDelete, fmt.Sprintf("/rest/servicedeskapi/organization/%s/user", url.PathEscape(organizationID)), map[string][]string{
		"accountIds": accountIDs,
	})
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		return nil
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return c.statusError("remove JSM organization user", resp)
	}
	return nil
}

func (c *client) GetUsersByAccountIDs(ctx context.Context, accountIDs []string) (map[string]User, error) {
	accountIDs = cleanUniqueStrings(accountIDs)
	users := make(map[string]User, len(accountIDs))
	for _, batch := range chunkJiraBulkUserAccountIDs(accountIDs) {
		batchUsers, err := c.getUsersByAccountIDsBatch(ctx, batch)
		if err != nil {
			return nil, err
		}
		for accountID, user := range batchUsers {
			users[accountID] = user
		}
	}
	return users, nil
}

func (c *client) getUsersByAccountIDsBatch(ctx context.Context, accountIDs []string) (map[string]User, error) {
	resp, err := c.do(ctx, http.MethodGet, jiraBulkUsersRequestPath(accountIDs), nil)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, c.statusError("bulk get Jira users", resp)
	}
	var page struct {
		Values []userSearchResult `json:"values"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
		return nil, err
	}
	users := make(map[string]User, len(page.Values))
	for _, user := range page.Values {
		accountID := strings.TrimSpace(user.AccountID)
		if accountID == "" {
			continue
		}
		users[accountID] = User{
			AccountID:    accountID,
			EmailAddress: user.EmailAddress,
			AccountType:  user.AccountType,
			Active:       user.Active,
		}
	}
	return users, nil
}

func jiraBulkUsersRequestPath(accountIDs []string) string {
	values := url.Values{}
	values.Set("maxResults", strconv.Itoa(len(accountIDs)))
	for _, accountID := range accountIDs {
		values.Add("accountId", accountID)
	}
	return jiraBulkUsersPath + "?" + values.Encode()
}

func chunkJiraBulkUserAccountIDs(accountIDs []string) [][]string {
	var batches [][]string
	var batch []string
	for _, accountID := range accountIDs {
		candidate := append(batch, accountID)
		if len(batch) > 0 && (len(candidate) > atlassianBatchSize ||
			len(jiraBulkUsersRequestPath(candidate)) > jiraBulkUsersMaxRequestTargetLength) {
			batches = append(batches, batch)
			batch = []string{accountID}
			continue
		}
		batch = candidate
	}
	if len(batch) > 0 {
		batches = append(batches, batch)
	}
	return batches
}

func (c *client) ListProjectRoleActorAccountIDs(ctx context.Context, projectKey string, roleID string) (map[string]struct{}, error) {
	resp, err := c.do(ctx, http.MethodGet, projectRolePath(projectKey, roleID), nil)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, c.statusError("list Jira project role actors", resp)
	}

	var role projectRole
	if err := json.NewDecoder(resp.Body).Decode(&role); err != nil {
		return nil, err
	}
	accounts := make(map[string]struct{}, len(role.Actors))
	for _, actor := range role.Actors {
		if actor.ActorUser != nil && actor.ActorUser.AccountID != "" {
			accounts[actor.ActorUser.AccountID] = struct{}{}
		}
	}
	return accounts, nil
}

func (c *client) AddProjectRoleActor(ctx context.Context, projectKey string, roleID string, accountID string) error {
	accountIDs := cleanUniqueStrings([]string{accountID})
	if strings.TrimSpace(projectKey) == "" || strings.TrimSpace(roleID) == "" || len(accountIDs) == 0 {
		return nil
	}
	return c.addProjectRoleActorsBatch(ctx, projectKey, roleID, accountIDs)
}

func (c *client) AddProjectRoleActors(
	ctx context.Context,
	projectKey string,
	roleID string,
	accountIDs []string,
) (result AddProjectRoleActorsResult, err error) {
	accountIDs = cleanUniqueStrings(accountIDs)
	if strings.TrimSpace(projectKey) == "" || strings.TrimSpace(roleID) == "" || len(accountIDs) == 0 {
		return result, nil
	}
	var failures []ProjectRoleActorFailure
	for _, batch := range chunkStrings(accountIDs, atlassianBatchSize) {
		batchResult, batchFailures, batchErr := c.addProjectRoleActorsResilient(ctx, projectKey, roleID, batch)
		result.Added += batchResult.Added
		result.AlreadyPresent += batchResult.AlreadyPresent
		failures = append(failures, batchFailures...)
		if batchErr != nil {
			return result, batchErr
		}
	}
	if len(failures) > 0 {
		return result, &ProjectRoleActorUserError{Failures: failures}
	}
	return result, nil
}

func (c *client) addProjectRoleActorsResilient(
	ctx context.Context,
	projectKey string,
	roleID string,
	accountIDs []string,
) (result AddProjectRoleActorsResult, failures []ProjectRoleActorFailure, err error) {
	err = c.addProjectRoleActorsBatch(ctx, projectKey, roleID, accountIDs)
	if err == nil {
		result.Added = len(accountIDs)
		return result, nil, nil
	}
	if len(accountIDs) == 1 && errors.Is(err, ErrProjectRoleActorAlreadyMember) {
		result.AlreadyPresent = 1
		return result, nil, nil
	}
	if !isProjectRoleActorUserError(err) {
		return result, nil, err
	}
	if len(accountIDs) == 1 {
		return result, []ProjectRoleActorFailure{{AccountID: accountIDs[0], Err: err}}, nil
	}

	middle := len(accountIDs) / 2
	leftResult, leftFailures, err := c.addProjectRoleActorsResilient(
		ctx,
		projectKey,
		roleID,
		accountIDs[:middle],
	)
	result.Added += leftResult.Added
	result.AlreadyPresent += leftResult.AlreadyPresent
	failures = append(failures, leftFailures...)
	if err != nil {
		return result, failures, err
	}

	rightResult, rightFailures, err := c.addProjectRoleActorsResilient(
		ctx,
		projectKey,
		roleID,
		accountIDs[middle:],
	)
	result.Added += rightResult.Added
	result.AlreadyPresent += rightResult.AlreadyPresent
	failures = append(failures, rightFailures...)
	return result, failures, err
}

func (c *client) addProjectRoleActorsBatch(ctx context.Context, projectKey string, roleID string, accountIDs []string) error {
	resp, err := c.doJSON(ctx, http.MethodPost, projectRolePath(projectKey, roleID), map[string][]string{
		"user": accountIDs,
	})
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		err := c.statusError("add Jira project role actor", resp)
		if isProjectRoleActorAlreadyMemberError(err, accountIDs) {
			return fmt.Errorf("%w: %w", ErrProjectRoleActorAlreadyMember, err)
		}
		return err
	}
	return nil
}

func (c *client) RemoveProjectRoleActor(ctx context.Context, projectKey string, roleID string, accountID string) error {
	values := url.Values{}
	values.Set("user", accountID)
	resp, err := c.do(ctx, http.MethodDelete, projectRolePath(projectKey, roleID)+"?"+values.Encode(), nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		return nil
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return c.statusError("remove Jira project role actor", resp)
	}
	return nil
}

func (c *client) doJSON(ctx context.Context, method string, path string, body any) (*http.Response, error) {
	raw, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}
	return c.do(ctx, method, path, bytes.NewReader(raw))
}

func (c *client) do(ctx context.Context, method string, path string, body io.Reader) (*http.Response, error) {
	if c.baseURL == "" || c.username == "" || c.apiToken == "" {
		return nil, fmt.Errorf("atlassian client is not configured")
	}
	if err := c.globalRateLimitCooldown.Wait(ctx); err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(c.username, c.apiToken)
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	return c.httpClient.Do(req)
}

func statusError(action string, resp *http.Response) error {
	retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())
	rateLimitReason := strings.TrimSpace(resp.Header.Get("RateLimit-Reason"))
	body, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorResponseBodyBytes+1))
	if err != nil {
		return fmt.Errorf("%s failed: status %s: read response body: %w", action, resp.Status, err)
	}
	truncated := len(body) > maxErrorResponseBodyBytes
	if truncated {
		body = body[:maxErrorResponseBodyBytes]
	}
	bodyText := strings.TrimSpace(string(body))
	if truncated {
		bodyText += "... (truncated)"
	}
	respErr := &responseError{
		action:     action,
		status:     resp.Status,
		statusCode: resp.StatusCode,
		body:       bodyText,
		errorKey:   jsmErrorKey(body),
	}
	if resp.StatusCode == http.StatusTooManyRequests {
		return &RateLimitError{
			Err:        respErr,
			RetryAfter: retryAfter,
			Reason:     rateLimitReason,
		}
	}
	return respErr
}

func jsmErrorKey(body []byte) string {
	var response struct {
		ErrorMessage     string `json:"errorMessage"`
		I18nErrorMessage struct {
			I18nKey string `json:"i18nKey"`
		} `json:"i18nErrorMessage"`
	}
	if err := json.Unmarshal(body, &response); err != nil {
		return ""
	}
	if key := strings.TrimSpace(response.ErrorMessage); key != "" {
		return key
	}
	return strings.TrimSpace(response.I18nErrorMessage.I18nKey)
}

func parseRetryAfter(value string, now time.Time) time.Duration {
	value = strings.TrimSpace(value)
	if value == "" {
		return 0
	}
	if seconds, err := strconv.Atoi(value); err == nil {
		if seconds <= 0 {
			return 0
		}
		return time.Duration(seconds) * time.Second
	}
	retryAt, err := http.ParseTime(value)
	if err != nil || !retryAt.After(now) {
		return 0
	}
	return retryAt.Sub(now)
}

func isProjectRoleActorAlreadyMemberError(err error, accountIDs []string) bool {
	if len(accountIDs) != 1 {
		return false
	}
	var respErr *responseError
	if !errors.As(err, &respErr) || respErr.statusCode != http.StatusBadRequest {
		return false
	}
	var jiraErr struct {
		ErrorMessages []string `json:"errorMessages"`
	}
	if json.Unmarshal([]byte(respErr.body), &jiraErr) != nil {
		return false
	}
	want := fmt.Sprintf("'%s' is already a member of the project role.", accountIDs[0])
	for _, message := range jiraErr.ErrorMessages {
		if message == want {
			return true
		}
	}
	return false
}

func isProjectRoleActorUserError(err error) bool {
	var respErr *responseError
	if !errors.As(err, &respErr) || (respErr.statusCode != http.StatusBadRequest && respErr.statusCode != http.StatusNotFound) {
		return false
	}
	var jiraErr struct {
		ErrorMessages []string `json:"errorMessages"`
	}
	if json.Unmarshal([]byte(respErr.body), &jiraErr) != nil {
		return false
	}
	for _, message := range jiraErr.ErrorMessages {
		if strings.Contains(message, "in any accessible user directory") ||
			strings.Contains(message, "is already a member of the project role") {
			return true
		}
	}
	return false
}

func projectRolePath(projectKey string, roleID string) string {
	return fmt.Sprintf("/rest/api/3/project/%s/role/%s", url.PathEscape(projectKey), url.PathEscape(roleID))
}

func cleanUniqueStrings(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	result := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		result = append(result, value)
	}
	return result
}

func chunkStrings(values []string, size int) [][]string {
	if size <= 0 || len(values) <= size {
		return [][]string{values}
	}
	chunks := make([][]string, 0, (len(values)+size-1)/size)
	for start := 0; start < len(values); start += size {
		end := start + size
		if end > len(values) {
			end = len(values)
		}
		chunks = append(chunks, values[start:end])
	}
	return chunks
}
