//
// Copyright 2021, Sander van Harmelen
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

// Package gitlab implements a GitLab API client.
package gitlab

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"maps"
	"math"
	"math/rand/v2"
	"mime/multipart"
	"net"
	"net/http"
	"net/url"
	"slices"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/hashicorp/go-cleanhttp"

	"github.com/google/go-querystring/query"
	retryablehttp "github.com/hashicorp/go-retryablehttp"
	"golang.org/x/oauth2"
	"golang.org/x/time/rate"
)

const (
	defaultBaseURL = "https://gitlab.com/"
	apiVersionPath = "api/v4/"
	userAgent      = "go-gitlab"

	headerRateLimit = "Ratelimit-Limit"
	headerRateReset = "Ratelimit-Reset"

	AccessTokenHeaderName = "Private-Token"
	JobTokenHeaderName    = "Job-Token"
	OAuthTokenHeaderName  = "Authorization"
)

// AuthType represents an authentication type within GitLab.
//
// GitLab API docs: https://docs.gitlab.com/api/
type AuthType int

// List of available authentication types.
//
// GitLab API docs: https://docs.gitlab.com/api/
const (
	BasicAuth AuthType = iota
	JobToken
	OAuthToken
	PrivateToken
)

var (
	// ErrNotFound is returned for 404 Not Found errors
	ErrNotFound = &ErrorResponse{
		StatusCode: http.StatusNotFound,
		Message:    "Not Found",
	}

	// ErrEmptyResponse is returned when the API response is empty but expected to contain data
	ErrEmptyResponse = errors.New("unexpected empty response")

	// errUnauthenticated is an internal sentinel error to indicate that the auth source doesn't use any authentication
	errUnauthenticated = errors.New("unauthenticated")
)

// URLValidationError wraps URL parsing errors with helpful context
type URLValidationError struct {
	URL  string
	Err  error
	Hint string
}

func (e *URLValidationError) Error() string {
	msg := fmt.Sprintf("invalid base URL %q: %v", e.URL, e.Err)
	if e.Hint != "" {
		msg += fmt.Sprintf(" (hint: %s)", e.Hint)
	}
	return msg
}

// bodyReader is a special type that signals to Client.Do that we want to
// preserve the response body without copying it. The embedded ReadCloser will be set
// to the actual response body, and the caller is responsible for closing it.
type bodyReader struct {
	io.ReadCloser
}

// A Client manages communication with the GitLab API.
type Client struct {
	// HTTP client used to communicate with the API.
	client *retryablehttp.Client

	// Base URL for API requests. Defaults to the public GitLab API, but can be
	// set to a domain endpoint to use with a self hosted GitLab server. baseURL
	// should always be specified with a trailing slash.
	baseURL *url.URL

	// disableRetries is used to disable the default retry logic.
	disableRetries bool

	// configureLimiterOnce is used to make sure the limiter is configured exactly
	// once and block all other calls until the initial (one) call is done.
	configureLimiterOnce sync.Once

	// Limiter is used to limit API calls and prevent 429 responses.
	limiter RateLimiter

	// authSource is used to obtain authentication headers.
	authSource AuthSource
	// authSourceStrategy specifies a strategy that injects authorization headers from the AuthSource.
	authSourceStrategy AuthTokenStrategy

	// authSourceInit is used to ensure that AuthSources are initialized only
	// once.
	authSourceInit sync.Once

	// jar is the cookie jar to use with the HTTP client
	jar http.CookieJar

	// Default request options applied to every request.
	defaultRequestOptions []RequestOptionFunc

	// interceptors contain the stack of *http.Client round tripper builder func
	// which are used to decorate the http.Client#Transport value.
	interceptors []Interceptor

	// urlWarningLogger is used to print URL validation warnings
	urlWarningLogger *slog.Logger

	// User agent used when communicating with the GitLab API.
	UserAgent string

	// GraphQL interface
	GraphQL GraphQLInterface

	// Services used for talking to different parts of the GitLab API.
	AccessRequests                      AccessRequestsServiceInterface
	AdminCompliancePolicySettings       AdminCompliancePolicySettingsServiceInterface
	AlertManagement                     AlertManagementServiceInterface
	Appearance                          AppearanceServiceInterface
	Applications                        ApplicationsServiceInterface
	ApplicationStatistics               ApplicationStatisticsServiceInterface
	Attestations                        AttestationsServiceInterface
	AuditEvents                         AuditEventsServiceInterface
	Avatar                              AvatarRequestsServiceInterface
	AwardEmoji                          AwardEmojiServiceInterface
	Boards                              IssueBoardsServiceInterface
	Branches                            BranchesServiceInterface
	BroadcastMessage                    BroadcastMessagesServiceInterface
	BulkImports                         BulkImportsServiceInterface
	CIYMLTemplate                       CIYMLTemplatesServiceInterface
	ClusterAgents                       ClusterAgentsServiceInterface
	Commits                             CommitsServiceInterface
	ContainerRegistry                   ContainerRegistryServiceInterface
	ContainerRegistryProtectionRules    ContainerRegistryProtectionRulesServiceInterface
	ContainerRegistryTagProtectionRules ContainerRegistryTagProtectionRulesServiceInterface
	CustomAttribute                     CustomAttributesServiceInterface
	DatabaseMigrations                  DatabaseMigrationsServiceInterface
	Dependencies                        DependenciesServiceInterface
	DependencyListExport                DependencyListExportServiceInterface
	DependencyProxy                     DependencyProxyServiceInterface
	DeployKeys                          DeployKeysServiceInterface
	DeployTokens                        DeployTokensServiceInterface
	DeploymentMergeRequests             DeploymentMergeRequestsServiceInterface
	Deployments                         DeploymentsServiceInterface
	Discussions                         DiscussionsServiceInterface
	DockerfileTemplate                  DockerfileTemplatesServiceInterface
	DORAMetrics                         DORAMetricsServiceInterface
	DraftNotes                          DraftNotesServiceInterface
	EnterpriseUsers                     EnterpriseUsersServiceInterface
	Environments                        EnvironmentsServiceInterface
	EpicIssues                          EpicIssuesServiceInterface
	Epics                               EpicsServiceInterface
	ErrorTracking                       ErrorTrackingServiceInterface
	Events                              EventsServiceInterface
	ExternalStatusChecks                ExternalStatusChecksServiceInterface
	FeatureFlagUserLists                FeatureFlagUserListsServiceInterface
	Features                            FeaturesServiceInterface
	FreezePeriods                       FreezePeriodsServiceInterface
	GenericPackages                     GenericPackagesServiceInterface
	GeoNodes                            GeoNodesServiceInterface
	GeoSites                            GeoSitesServiceInterface
	GitIgnoreTemplates                  GitIgnoreTemplatesServiceInterface
	GroupAccessTokens                   GroupAccessTokensServiceInterface
	GroupActivityAnalytics              GroupActivityAnalyticsServiceInterface
	GroupBadges                         GroupBadgesServiceInterface
	GroupCluster                        GroupClustersServiceInterface
	GroupCredentials                    GroupCredentialsServiceInterface
	GroupEpicBoards                     GroupEpicBoardsServiceInterface
	GroupImportExport                   GroupImportExportServiceInterface
	Integrations                        IntegrationsServiceInterface
	GroupIssueBoards                    GroupIssueBoardsServiceInterface
	GroupIterations                     GroupIterationsServiceInterface
	GroupLabels                         GroupLabelsServiceInterface
	GroupMarkdownUploads                GroupMarkdownUploadsServiceInterface
	GroupMembers                        GroupMembersServiceInterface
	GroupMilestones                     GroupMilestonesServiceInterface
	GroupProtectedEnvironments          GroupProtectedEnvironmentsServiceInterface
	GroupProtectedBranches              GroupProtectedBranchesServiceInterface
	GroupRelationsExport                GroupRelationsExportServiceInterface
	GroupReleases                       GroupReleasesServiceInterface
	GroupRepositoryStorageMove          GroupRepositoryStorageMoveServiceInterface
	GroupSCIM                           GroupSCIMServiceInterface
	GroupSecuritySettings               GroupSecuritySettingsServiceInterface
	GroupSSHCertificates                GroupSSHCertificatesServiceInterface
	GroupVariables                      GroupVariablesServiceInterface
	GroupWikis                          GroupWikisServiceInterface
	Groups                              GroupsServiceInterface
	Import                              ImportServiceInterface
	InstanceCluster                     InstanceClustersServiceInterface
	InstanceVariables                   InstanceVariablesServiceInterface
	Invites                             InvitesServiceInterface
	IssueLinks                          IssueLinksServiceInterface
	Issues                              IssuesServiceInterface
	IssuesStatistics                    IssuesStatisticsServiceInterface
	Jobs                                JobsServiceInterface
	JobTokenScope                       JobTokenScopeServiceInterface
	Keys                                KeysServiceInterface
	Labels                              LabelsServiceInterface
	License                             LicenseServiceInterface
	LicenseTemplates                    LicenseTemplatesServiceInterface
	Markdown                            MarkdownServiceInterface
	MemberRolesService                  MemberRolesServiceInterface
	MergeRequestApprovals               MergeRequestApprovalsServiceInterface
	MergeRequestApprovalSettings        MergeRequestApprovalSettingsServiceInterface
	MergeRequestContextCommits          MergeRequestContextCommitsServiceInterface
	MergeRequests                       MergeRequestsServiceInterface
	MergeTrains                         MergeTrainsServiceInterface
	Metadata                            MetadataServiceInterface
	Milestones                          MilestonesServiceInterface
	ModelRegistry                       ModelRegistryServiceInterface
	Namespaces                          NamespacesServiceInterface
	Notes                               NotesServiceInterface
	NotificationSettings                NotificationSettingsServiceInterface
	Orbit                               OrbitServiceInterface
	Packages                            PackagesServiceInterface
	Pages                               PagesServiceInterface
	PagesDomains                        PagesDomainsServiceInterface
	PersonalAccessTokens                PersonalAccessTokensServiceInterface
	PipelineSchedules                   PipelineSchedulesServiceInterface
	PipelineTriggers                    PipelineTriggersServiceInterface
	Pipelines                           PipelinesServiceInterface
	PlanLimits                          PlanLimitsServiceInterface
	ProjectAccessTokens                 ProjectAccessTokensServiceInterface
	ProjectAliases                      ProjectAliasesServiceInterface
	ProjectBadges                       ProjectBadgesServiceInterface
	ProjectCluster                      ProjectClustersServiceInterface
	ProjectFeatureFlags                 ProjectFeatureFlagServiceInterface
	ProjectImportExport                 ProjectImportExportServiceInterface
	ProjectIterations                   ProjectIterationsServiceInterface
	ProjectMarkdownUploads              ProjectMarkdownUploadsServiceInterface
	ProjectMembers                      ProjectMembersServiceInterface
	ProjectMirrors                      ProjectMirrorServiceInterface
	ProjectRepositoryStorageMove        ProjectRepositoryStorageMoveServiceInterface
	ProjectSecuritySettings             ProjectSecuritySettingsServiceInterface
	ProjectSnippets                     ProjectSnippetsServiceInterface
	ProjectStatistics                   ProjectStatisticsServiceInterface
	ProjectTemplates                    ProjectTemplatesServiceInterface
	ProjectVariables                    ProjectVariablesServiceInterface
	ProjectVulnerabilities              ProjectVulnerabilitiesServiceInterface
	Projects                            ProjectsServiceInterface
	ProtectedBranches                   ProtectedBranchesServiceInterface
	ProtectedEnvironments               ProtectedEnvironmentsServiceInterface
	ProtectedPackages                   ProtectedPackagesServiceInterface
	ProtectedTags                       ProtectedTagsServiceInterface
	ReleaseLinks                        ReleaseLinksServiceInterface
	Releases                            ReleasesServiceInterface
	Repositories                        RepositoriesServiceInterface
	RepositoryFiles                     RepositoryFilesServiceInterface
	RepositorySubmodules                RepositorySubmodulesServiceInterface
	ResourceGroup                       ResourceGroupServiceInterface
	ResourceIterationEvents             ResourceIterationEventsServiceInterface
	ResourceLabelEvents                 ResourceLabelEventsServiceInterface
	ResourceMilestoneEvents             ResourceMilestoneEventsServiceInterface
	ResourceStateEvents                 ResourceStateEventsServiceInterface
	ResourceWeightEvents                ResourceWeightEventsServiceInterface
	RunnerControllers                   RunnerControllersServiceInterface
	RunnerControllerScopes              RunnerControllerScopesServiceInterface
	RunnerControllerTokens              RunnerControllerTokensServiceInterface
	Runners                             RunnersServiceInterface
	Search                              SearchServiceInterface
	SecurityAttributes                  SecurityAttributesServiceInterface
	SecurityCategories                  SecurityCategoriesServiceInterface
	SecurityScanProfiles                SecurityScanProfilesServiceInterface
	SecureFiles                         SecureFilesServiceInterface
	Services                            ServicesServiceInterface
	Settings                            SettingsServiceInterface
	Sidekiq                             SidekiqServiceInterface
	SnippetRepositoryStorageMove        SnippetRepositoryStorageMoveServiceInterface
	Snippets                            SnippetsServiceInterface
	SystemHooks                         SystemHooksServiceInterface
	Tags                                TagsServiceInterface
	TerraformStates                     TerraformStatesServiceInterface
	Todos                               TodosServiceInterface
	Topics                              TopicsServiceInterface
	UsageData                           UsageDataServiceInterface
	Users                               UsersServiceInterface
	Validate                            ValidateServiceInterface
	Version                             VersionServiceInterface
	Wikis                               WikisServiceInterface
	WorkItems                           WorkItemsServiceInterface
}

// Interceptor is used to build a *http.Client request pipeline,
//
// It receives the next RoundTripper in the chain and returns a new one that
// will be used for the request.
//
// This next RoundTripper might or might not be the actual transporter,
// which actually does the request call,
// but it is safe to assume that calling the next will result in the expected HTTP call.
//
// Example:
//
//	// Simple logger interceptor.
//	logger := func(next http.RoundTripper) http.RoundTripper {
//	    return roundtripperFunc(func(req *http.Request) (*http.Response, error) {
//	        fmt.Printf("Request: %s %s\n", req.Method, req.URL)
//	        resp, err := next.RoundTrip(req)
//	        if err == nil {
//	            fmt.Printf("Response status: %d\n", resp.StatusCode)
//	        }
//	        return resp, err
//	    })
//	}
//
// The Interceptor type lets you add such middlewares to a client by chaining them.
type Interceptor func(next http.RoundTripper) http.RoundTripper

// ListOptions specifies the optional parameters to various List methods that
// support pagination.
type ListOptions struct {
	// For keyset-based paginated result sets, the value must be `"keyset"`
	Pagination string `url:"pagination,omitempty" json:"pagination,omitempty"`
	// For offset-based and keyset-based paginated result sets, the number of results to include per page.
	PerPage int64 `url:"per_page,omitempty" json:"per_page,omitempty"`
	// For offset-based paginated result sets, page of results to retrieve.
	Page int64 `url:"page,omitempty" json:"page,omitempty"`
	// For keyset-based paginated result sets, tree record ID at which to fetch the next page.
	PageToken string `url:"page_token,omitempty" json:"page_token,omitempty"`
	// For keyset-based paginated result sets, name of the column by which to order
	OrderBy string `url:"order_by,omitempty" json:"order_by,omitempty"`
	// For keyset-based paginated result sets, sort order (`"asc"`` or `"desc"`)
	Sort string `url:"sort,omitempty" json:"sort,omitempty"`
}

// RateLimiter describes the interface that all (custom) rate limiters must implement.
type RateLimiter interface {
	Wait(context.Context) error
}

// NewClient returns a new GitLab API client. To use API methods which require
// authentication, provide a valid private or personal token.
func NewClient(token string, options ...ClientOptionFunc) (*Client, error) {
	as := AccessTokenAuthSource{Token: token}
	return NewAuthSourceClient(as, options...)
}

// NewBasicAuthClient returns a new GitLab API client using the OAuth 2.0 Resource Owner Password Credentials flow.
// The provided username and password are used to obtain an OAuth access token
// from GitLab's token endpoint on the first API request. The token is then
// cached, reused for subsequent requests, and refreshed when expired.
//
// The Resource Owner Password Credentials flow is only suitable for trusted,
// first-party applications and does not work for users who have two-factor
// authentication enabled.
//
// Note: This method uses OAuth tokens with Bearer authentication, not HTTP Basic Auth.
//
// Deprecated: GitLab recommends against using this authentication method.
func NewBasicAuthClient(username, password string, options ...ClientOptionFunc) (*Client, error) {
	as := &PasswordCredentialsAuthSource{
		Username: username,
		Password: password,
	}

	return NewAuthSourceClient(as, options...)
}

// NewJobClient returns a new GitLab API client. To use API methods which require
// authentication, provide a valid job token.
func NewJobClient(token string, options ...ClientOptionFunc) (*Client, error) {
	as := JobTokenAuthSource{Token: token}
	return NewAuthSourceClient(as, options...)
}

// NewOAuthClient returns a new GitLab API client using a static OAuth bearer token for authentication.
//
// Deprecated: use NewAuthSourceClient with a StaticTokenSource instead. For example:
//
//	ts := oauth2.StaticTokenSource(
//	    &oauth2.Token{AccessToken: "YOUR STATIC TOKEN"},
//	)
//	c, err := gitlab.NewAuthSourceClient(gitlab.OAuthTokenSource{ts})
func NewOAuthClient(token string, options ...ClientOptionFunc) (*Client, error) {
	as := OAuthTokenSource{
		TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}),
	}

	return NewAuthSourceClient(as, options...)
}

// NewAuthSourceClient returns a new GitLab API client that uses the AuthSource for authentication.
func NewAuthSourceClient(as AuthSource, options ...ClientOptionFunc) (*Client, error) {
	c := &Client{
		UserAgent:          userAgent,
		authSource:         as,
		authSourceStrategy: &AllHeadersAuthStrategy{},
		urlWarningLogger:   slog.Default(),
	}

	// Configure the HTTP client.
	c.client = &retryablehttp.Client{
		Backoff:      c.retryHTTPBackoff,
		CheckRetry:   c.retryHTTPCheck,
		ErrorHandler: retryablehttp.PassthroughErrorHandler,
		HTTPClient:   cleanhttp.DefaultPooledClient(),
		RetryWaitMin: 100 * time.Millisecond,
		RetryWaitMax: 400 * time.Millisecond,
		RetryMax:     5,
	}

	// Set the default base URL.
	c.setBaseURL(defaultBaseURL)

	// Apply any given client options.
	for _, fn := range options {
		if fn == nil {
			continue
		}
		if err := fn(c); err != nil {
			return nil, err
		}
	}

	decorateHTTPClientTransportWithInterceptors(c)

	// Wire up the cookie jar.
	// The ClientOptionFunc can't do it directly,
	// because the user may also specify HTTPClient
	// and we want the order of passing those to matter
	// as little as possible.
	if c.jar != nil {
		if c.client.HTTPClient.Jar != nil {
			return nil, errors.New("conflicting option functions when creating client. The provided HTTP Client already has a Jar configured, therefore you can't use gitlab.WithCookieJar")
		}
		c.client.HTTPClient.Jar = c.jar
	}

	// If no custom limiter was set using a client option, configure
	// the default rate limiter with values that implicitly disable
	// rate limiting until an initial HTTP call is done and we can
	// use the headers to try and properly configure the limiter.
	if c.limiter == nil {
		c.limiter = rate.NewLimiter(rate.Inf, 0)
	}

	// Create the internal timeStats service.
	timeStats := &timeStatsService{client: c}

	// GraphQL interface
	c.GraphQL = &GraphQL{client: c}

	// Create all the public services.
	c.AccessRequests = &AccessRequestsService{client: c}
	c.AdminCompliancePolicySettings = &AdminCompliancePolicySettingsService{client: c}
	c.AlertManagement = &AlertManagementService{client: c}
	c.Appearance = &AppearanceService{client: c}
	c.Applications = &ApplicationsService{client: c}
	c.ApplicationStatistics = &ApplicationStatisticsService{client: c}
	c.Attestations = &AttestationsService{client: c}
	c.AuditEvents = &AuditEventsService{client: c}
	c.Avatar = &AvatarRequestsService{client: c}
	c.AwardEmoji = &AwardEmojiService{client: c}
	c.Boards = &IssueBoardsService{client: c}
	c.Branches = &BranchesService{client: c}
	c.BroadcastMessage = &BroadcastMessagesService{client: c}
	c.BulkImports = &BulkImportsService{client: c}
	c.CIYMLTemplate = &CIYMLTemplatesService{client: c}
	c.ClusterAgents = &ClusterAgentsService{client: c}
	c.Commits = &CommitsService{client: c}
	c.ContainerRegistry = &ContainerRegistryService{client: c}
	c.ContainerRegistryProtectionRules = &ContainerRegistryProtectionRulesService{client: c}
	c.ContainerRegistryTagProtectionRules = &ContainerRegistryTagProtectionRulesService{client: c}
	c.CustomAttribute = &CustomAttributesService{client: c}
	c.DatabaseMigrations = &DatabaseMigrationsService{client: c}
	c.Dependencies = &DependenciesService{client: c}
	c.DependencyListExport = &DependencyListExportService{client: c}
	c.DependencyProxy = &DependencyProxyService{client: c}
	c.DeployKeys = &DeployKeysService{client: c}
	c.DeployTokens = &DeployTokensService{client: c}
	c.DeploymentMergeRequests = &DeploymentMergeRequestsService{client: c}
	c.Deployments = &DeploymentsService{client: c}
	c.Discussions = &DiscussionsService{client: c}
	c.DockerfileTemplate = &DockerfileTemplatesService{client: c}
	c.DORAMetrics = &DORAMetricsService{client: c}
	c.DraftNotes = &DraftNotesService{client: c}
	c.EnterpriseUsers = &EnterpriseUsersService{client: c}
	c.Environments = &EnvironmentsService{client: c}
	c.EpicIssues = &EpicIssuesService{client: c}
	c.Epics = &EpicsService{client: c}
	c.ErrorTracking = &ErrorTrackingService{client: c}
	c.Events = &EventsService{client: c}
	c.ExternalStatusChecks = &ExternalStatusChecksService{client: c}
	c.FeatureFlagUserLists = &FeatureFlagUserListsService{client: c}
	c.Features = &FeaturesService{client: c}
	c.FreezePeriods = &FreezePeriodsService{client: c}
	c.GenericPackages = &GenericPackagesService{client: c}
	c.GeoNodes = &GeoNodesService{client: c}
	c.GeoSites = &GeoSitesService{client: c}
	c.GitIgnoreTemplates = &GitIgnoreTemplatesService{client: c}
	c.GroupAccessTokens = &GroupAccessTokensService{client: c}
	c.GroupActivityAnalytics = &GroupActivityAnalyticsService{client: c}
	c.GroupBadges = &GroupBadgesService{client: c}
	c.GroupCluster = &GroupClustersService{client: c}
	c.GroupCredentials = &GroupCredentialsService{client: c}
	c.GroupEpicBoards = &GroupEpicBoardsService{client: c}
	c.GroupImportExport = &GroupImportExportService{client: c}
	c.Integrations = &IntegrationsService{client: c}
	c.GroupIssueBoards = &GroupIssueBoardsService{client: c}
	c.GroupIterations = &GroupIterationsService{client: c}
	c.GroupLabels = &GroupLabelsService{client: c}
	c.GroupMarkdownUploads = &GroupMarkdownUploadsService{client: c}
	c.GroupMembers = &GroupMembersService{client: c}
	c.GroupMilestones = &GroupMilestonesService{client: c}
	c.GroupProtectedEnvironments = &GroupProtectedEnvironmentsService{client: c}
	c.GroupProtectedBranches = &GroupProtectedBranchesService{client: c}
	c.GroupRelationsExport = &GroupRelationsExportService{client: c}
	c.GroupReleases = &GroupReleasesService{client: c}
	c.GroupRepositoryStorageMove = &GroupRepositoryStorageMoveService{client: c}
	c.GroupSCIM = &GroupSCIMService{client: c}
	c.GroupSecuritySettings = &GroupSecuritySettingsService{client: c}
	c.GroupSSHCertificates = &GroupSSHCertificatesService{client: c}
	c.GroupVariables = &GroupVariablesService{client: c}
	c.GroupWikis = &GroupWikisService{client: c}
	c.Groups = &GroupsService{client: c}
	c.Import = &ImportService{client: c}
	c.InstanceCluster = &InstanceClustersService{client: c}
	c.InstanceVariables = &InstanceVariablesService{client: c}
	c.Invites = &InvitesService{client: c}
	c.IssueLinks = &IssueLinksService{client: c}
	c.Issues = &IssuesService{client: c, timeStats: timeStats}
	c.IssuesStatistics = &IssuesStatisticsService{client: c}
	c.Jobs = &JobsService{client: c}
	c.JobTokenScope = &JobTokenScopeService{client: c}
	c.Keys = &KeysService{client: c}
	c.Labels = &LabelsService{client: c}
	c.License = &LicenseService{client: c}
	c.LicenseTemplates = &LicenseTemplatesService{client: c}
	c.Markdown = &MarkdownService{client: c}
	c.MemberRolesService = &MemberRolesService{client: c}
	c.MergeRequestApprovals = &MergeRequestApprovalsService{client: c}
	c.MergeRequestApprovalSettings = &MergeRequestApprovalSettingsService{client: c}
	c.MergeRequestContextCommits = &MergeRequestContextCommitsService{client: c}
	c.MergeRequests = &MergeRequestsService{client: c, timeStats: timeStats}
	c.MergeTrains = &MergeTrainsService{client: c}
	c.Metadata = &MetadataService{client: c}
	c.Milestones = &MilestonesService{client: c}
	c.ModelRegistry = &ModelRegistryService{client: c}
	c.Namespaces = &NamespacesService{client: c}
	c.Notes = &NotesService{client: c}
	c.NotificationSettings = &NotificationSettingsService{client: c}
	c.Orbit = &OrbitService{client: c}
	c.Packages = &PackagesService{client: c}
	c.Pages = &PagesService{client: c}
	c.PagesDomains = &PagesDomainsService{client: c}
	c.PersonalAccessTokens = &PersonalAccessTokensService{client: c}
	c.PipelineSchedules = &PipelineSchedulesService{client: c}
	c.PipelineTriggers = &PipelineTriggersService{client: c}
	c.Pipelines = &PipelinesService{client: c}
	c.PlanLimits = &PlanLimitsService{client: c}
	c.ProjectAccessTokens = &ProjectAccessTokensService{client: c}
	c.ProjectAliases = &ProjectAliasesService{client: c}
	c.ProjectBadges = &ProjectBadgesService{client: c}
	c.ProjectCluster = &ProjectClustersService{client: c}
	c.ProjectFeatureFlags = &ProjectFeatureFlagService{client: c}
	c.ProjectImportExport = &ProjectImportExportService{client: c}
	c.ProjectIterations = &ProjectIterationsService{client: c}
	c.ProjectMarkdownUploads = &ProjectMarkdownUploadsService{client: c}
	c.ProjectMembers = &ProjectMembersService{client: c}
	c.ProjectMirrors = &ProjectMirrorService{client: c}
	c.ProjectRepositoryStorageMove = &ProjectRepositoryStorageMoveService{client: c}
	c.ProjectSecuritySettings = &ProjectSecuritySettingsService{client: c}
	c.ProjectSnippets = &ProjectSnippetsService{client: c}
	c.ProjectStatistics = &ProjectStatisticsService{client: c}
	c.ProjectTemplates = &ProjectTemplatesService{client: c}
	c.ProjectVariables = &ProjectVariablesService{client: c}
	c.ProjectVulnerabilities = &ProjectVulnerabilitiesService{client: c}
	c.Projects = &ProjectsService{client: c}
	c.ProtectedBranches = &ProtectedBranchesService{client: c}
	c.ProtectedEnvironments = &ProtectedEnvironmentsService{client: c}
	c.ProtectedPackages = &ProtectedPackagesService{client: c}
	c.ProtectedTags = &ProtectedTagsService{client: c}
	c.ReleaseLinks = &ReleaseLinksService{client: c}
	c.Releases = &ReleasesService{client: c}
	c.Repositories = &RepositoriesService{client: c}
	c.RepositoryFiles = &RepositoryFilesService{client: c}
	c.RepositorySubmodules = &RepositorySubmodulesService{client: c}
	c.ResourceGroup = &ResourceGroupService{client: c}
	c.ResourceIterationEvents = &ResourceIterationEventsService{client: c}
	c.ResourceLabelEvents = &ResourceLabelEventsService{client: c}
	c.ResourceMilestoneEvents = &ResourceMilestoneEventsService{client: c}
	c.ResourceStateEvents = &ResourceStateEventsService{client: c}
	c.ResourceWeightEvents = &ResourceWeightEventsService{client: c}
	c.RunnerControllers = &RunnerControllersService{client: c}
	c.RunnerControllerScopes = &RunnerControllerScopesService{client: c}
	c.RunnerControllerTokens = &RunnerControllerTokensService{client: c}
	c.Runners = &RunnersService{client: c}
	c.Search = &SearchService{client: c}
	c.SecurityAttributes = &SecurityAttributesService{client: c}
	c.SecurityCategories = &SecurityCategoriesService{client: c}
	c.SecurityScanProfiles = &SecurityScanProfilesService{client: c}
	c.SecureFiles = &SecureFilesService{client: c}
	c.Services = &ServicesService{client: c}
	c.Settings = &SettingsService{client: c}
	c.Sidekiq = &SidekiqService{client: c}
	c.Snippets = &SnippetsService{client: c}
	c.SnippetRepositoryStorageMove = &SnippetRepositoryStorageMoveService{client: c}
	c.SystemHooks = &SystemHooksService{client: c}
	c.Tags = &TagsService{client: c}
	c.TerraformStates = &TerraformStatesService{client: c}
	c.Todos = &TodosService{client: c}
	c.Topics = &TopicsService{client: c}
	c.UsageData = &UsageDataService{client: c}
	c.Users = &UsersService{client: c}
	c.Validate = &ValidateService{client: c}
	c.Version = &VersionService{client: c}
	c.Wikis = &WikisService{client: c}
	c.WorkItems = &WorkItemsService{client: c}

	return c, nil
}

func decorateHTTPClientTransportWithInterceptors(c *Client) {
	if len(c.interceptors) == 0 {
		return
	}
	c.client.HTTPClient.Transport = chainInterceptors(c.client.HTTPClient.Transport, c.interceptors...)
}

func chainInterceptors(rt http.RoundTripper, interceptors ...Interceptor) http.RoundTripper {
	for _, i := range slices.Backward(interceptors) {
		rt = i(rt)
	}
	return rt
}

func (c *Client) HTTPClient() *http.Client {
	return c.client.HTTPClient
}

// retryHTTPCheck provides a callback for Client.CheckRetry which
// respects default retries and retries what is safe to retry.
func (c *Client) retryHTTPCheck(ctx context.Context, resp *http.Response, err error) (bool, error) {
	// do not retry if retries are disabled completely
	if c.disableRetries {
		return false, nil
	}

	// do not retry on context.Canceled or context.DeadlineExceeded
	if ctx.Err() != nil {
		return false, ctx.Err()
	}

	if err != nil {
		// We should be able to retry requests with assumed idempotent HTTP methods.
		// In a future iteration we might want to annotate requests that are idempotent
		// to further improve this logic here.
		if resp != nil && resp.Request != nil {
			switch resp.Request.Method {
			case http.MethodConnect, http.MethodOptions, http.MethodTrace, http.MethodHead, http.MethodGet:
				return true, nil
			}
		}

		// Only retry errors that we know happened before writing to the wire
		var urlErr *url.Error
		var netOpErr *net.OpError
		var dnsErr *net.DNSError
		// see Go src/net/http/transport.go
		var potentialTLSHandshakeErr interface {
			Timeout() bool
			Temporary() bool
		}

		switch {
		// DNS errors are safe - they happen before any connection
		case errors.As(err, &dnsErr):
			// NXDOMAIN should not be retried
			if dnsErr.IsNotFound {
				return false, err
			}
			// Other DNS errors are safe to retry
			return true, nil

		// Direct net.OpError for dial operations
		case errors.As(err, &netOpErr):
			// from the comments in the implementation of net.OpError.Temporary
			// it seems that it's safe to retry temporary OpErrors.
			if netOpErr.Temporary() {
				return true, nil
			}

			if strings.EqualFold(netOpErr.Op, "dial") {
				return true, nil
			}

		// Connection refused errors are safe - they happen at TCP establishment
		case errors.As(err, &urlErr):
			if strings.Contains(urlErr.Error(), "connection refused") {
				return true, nil
			}

		// TLS handshake errors are safe if we can identify them
		case errors.As(err, &potentialTLSHandshakeErr):
			// Check if this is a TLS handshake timeout specifically
			if strings.Contains(err.Error(), "net/http: TLS handshake timeout") {
				return true, nil
			}
		}

		// we are conservative here and do not want to retry any "unknown" errors, because
		// they could have happened when the connection was already established and the request
		// partially fulfilled. We don't have the insights here if the request
		// was idempotent or not, so we reject a retry.
		return false, nil
	}

	// 429 Too Many Requests is recoverable. Sometimes the server puts
	// a Retry-After response header to indicate when the server is
	// available to start processing request from client.
	if resp.StatusCode == http.StatusTooManyRequests {
		return true, nil
	}

	// Check the response code. We retry on 500-range responses to allow
	// the server time to recover, as 500's are typically not permanent
	// errors and may relate to outages on the server side. This will catch
	// invalid response codes as well, like 0 and 999.
	// Status code 0 is especially important for AWS ALB:
	// https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html#response-code-000
	if resp.StatusCode == 0 || (resp.StatusCode >= 500 && resp.StatusCode != http.StatusNotImplemented) {
		// return true, fmt.Errorf("unexpected HTTP status %s", resp.Status)
		return true, nil
	}

	return false, nil
}

// retryHTTPBackoff provides a generic callback for Client.Backoff which
// will pass through all calls based on the status code of the response.
func (c *Client) retryHTTPBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
	// Use the rate limit backoff function when we are rate limited.
	if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
		return rateLimitBackoff(min, max, attemptNum, resp)
	}

	// Set custom duration's when we experience a service interruption.
	min = 700 * time.Millisecond
	max = 900 * time.Millisecond

	return retryablehttp.LinearJitterBackoff(min, max, attemptNum, resp)
}

// rateLimitBackoff provides a callback for Client.Backoff which will use the
// Ratelimit-Reset header to determine the time to wait. We add some jitter
// to prevent a thundering herd.
//
// min and max are mainly used for bounding the jitter that will be added to
// the reset time retrieved from the headers. But if the final wait time is
// less than min, min will be used instead.
func rateLimitBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
	// First create some jitter bounded by the min and max durations.
	jitter := time.Duration(rand.Float64() * float64(max-min))

	if resp != nil {
		if v := resp.Header.Get(headerRateReset); v != "" {
			if reset, _ := strconv.ParseInt(v, 10, 64); reset > 0 {
				// Only update min if the given time to wait is longer.
				if wait := time.Until(time.Unix(reset, 0)); wait > min {
					min = wait
				}
			}
		} else {
			// In case the Ratelimit-Reset header is not set, back off an additional
			// 100% exponentially. With the default milliseconds being set to 100 for
			// `min`, this makes the 5th retry wait 3.2 seconds (3,200 ms) by default.
			min = time.Duration(float64(min) * math.Pow(2, float64(attemptNum)))
		}
	}

	return min + jitter
}

// configureLimiter configures the rate limiter.
func (c *Client) configureLimiter(ctx context.Context, headers http.Header) {
	if v := headers.Get(headerRateLimit); v != "" {
		if rateLimit, _ := strconv.ParseFloat(v, 64); rateLimit > 0 {
			// The rate limit is based on requests per minute, so for our limiter to
			// work correctly we divide the limit by 60 to get the limit per second.
			rateLimit /= 60

			// Configure the limit and burst using a split of 2/3 for the limit and
			// 1/3 for the burst. This enables clients to burst 1/3 of the allowed
			// calls before the limiter kicks in. The remaining calls will then be
			// spread out evenly using intervals of time.Second / limit which should
			// prevent hitting the rate limit.
			limit := rate.Limit(rateLimit * 0.66)
			burst := int(rateLimit * 0.33)

			// Need at least one allowed to burst or x/time will throw an error
			if burst == 0 {
				burst = 1
			}

			// Create a new limiter using the calculated values.
			c.limiter = rate.NewLimiter(limit, burst)

			// Call the limiter once as we have already made a request
			// to get the headers and the limiter is not aware of this.
			c.limiter.Wait(ctx)
		}
	}
}

// BaseURL return a copy of the baseURL.
func (c *Client) BaseURL() *url.URL {
	u := *c.baseURL
	return &u
}

// validateBaseURL checks for common real-world mistakes and returns them as errors.
// Returns the parsed URL if validation succeeds.
func validateBaseURL(baseURL string) (*url.URL, error) {
	if baseURL == "" {
		return nil, &URLValidationError{
			URL:  baseURL,
			Err:  errors.New("empty URL"),
			Hint: `provide a valid GitLab instance URL (e.g., "https://gitlab.com")`,
		}
	}

	if !strings.Contains(baseURL, "://") {
		return nil, &URLValidationError{
			URL:  baseURL,
			Err:  errors.New("missing scheme"),
			Hint: fmt.Sprintf(`try "https://%s"`, baseURL),
		}
	}

	parsedURL, err := url.Parse(baseURL)
	if err != nil {
		return nil, &URLValidationError{
			URL: baseURL,
			Err: err,
			Hint: `possible issues:
		  - missing hostname
		  - invalid characters/spaces
		  - invalid port (must be 1-65535)
		  - query parameters (?)
		  - fragments (#)
		  - invalid URL encoding`,
		}
	}

	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { //nolint:goconst
		return nil, &URLValidationError{
			URL:  baseURL,
			Err:  fmt.Errorf("unsupported scheme %q", parsedURL.Scheme),
			Hint: fmt.Sprintf(`GitLab API requires http or https (try "https://%s")`, parsedURL.Host),
		}
	}

	return parsedURL, nil
}

// setBaseURL sets the base URL for API requests to a custom endpoint.
func (c *Client) setBaseURL(urlStr string) error {
	// Make sure the given URL ends with a slash
	if !strings.HasSuffix(urlStr, "/") {
		urlStr += "/"
	}

	// Validate and parse
	baseURL, err := validateBaseURL(urlStr)
	if err != nil {
		// Log the validation warning
		c.urlWarningLogger.Warn("URL validation warning", "url", urlStr, "error", err)

		// Don't return the error - just warn and continue
		// Try to parse anyway as a fallback
		baseURL, err = url.Parse(urlStr)
		if err != nil {
			// If we really can't parse it, we have to give up
			return fmt.Errorf("failed to parse base URL: %w", err)
		}
	}

	if !strings.HasSuffix(baseURL.Path, apiVersionPath) {
		baseURL.Path += apiVersionPath
	}

	// Update the base URL of the client.
	c.baseURL = baseURL

	return nil
}

// NewRequest creates a new API request. The method expects a relative URL
// path that will be resolved relative to the base URL of the Client.
// Relative URL paths should always be specified without a preceding slash.
// If specified, the value pointed to by body is JSON encoded and included
// as the request body.
func (c *Client) NewRequest(method, path string, opt any, options []RequestOptionFunc) (*retryablehttp.Request, error) {
	u := *c.baseURL
	unescaped, err := url.PathUnescape(path)
	if err != nil {
		return nil, err
	}

	// Set the encoded path data
	u.RawPath = c.baseURL.Path + path
	u.Path = c.baseURL.Path + unescaped

	return c.NewRequestToURL(method, &u, opt, options)
}

func (c *Client) NewRequestToURL(method string, u *url.URL, opt any, options []RequestOptionFunc) (*retryablehttp.Request, error) {
	if u.Scheme != c.baseURL.Scheme || u.Host != c.baseURL.Host {
		return nil, fmt.Errorf("client only allows requests to URLs matching the clients configured base URL. Got %q, base URL is %q", u.String(), c.baseURL.String())
	}

	// Create a request specific headers map.
	reqHeaders := make(http.Header)
	reqHeaders.Set("Accept", "application/json")

	if c.UserAgent != "" {
		reqHeaders.Set("User-Agent", c.UserAgent)
	}

	var body any
	switch {
	case method == http.MethodPatch || method == http.MethodPost || method == http.MethodPut:
		reqHeaders.Set("Content-Type", "application/json")

		if opt != nil {
			b, err := json.Marshal(opt)
			if err != nil {
				return nil, err
			}
			body = b
		}
	case opt != nil:
		q, err := query.Values(opt)
		if err != nil {
			return nil, err
		}
		u.RawQuery = q.Encode()
	}

	req, err := retryablehttp.NewRequest(method, u.String(), body)
	if err != nil {
		return nil, err
	}

	for _, fn := range append(c.defaultRequestOptions, options...) {
		if fn == nil {
			continue
		}
		if err := fn(req); err != nil {
			return nil, err
		}
	}

	// Set the request specific headers if they don't yet exist.
	for k, v := range reqHeaders {
		if _, ok := req.Header[k]; !ok {
			req.Header[k] = v
		}
	}

	return req, nil
}

// UploadRequest creates an API request for uploading a file. The method
// expects a relative URL path that will be resolved relative to the base
// URL of the Client. Relative URL paths should always be specified without
// a preceding slash. If specified, the value pointed to by body is JSON
// encoded and included as the request body.
func (c *Client) UploadRequest(method, path string, content io.Reader, filename string, uploadType UploadType, opt any, options []RequestOptionFunc) (*retryablehttp.Request, error) {
	u := *c.baseURL
	unescaped, err := url.PathUnescape(path)
	if err != nil {
		return nil, err
	}

	// Set the encoded path data
	u.RawPath = c.baseURL.Path + path
	u.Path = c.baseURL.Path + unescaped

	// Create a request specific headers map.
	reqHeaders := make(http.Header)
	reqHeaders.Set("Accept", "application/json")

	if c.UserAgent != "" {
		reqHeaders.Set("User-Agent", c.UserAgent)
	}

	b := new(bytes.Buffer)
	w := multipart.NewWriter(b)

	fw, err := w.CreateFormFile(string(uploadType), filename)
	if err != nil {
		return nil, err
	}

	if _, err := io.Copy(fw, content); err != nil {
		return nil, err
	}

	if opt != nil {
		fields, err := query.Values(opt)
		if err != nil {
			return nil, err
		}
		for name := range fields {
			if err = w.WriteField(name, fields.Get(name)); err != nil {
				return nil, err
			}
		}
	}

	if err = w.Close(); err != nil {
		return nil, err
	}

	reqHeaders.Set("Content-Type", w.FormDataContentType())

	req, err := retryablehttp.NewRequest(method, u.String(), b)
	if err != nil {
		return nil, err
	}

	for _, fn := range append(c.defaultRequestOptions, options...) {
		if fn == nil {
			continue
		}
		if err := fn(req); err != nil {
			return nil, err
		}
	}

	// Set the request specific headers.
	maps.Copy(req.Header, reqHeaders)

	return req, nil
}

// Response is a GitLab API response. This wraps the standard http.Response
// returned from GitLab and provides convenient access to things like
// pagination links.
type Response struct {
	*http.Response

	// Fields used for offset-based pagination.
	TotalItems   int64
	TotalPages   int64
	ItemsPerPage int64
	CurrentPage  int64
	NextPage     int64
	PreviousPage int64

	// Fields used for keyset-based pagination.
	PreviousLink string
	NextLink     string
	FirstLink    string
	LastLink     string

	// GraphQL pagination.
	PageInfo *PageInfo
}

// newResponse creates a new Response for the provided http.Response.
func newResponse(r *http.Response) *Response {
	response := &Response{Response: r}
	response.populatePageValues()
	response.populateLinkValues()
	return response
}

const (
	// Headers used for offset-based pagination.
	xTotal      = "X-Total"
	xTotalPages = "X-Total-Pages"
	xPerPage    = "X-Per-Page"
	xPage       = "X-Page"
	xNextPage   = "X-Next-Page"
	xPrevPage   = "X-Prev-Page"

	// Headers used for keyset-based pagination.
	linkPrev  = "prev"
	linkNext  = "next"
	linkFirst = "first"
	linkLast  = "last"
)

// populatePageValues parses the HTTP Link response headers and populates the
// various pagination link values in the Response.
func (r *Response) populatePageValues() {
	if totalItems := r.Header.Get(xTotal); totalItems != "" {
		r.TotalItems, _ = strconv.ParseInt(totalItems, 10, 64)
	}
	if totalPages := r.Header.Get(xTotalPages); totalPages != "" {
		r.TotalPages, _ = strconv.ParseInt(totalPages, 10, 64)
	}
	if itemsPerPage := r.Header.Get(xPerPage); itemsPerPage != "" {
		r.ItemsPerPage, _ = strconv.ParseInt(itemsPerPage, 10, 64)
	}
	if currentPage := r.Header.Get(xPage); currentPage != "" {
		r.CurrentPage, _ = strconv.ParseInt(currentPage, 10, 64)
	}
	if nextPage := r.Header.Get(xNextPage); nextPage != "" {
		r.NextPage, _ = strconv.ParseInt(nextPage, 10, 64)
	}
	if previousPage := r.Header.Get(xPrevPage); previousPage != "" {
		r.PreviousPage, _ = strconv.ParseInt(previousPage, 10, 64)
	}
}

func (r *Response) populateLinkValues() {
	if link := r.Header.Get("Link"); link != "" {
		for link := range strings.SplitSeq(link, ",") {
			parts := strings.Split(link, ";")
			if len(parts) < 2 {
				continue
			}

			linkType := strings.Trim(strings.Split(parts[1], "=")[1], "\"")
			linkValue := strings.Trim(parts[0], "< >")

			switch linkType {
			case linkPrev:
				r.PreviousLink = linkValue
			case linkNext:
				r.NextLink = linkValue
			case linkFirst:
				r.FirstLink = linkValue
			case linkLast:
				r.LastLink = linkValue
			}
		}
	}
}

// Do sends an API request and returns the API response. The API response is
// JSON decoded and stored in the value pointed to by v, or returned as an
// error if an API error has occurred. If v implements the io.Writer
// interface, the raw response body will be written to v, without attempting to
// first decode it. If v is a *bodyReader, the response body is preserved
// without copying and the caller is responsible for closing it.
func (c *Client) Do(req *retryablehttp.Request, v any) (*Response, error) {
	// Wait will block until the limiter can obtain a new token.
	err := c.limiter.Wait(req.Context())
	if err != nil {
		return nil, err
	}

	c.authSourceInit.Do(func() {
		err = c.authSource.Init(req.Context(), c)
	})
	if err != nil {
		return nil, fmt.Errorf("initializing token source failed: %w", err)
	}

	if err := c.authSourceStrategy.ApplyAuthHeader(c.authSource, req); err != nil {
		return nil, err
	}

	client := c.client

	if cr := checkRetryFromContext(req.Context()); cr != nil {
		// for avoid overwriting c.client. Use copy of c.client and apply checkRetry from request context
		client = c.newRetryableHTTPClientWithRetryCheck(cr)
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}

	// Check if v is a bodyReader before setting up the defer
	_, isReader := v.(*bodyReader)
	if !isReader {
		defer func() {
			io.Copy(io.Discard, resp.Body)
			resp.Body.Close()
		}()
	}

	// If not yet configured, try to configure the rate limiter
	// using the response headers we just received. Fail silently
	// so the limiter will remain disabled in case of an error.
	c.configureLimiterOnce.Do(func() { c.configureLimiter(req.Context(), resp.Header) })

	response := newResponse(resp)

	err = CheckResponse(resp)
	if err != nil {
		// Even though there was an error, we still return the response
		// in case the caller wants to inspect it further.
		// If using bodyReader, still need to close the body on error
		if isReader {
			io.Copy(io.Discard, resp.Body)
			resp.Body.Close()
		}
		return response, err
	}

	if v != nil {
		switch v := v.(type) {
		case *bodyReader:
			// Preserve the body without copying
			v.ReadCloser = resp.Body
		case io.Writer:
			_, err = io.Copy(v, resp.Body)
		default:
			err = json.NewDecoder(resp.Body).Decode(v)
		}
	}

	return response, err
}

func (c *Client) endpoint() oauth2.Endpoint {
	baseURL := strings.TrimSuffix(c.baseURL.String(), apiVersionPath)

	return oauth2.Endpoint{
		AuthURL:       baseURL + "oauth/authorize",
		TokenURL:      baseURL + "oauth/token",
		DeviceAuthURL: baseURL + "oauth/authorize_device",
	}
}

// ErrInvalidIDType is returned when a function expecting an ID as either an integer
// or string receives a different type. This error commonly occurs when working with
// GitLab resources like groups and projects which support both numeric IDs and
// path-based string identifiers.
var ErrInvalidIDType = errors.New("the ID must be an int or a string")

// Helper function to accept and format both the project ID or name as project
// identifier for all API calls.
func parseID(id any) (string, error) {
	switch v := id.(type) {
	case int:
		return strconv.Itoa(v), nil
	case int64:
		return strconv.FormatInt(v, 10), nil
	case string:
		return v, nil
	default:
		return "", fmt.Errorf("invalid ID type %#v, %w", id, ErrInvalidIDType)
	}
}

// PathEscape is a helper function to escape a project identifier.
func PathEscape(s string) string {
	return strings.ReplaceAll(url.PathEscape(s), ".", "%2E")
}

// An ErrorResponse reports one or more errors caused by an API request.
//
// GitLab API docs:
// https://docs.gitlab.com/api/rest/troubleshooting/
type ErrorResponse struct {
	StatusCode int
	Body       []byte
	Response   *http.Response
	Message    string
}

func (e *ErrorResponse) Error() string {
	if e.Response == nil {
		if e.Message != "" {
			return fmt.Sprintf("%d %s", e.StatusCode, e.Message)
		}
		return fmt.Sprintf("HTTP %d", e.StatusCode)
	}
	path := e.Response.Request.URL.RawPath
	if path == "" {
		path = e.Response.Request.URL.Path
	}
	url := fmt.Sprintf("%s://%s%s", e.Response.Request.URL.Scheme, e.Response.Request.URL.Host, path)

	if e.Message == "" {
		return fmt.Sprintf("%s %s: %d", e.Response.Request.Method, url, e.StatusCode)
	}
	return fmt.Sprintf("%s %s: %d %s", e.Response.Request.Method, url, e.StatusCode, e.Message)
}

func (e *ErrorResponse) HasStatusCode(statusCode int) bool {
	return e != nil && e.StatusCode == statusCode
}

// Is matches any *ErrorResponse by status code, so errors.Is(err, ErrNotFound)
// returns true for any response with a 404 status, regardless of pointer identity.
func (e *ErrorResponse) Is(target error) bool {
	var t *ErrorResponse
	if !errors.As(target, &t) {
		return false
	}
	return t.StatusCode != 0 && e.StatusCode == t.StatusCode
}

// CheckResponse checks the API response for errors, and returns them if present.
func CheckResponse(r *http.Response) error {
	switch r.StatusCode {
	case http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent, http.StatusNotModified:
		return nil
	case http.StatusNotFound:
		return ErrNotFound
	}

	errorResponse := &ErrorResponse{
		StatusCode: r.StatusCode,
		Response:   r,
	}

	data, err := io.ReadAll(r.Body)
	if err == nil && strings.TrimSpace(string(data)) != "" {
		errorResponse.Body = data

		var raw any
		if err := json.Unmarshal(data, &raw); err != nil {
			errorResponse.Message = fmt.Sprintf("failed to parse unknown error format: %s", data)
		} else {
			errorResponse.Message = parseError(raw)
		}
	}

	return errorResponse
}

// Format:
//
//	{
//	    "message": {
//	        "<property-name>": [
//	            "<error-message>",
//	            "<error-message>",
//	            ...
//	        ],
//	        "<embed-entity>": {
//	            "<property-name>": [
//	                "<error-message>",
//	                "<error-message>",
//	                ...
//	            ],
//	        }
//	    },
//	    "error": "<error-message>"
//	}
func parseError(raw any) string {
	switch raw := raw.(type) {
	case string:
		return raw

	case []any:
		var errs []string
		for _, v := range raw {
			errs = append(errs, parseError(v))
		}
		return fmt.Sprintf("[%s]", strings.Join(errs, ", "))

	case map[string]any:
		var errs []string
		for k, v := range raw {
			errs = append(errs, fmt.Sprintf("{%s: %s}", k, parseError(v)))
		}
		sort.Strings(errs)
		return strings.Join(errs, ", ")

	default:
		return fmt.Sprintf("failed to parse unexpected error type: %T", raw)
	}
}

func HasStatusCode(err error, statusCode int) bool {
	var errResponse *ErrorResponse
	if !errors.As(err, &errResponse) {
		return false
	}

	return errResponse.HasStatusCode(statusCode)
}

// newRetryableHTTPClientWithRetryCheck returns a `retryablehttp.Client` clone of itself with the given CheckRetry function
func (c *Client) newRetryableHTTPClientWithRetryCheck(cr retryablehttp.CheckRetry) *retryablehttp.Client {
	return &retryablehttp.Client{
		HTTPClient:     c.client.HTTPClient,
		Logger:         c.client.Logger,
		RetryWaitMin:   c.client.RetryWaitMin,
		RetryWaitMax:   c.client.RetryWaitMax,
		RetryMax:       c.client.RetryMax,
		RequestLogHook: c.client.RequestLogHook,
		CheckRetry:     cr,
		Backoff:        c.client.Backoff,
		ErrorHandler:   c.client.ErrorHandler,
		PrepareRetry:   c.client.PrepareRetry,
	}
}

// AuthSource is used to obtain access tokens.
type AuthSource interface {
	// Init is called once before making any requests.
	// If the token source needs access to client to initialize itself, it should do so here.
	Init(context.Context, *Client) error

	// Header returns an authentication header. When no error is returned, the
	// key and value should never be empty.
	Header(ctx context.Context) (key, value string, err error)
}

// OAuthTokenSource wraps an oauth2.TokenSource to implement the AuthSource interface.
type OAuthTokenSource struct {
	TokenSource oauth2.TokenSource
}

func (OAuthTokenSource) Init(context.Context, *Client) error {
	return nil
}

func (as OAuthTokenSource) Header(_ context.Context) (string, string, error) {
	t, err := as.TokenSource.Token()
	if err != nil {
		return "", "", err
	}

	return OAuthTokenHeaderName, "Bearer " + t.AccessToken, nil
}

// JobTokenAuthSource used as an AuthSource for CI Job Tokens
type JobTokenAuthSource struct {
	Token string
}

func (JobTokenAuthSource) Init(context.Context, *Client) error {
	return nil
}

func (s JobTokenAuthSource) Header(_ context.Context) (string, string, error) {
	return JobTokenHeaderName, s.Token, nil
}

// AccessTokenAuthSource used as an AuthSource for various access tokens, like Personal-, Project- and Group- Access Tokens.
// Can be used for all tokens that authorize with the Private-Token header.
type AccessTokenAuthSource struct {
	Token string
}

func (AccessTokenAuthSource) Init(context.Context, *Client) error {
	return nil
}

func (s AccessTokenAuthSource) Header(_ context.Context) (string, string, error) {
	return AccessTokenHeaderName, s.Token, nil
}

// PasswordCredentialsAuthSource implements the AuthSource interface for the OAuth 2.0
// resource owner password credentials flow.
type PasswordCredentialsAuthSource struct {
	Username string
	Password string

	AuthSource
}

func (as *PasswordCredentialsAuthSource) Init(ctx context.Context, client *Client) error {
	ctx = context.WithValue(ctx, oauth2.HTTPClient, client.client.HTTPClient)

	config := &oauth2.Config{
		Endpoint: client.endpoint(),
	}

	pct, err := config.PasswordCredentialsToken(ctx, as.Username, as.Password)
	if err != nil {
		return fmt.Errorf("PasswordCredentialsToken(%q, ******): %w", as.Username, err)
	}

	as.AuthSource = OAuthTokenSource{
		config.TokenSource(ctx, pct),
	}

	return nil
}

// Unauthenticated is an authentication source for unauthenticated clients
type Unauthenticated struct{}

func (Unauthenticated) Init(context.Context, *Client) error {
	return nil
}

func (u Unauthenticated) Header(context.Context) (string, string, error) {
	return "", "", errUnauthenticated
}

// AuthTokenStrategy - Specifies a strategy that injects authorization headers from the AuthSource.
type AuthTokenStrategy interface {
	ApplyAuthHeader(authSource AuthSource, req *retryablehttp.Request) error
}

// AllHeadersAuthStrategy - The default implementation for v2 of package.
// With this authentication strategy, all token headers specified in the client will be passed in the request
// and GitLab will determine which token to use. This means that if both a Job Token and a Private Token are
// provided, both will be sent in the headers.
type AllHeadersAuthStrategy struct{}

func (d AllHeadersAuthStrategy) ApplyAuthHeader(authSource AuthSource, req *retryablehttp.Request) error {
	authKey, authValue, err := authSource.Header(req.Context())
	if errors.Is(err, errUnauthenticated) {
		// we simply skip using an auth header
		return nil
	}
	if err != nil {
		return err
	}

	if v := req.Header.Values(authKey); len(v) == 0 {
		req.Header.Set(authKey, authValue)
	}

	return nil
}

// RequestLevelAuthStrategy defines an authentication strategy where tokens that are provided
// using functions such as `WithToken` will override any tokens provided in the client constructor.
// The "DefaultAuthStrategy" parameter can be used to define what token should be used if no token is provided
// on a given request. It will default to `AllHeadersAuthStrategy`
type RequestLevelAuthStrategy struct {
	DefaultAuthStrategy AuthTokenStrategy
}

func (a RequestLevelAuthStrategy) ApplyAuthHeader(authSource AuthSource, req *retryablehttp.Request) error {
	if value := req.Header.Get(AccessTokenHeaderName); value != "" {
		return nil
	}

	if value := req.Header.Get(OAuthTokenHeaderName); value != "" {
		return nil
	}

	if value := req.Header.Get(JobTokenHeaderName); value != "" {
		return nil
	}

	if a.DefaultAuthStrategy == nil {
		a.DefaultAuthStrategy = &AllHeadersAuthStrategy{}
	}

	return a.DefaultAuthStrategy.ApplyAuthHeader(authSource, req)
}
