package actions

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

	"github.com/go-logr/logr"
	"github.com/golang-jwt/jwt/v4"
	"github.com/google/uuid"
	"github.com/hashicorp/go-retryablehttp"
)

const (
	runnerEndpoint       = "_apis/distributedtask/pools/0/agents"
	scaleSetEndpoint     = "_apis/runtime/runnerscalesets"
	apiVersionQueryParam = "api-version=6.0-preview"
)

//go:generate mockery --inpackage --name=ActionsService
type ActionsService interface {
	GetRunnerScaleSet(ctx context.Context, runnerScaleSetName string) (*RunnerScaleSet, error)
	GetRunnerScaleSetById(ctx context.Context, runnerScaleSetId int) (*RunnerScaleSet, error)
	GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*RunnerGroup, error)
	CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error)

	CreateMessageSession(ctx context.Context, runnerScaleSetId int, owner string) (*RunnerScaleSetSession, error)
	DeleteMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) error
	RefreshMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) (*RunnerScaleSetSession, error)

	AcquireJobs(ctx context.Context, runnerScaleSetId int, messageQueueAccessToken string, requestIds []int64) ([]int64, error)
	GetAcquirableJobs(ctx context.Context, runnerScaleSetId int) (*AcquirableJobList, error)

	GetMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, lastMessageId int64) (*RunnerScaleSetMessage, error)
	DeleteMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, messageId int64) error

	GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *RunnerScaleSetJitRunnerSetting, scaleSetId int) (*RunnerScaleSetJitRunnerConfig, error)

	GetRunner(ctx context.Context, runnerId int64) (*RunnerReference, error)
	GetRunnerByName(ctx context.Context, runnerName string) (*RunnerReference, error)
	RemoveRunner(ctx context.Context, runnerId int64) error
}

type Client struct {
	*http.Client

	// lock for refreshing the ActionsServiceAdminToken and ActionsServiceAdminTokenExpiresAt
	mu sync.Mutex

	// TODO: Convert to unexported fields once refactor of Listener is complete
	ActionsServiceAdminToken          *string
	ActionsServiceAdminTokenExpiresAt *time.Time
	ActionsServiceURL                 *string

	retryMax     int
	retryWaitMax time.Duration

	creds           *ActionsAuth
	githubConfigURL string
	logger          logr.Logger
	userAgent       string
}

type ClientOption func(*Client)

func WithUserAgent(userAgent string) ClientOption {
	return func(c *Client) {
		c.userAgent = userAgent
	}
}

func WithLogger(logger logr.Logger) ClientOption {
	return func(c *Client) {
		c.logger = logger
	}
}

func WithRetryMax(retryMax int) ClientOption {
	return func(c *Client) {
		c.retryMax = retryMax
	}
}

func WithRetryWaitMax(retryWaitMax time.Duration) ClientOption {
	return func(c *Client) {
		c.retryWaitMax = retryWaitMax
	}
}

func NewClient(ctx context.Context, githubConfigURL string, creds *ActionsAuth, options ...ClientOption) (ActionsService, error) {
	ac := &Client{
		creds:           creds,
		githubConfigURL: githubConfigURL,
		logger:          logr.Discard(),

		// retryablehttp defaults
		retryMax:     4,
		retryWaitMax: 30 * time.Second,
	}

	for _, option := range options {
		option(ac)
	}

	retryClient := retryablehttp.NewClient()

	// TODO: this silences retryclient default logger, do we want to provide one
	// instead? by default retryablehttp logs all requests to stderr
	retryClient.Logger = log.New(io.Discard, "", log.LstdFlags)

	retryClient.RetryMax = ac.retryMax
	retryClient.RetryWaitMax = ac.retryWaitMax
	ac.Client = retryClient.StandardClient()

	rt, err := ac.getRunnerRegistrationToken(ctx, githubConfigURL, *creds)
	if err != nil {
		return nil, fmt.Errorf("failed to get runner registration token: %w", err)
	}

	adminConnInfo, err := ac.getActionsServiceAdminConnection(ctx, rt, githubConfigURL)
	if err != nil {
		return nil, fmt.Errorf("failed to get actions service admin connection: %w", err)
	}

	ac.ActionsServiceURL = adminConnInfo.ActionsServiceUrl

	ac.mu.Lock()
	defer ac.mu.Unlock()
	ac.ActionsServiceAdminToken = adminConnInfo.AdminToken
	ac.ActionsServiceAdminTokenExpiresAt, err = actionsServiceAdminTokenExpiresAt(*adminConnInfo.AdminToken)
	if err != nil {
		return nil, fmt.Errorf("failed to get admin token expire at: %w", err)
	}

	return ac, nil
}

func (c *Client) GetRunnerScaleSet(ctx context.Context, runnerScaleSetName string) (*RunnerScaleSet, error) {
	u := fmt.Sprintf("%s/%s?name=%s&api-version=6.0-preview", *c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetName)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return nil, fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusOK {
		return nil, ParseActionsErrorFromResponse(resp)
	}
	var runnerScaleSetList *runnerScaleSetsResponse
	err = unmarshalBody(resp, &runnerScaleSetList)
	if err != nil {
		return nil, err
	}
	if runnerScaleSetList.Count == 0 {
		return nil, nil
	}
	if runnerScaleSetList.Count > 1 {
		return nil, fmt.Errorf("multiple runner scale sets found with name %s", runnerScaleSetName)
	}

	return &runnerScaleSetList.RunnerScaleSets[0], nil
}

func (c *Client) GetRunnerScaleSetById(ctx context.Context, runnerScaleSetId int) (*RunnerScaleSet, error) {
	u := fmt.Sprintf("%s/%s/%d?api-version=6.0-preview", *c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetId)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return nil, fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusOK {
		return nil, ParseActionsErrorFromResponse(resp)
	}

	var runnerScaleSet *RunnerScaleSet
	err = unmarshalBody(resp, &runnerScaleSet)
	if err != nil {
		return nil, err
	}
	return runnerScaleSet, nil
}

func (c *Client) GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*RunnerGroup, error) {
	u := fmt.Sprintf("%s/_apis/runtime/runnergroups/?groupName=%s&api-version=6.0-preview", *c.ActionsServiceURL, runnerGroup)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return nil, fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusOK {
		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return nil, err
		}
		return nil, fmt.Errorf("unexpected status code: %d - body: %s", resp.StatusCode, string(body))
	}

	var runnerGroupList *RunnerGroupList
	err = unmarshalBody(resp, &runnerGroupList)
	if err != nil {
		return nil, err
	}

	if runnerGroupList.Count == 0 {
		return nil, nil
	}

	if runnerGroupList.Count > 1 {
		return nil, fmt.Errorf("multiple runner group found with name %s", runnerGroup)
	}

	return &runnerGroupList.RunnerGroups[0], nil
}

func (c *Client) CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) {
	u := fmt.Sprintf("%s/%s?api-version=6.0-preview", *c.ActionsServiceURL, scaleSetEndpoint)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return nil, fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	body, err := json.Marshal(runnerScaleSet)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewBuffer(body))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusOK {
		return nil, ParseActionsErrorFromResponse(resp)
	}
	var createdRunnerScaleSet *RunnerScaleSet
	err = unmarshalBody(resp, &createdRunnerScaleSet)
	if err != nil {
		return nil, err
	}
	return createdRunnerScaleSet, nil
}

func (c *Client) DeleteRunnerScaleSet(ctx context.Context, runnerScaleSetId int) error {
	u := fmt.Sprintf("%s/%s/%d?api-version=6.0-preview", *c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetId)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusNoContent {
		return ParseActionsErrorFromResponse(resp)
	}

	defer resp.Body.Close()
	return nil
}

func (c *Client) GetMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, lastMessageId int64) (*RunnerScaleSetMessage, error) {
	u := messageQueueUrl
	if lastMessageId > 0 {
		u = fmt.Sprintf("%s&lassMessageId=%d", messageQueueUrl, lastMessageId)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Accept", "application/json; api-version=6.0-preview")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", messageQueueAccessToken))
	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode == http.StatusAccepted {
		defer resp.Body.Close()
		return nil, nil
	}

	if resp.StatusCode != http.StatusOK {
		if resp.StatusCode != http.StatusUnauthorized {
			return nil, ParseActionsErrorFromResponse(resp)
		}

		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		body = trimByteOrderMark(body)
		if err != nil {
			return nil, err
		}
		return nil, &MessageQueueTokenExpiredError{msg: string(body)}
	}

	var message *RunnerScaleSetMessage
	err = unmarshalBody(resp, &message)
	if err != nil {
		return nil, err
	}
	return message, nil
}

func (c *Client) DeleteMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, messageId int64) error {
	u, err := url.Parse(messageQueueUrl)
	if err != nil {
		return err
	}

	u.Path = fmt.Sprintf("%s/%d", u.Path, messageId)

	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil)
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", messageQueueAccessToken))
	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusNoContent {
		if resp.StatusCode != http.StatusUnauthorized {
			return ParseActionsErrorFromResponse(resp)
		}

		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		body = trimByteOrderMark(body)
		if err != nil {
			return err
		}
		return &MessageQueueTokenExpiredError{msg: string(body)}
	}
	return nil
}

func (c *Client) CreateMessageSession(ctx context.Context, runnerScaleSetId int, owner string) (*RunnerScaleSetSession, error) {
	u := fmt.Sprintf("%v/%v/%v/sessions?%v", *c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetId, apiVersionQueryParam)

	newSession := &RunnerScaleSetSession{
		OwnerName: owner,
	}

	requestData, err := json.Marshal(newSession)
	if err != nil {
		return nil, err
	}

	createdSession := &RunnerScaleSetSession{}

	err = c.doSessionRequest(ctx, http.MethodPost, u, bytes.NewBuffer(requestData), http.StatusOK, createdSession)

	return createdSession, err
}

func (c *Client) DeleteMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) error {
	u := fmt.Sprintf("%v/%v/%v/sessions/%v?%v", *c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetId, sessionId.String(), apiVersionQueryParam)

	return c.doSessionRequest(ctx, http.MethodDelete, u, nil, http.StatusNoContent, nil)
}

func (c *Client) RefreshMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) (*RunnerScaleSetSession, error) {
	u := fmt.Sprintf("%v/%v/%v/sessions/%v?%v", *c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetId, sessionId.String(), apiVersionQueryParam)
	refreshedSession := &RunnerScaleSetSession{}
	err := c.doSessionRequest(ctx, http.MethodPatch, u, nil, http.StatusOK, refreshedSession)
	return refreshedSession, err
}

func (c *Client) doSessionRequest(ctx context.Context, method, url string, requestData io.Reader, expectedResponseStatusCode int, responseUnmarshalTarget any) error {
	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, method, url, requestData)
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode == expectedResponseStatusCode && responseUnmarshalTarget != nil {
		err = unmarshalBody(resp, &responseUnmarshalTarget)
		return err
	}

	if resp.StatusCode >= 400 && resp.StatusCode < 500 {
		return ParseActionsErrorFromResponse(resp)
	}

	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	body = trimByteOrderMark(body)
	if err != nil {
		return err
	}

	return fmt.Errorf("unexpected status code: %d - body: %s", resp.StatusCode, string(body))
}

func (c *Client) AcquireJobs(ctx context.Context, runnerScaleSetId int, messageQueueAccessToken string, requestIds []int64) ([]int64, error) {
	u := fmt.Sprintf("%s/%s/%d/acquirejobs?api-version=6.0-preview", *c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetId)

	body, err := json.Marshal(requestIds)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewBuffer(body))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", messageQueueAccessToken))
	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusOK {
		return nil, ParseActionsErrorFromResponse(resp)
	}

	var acquiredJobs Int64List
	err = unmarshalBody(resp, &acquiredJobs)
	if err != nil {
		return nil, err
	}

	return acquiredJobs.Value, nil
}

func (c *Client) GetAcquirableJobs(ctx context.Context, runnerScaleSetId int) (*AcquirableJobList, error) {
	u := fmt.Sprintf("%s/%s/%d/acquirablejobs?api-version=6.0-preview", *c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetId)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return nil, fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode == http.StatusNoContent {
		defer resp.Body.Close()
		return &AcquirableJobList{Count: 0, Jobs: []AcquirableJob{}}, nil
	}

	if resp.StatusCode != http.StatusOK {
		return nil, ParseActionsErrorFromResponse(resp)
	}

	var acquirableJobList *AcquirableJobList
	err = unmarshalBody(resp, &acquirableJobList)
	if err != nil {
		return nil, err
	}

	return acquirableJobList, nil
}

func (c *Client) GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *RunnerScaleSetJitRunnerSetting, scaleSetId int) (*RunnerScaleSetJitRunnerConfig, error) {
	runnerJitConfigUrl := fmt.Sprintf("%s/%s/%d/generatejitconfig?api-version=6.0-preview", *c.ActionsServiceURL, scaleSetEndpoint, scaleSetId)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return nil, fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	body, err := json.Marshal(jitRunnerSetting)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, runnerJitConfigUrl, bytes.NewBuffer(body))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))
	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusOK {
		return nil, ParseActionsErrorFromResponse(resp)
	}

	var runnerJitConfig *RunnerScaleSetJitRunnerConfig
	err = unmarshalBody(resp, &runnerJitConfig)
	if err != nil {
		return nil, err
	}
	return runnerJitConfig, nil
}

func (c *Client) GetRunner(ctx context.Context, runnerId int64) (*RunnerReference, error) {
	url := fmt.Sprintf("%v/%v/%v?%v", *c.ActionsServiceURL, runnerEndpoint, runnerId, apiVersionQueryParam)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return nil, fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusOK {
		return nil, ParseActionsErrorFromResponse(resp)
	}

	var runnerReference *RunnerReference
	if err := unmarshalBody(resp, &runnerReference); err != nil {
		return nil, err
	}

	return runnerReference, nil
}

func (c *Client) GetRunnerByName(ctx context.Context, runnerName string) (*RunnerReference, error) {
	url := fmt.Sprintf("%v/%v?agentName=%v&%v", *c.ActionsServiceURL, runnerEndpoint, runnerName, apiVersionQueryParam)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return nil, fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusOK {
		return nil, ParseActionsErrorFromResponse(resp)
	}

	var runnerList *RunnerReferenceList
	err = unmarshalBody(resp, &runnerList)
	if err != nil {
		return nil, err
	}

	if runnerList.Count == 0 {
		return nil, nil
	}

	if runnerList.Count > 1 {
		return nil, fmt.Errorf("multiple runner found with name %s", runnerName)
	}

	return &runnerList.RunnerReferences[0], nil
}

func (c *Client) RemoveRunner(ctx context.Context, runnerId int64) error {
	url := fmt.Sprintf("%v/%v/%v?%v", *c.ActionsServiceURL, runnerEndpoint, runnerId, apiVersionQueryParam)

	if err := c.refreshTokenIfNeeded(ctx); err != nil {
		return fmt.Errorf("failed to refresh admin token if needed: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *c.ActionsServiceAdminToken))

	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}

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

	if resp.StatusCode != http.StatusNoContent {
		return ParseActionsErrorFromResponse(resp)
	}

	defer resp.Body.Close()
	return nil
}

type registrationToken struct {
	Token     *string    `json:"token,omitempty"`
	ExpiresAt *time.Time `json:"expires_at,omitempty"`
}

func (c *Client) getRunnerRegistrationToken(ctx context.Context, githubConfigUrl string, creds ActionsAuth) (*registrationToken, error) {
	registrationTokenURL, err := createRegistrationTokenURL(githubConfigUrl)
	if err != nil {
		return nil, err
	}

	var buf bytes.Buffer
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, registrationTokenURL, &buf)
	if err != nil {
		return nil, err
	}

	bearerToken := ""

	if creds.Token != "" {
		encodedToken := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("github:%v", creds.Token)))
		bearerToken = fmt.Sprintf("Basic %v", encodedToken)
	} else {
		accessToken, err := c.fetchAccessToken(ctx, githubConfigUrl, creds.AppCreds)
		if err != nil {
			return nil, err
		}

		bearerToken = fmt.Sprintf("Bearer %v", accessToken.Token)
	}

	req.Header.Set("Content-Type", "application/vnd.github.v3+json")
	req.Header.Set("Authorization", bearerToken)
	req.Header.Set("User-Agent", c.userAgent)

	c.logger.Info("getting runner registration token", "registrationTokenURL", registrationTokenURL)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusCreated {
		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return nil, err
		}
		return nil, fmt.Errorf("unexpected response from Actions service during registration token call: %v - %v", resp.StatusCode, string(body))
	}

	registrationToken := &registrationToken{}
	if err := json.NewDecoder(resp.Body).Decode(registrationToken); err != nil {
		return nil, err
	}

	return registrationToken, nil
}

// Format: https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app
type accessToken struct {
	Token     string    `json:"token"`
	ExpiresAt time.Time `json:"expires_at"`
}

func (c *Client) fetchAccessToken(ctx context.Context, gitHubConfigURL string, creds *GitHubAppAuth) (*accessToken, error) {
	parsedGitHubConfigURL, err := url.Parse(gitHubConfigURL)
	if err != nil {
		return nil, err
	}

	accessTokenJWT, err := createJWTForGitHubApp(creds)
	if err != nil {
		return nil, err
	}

	ru := fmt.Sprintf("%v://%v/app/installations/%v/access_tokens", parsedGitHubConfigURL.Scheme, parsedGitHubConfigURL.Host, creds.AppInstallationID)
	accessTokenURL, err := url.Parse(ru)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, accessTokenURL.String(), nil)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/vnd.github+json")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessTokenJWT))
	req.Header.Add("User-Agent", c.userAgent)

	c.logger.Info("getting access token for GitHub App auth", "accessTokenURL", accessTokenURL.String())

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	// Format: https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app
	accessToken := &accessToken{}
	err = json.NewDecoder(resp.Body).Decode(accessToken)
	return accessToken, err
}

type ActionsServiceAdminConnection struct {
	ActionsServiceUrl *string `json:"url,omitempty"`
	AdminToken        *string `json:"token,omitempty"`
}

func (c *Client) getActionsServiceAdminConnection(ctx context.Context, rt *registrationToken, githubConfigUrl string) (*ActionsServiceAdminConnection, error) {
	parsedGitHubConfigURL, err := url.Parse(githubConfigUrl)
	if err != nil {
		return nil, err
	}

	if isHostedServer(*parsedGitHubConfigURL) {
		parsedGitHubConfigURL.Host = fmt.Sprintf("api.%v", parsedGitHubConfigURL.Host)
	}

	ru := fmt.Sprintf("%v://%v/actions/runner-registration", parsedGitHubConfigURL.Scheme, parsedGitHubConfigURL.Host)
	registrationURL, err := url.Parse(ru)
	if err != nil {
		return nil, err
	}

	body := struct {
		Url         string `json:"url"`
		RunnerEvent string `json:"runner_event"`
	}{
		Url:         githubConfigUrl,
		RunnerEvent: "register",
	}

	buf := &bytes.Buffer{}
	enc := json.NewEncoder(buf)
	enc.SetEscapeHTML(false)

	if err := enc.Encode(body); err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, registrationURL.String(), buf)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", fmt.Sprintf("RemoteAuth %s", *rt.Token))
	req.Header.Set("User-Agent", c.userAgent)

	c.logger.Info("getting Actions tenant URL and JWT", "registrationURL", registrationURL.String())

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	actionsServiceAdminConnection := &ActionsServiceAdminConnection{}
	if err := json.NewDecoder(resp.Body).Decode(actionsServiceAdminConnection); err != nil {
		return nil, err
	}

	return actionsServiceAdminConnection, nil
}

func isHostedServer(gitHubURL url.URL) bool {
	return gitHubURL.Host == "github.com" ||
		gitHubURL.Host == "www.github.com" ||
		gitHubURL.Host == "github.localhost"
}

func createRegistrationTokenURL(githubConfigUrl string) (string, error) {
	parsedGitHubConfigURL, err := url.Parse(githubConfigUrl)
	if err != nil {
		return "", err
	}

	// Check for empty path before split, because strings.Split will return a slice of length 1
	// when the split delimiter is not present.
	trimmedPath := strings.TrimLeft(parsedGitHubConfigURL.Path, "/")
	if len(trimmedPath) == 0 {
		return "", fmt.Errorf("%q should point to an enterprise, org, or repository", parsedGitHubConfigURL.String())
	}

	pathParts := strings.Split(path.Clean(strings.TrimLeft(parsedGitHubConfigURL.Path, "/")), "/")

	switch len(pathParts) {
	case 1: // Organization
		registrationTokenURL := fmt.Sprintf(
			"%v://%v/api/v3/orgs/%v/actions/runners/registration-token",
			parsedGitHubConfigURL.Scheme, parsedGitHubConfigURL.Host, pathParts[0])

		if isHostedServer(*parsedGitHubConfigURL) {
			registrationTokenURL = fmt.Sprintf(
				"%v://api.%v/orgs/%v/actions/runners/registration-token",
				parsedGitHubConfigURL.Scheme, parsedGitHubConfigURL.Host, pathParts[0])
		}

		return registrationTokenURL, nil
	case 2: // Repository or enterprise
		repoScope := "repos/"
		if strings.ToLower(pathParts[0]) == "enterprises" {
			repoScope = ""
		}

		registrationTokenURL := fmt.Sprintf("%v://%v/api/v3/%v%v/%v/actions/runners/registration-token",
			parsedGitHubConfigURL.Scheme, parsedGitHubConfigURL.Host, repoScope, pathParts[0], pathParts[1])

		if isHostedServer(*parsedGitHubConfigURL) {
			registrationTokenURL = fmt.Sprintf("%v://api.%v/%v%v/%v/actions/runners/registration-token",
				parsedGitHubConfigURL.Scheme, parsedGitHubConfigURL.Host, repoScope, pathParts[0], pathParts[1])
		}

		return registrationTokenURL, nil
	default:
		return "", fmt.Errorf("%q should point to an enterprise, org, or repository", parsedGitHubConfigURL.String())
	}
}

func createJWTForGitHubApp(appAuth *GitHubAppAuth) (string, error) {
	// Encode as JWT
	// See https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app

	// Going back in time a bit helps with clock skew.
	issuedAt := time.Now().Add(-60 * time.Second)
	// Max expiration date is 10 minutes.
	expiresAt := issuedAt.Add(9 * time.Minute)
	claims := &jwt.RegisteredClaims{
		IssuedAt:  jwt.NewNumericDate(issuedAt),
		ExpiresAt: jwt.NewNumericDate(expiresAt),
		Issuer:    strconv.FormatInt(appAuth.AppID, 10),
	}

	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)

	privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(appAuth.AppPrivateKey))
	if err != nil {
		return "", err
	}

	return token.SignedString(privateKey)
}

func unmarshalBody(response *http.Response, v interface{}) (err error) {
	if response != nil && response.Body != nil {
		var err error
		defer func() {
			if closeError := response.Body.Close(); closeError != nil {
				err = closeError
			}
		}()
		body, err := io.ReadAll(response.Body)
		if err != nil {
			return err
		}
		body = trimByteOrderMark(body)
		return json.Unmarshal(body, &v)
	}
	return nil
}

// Returns slice of body without utf-8 byte order mark.
// If BOM does not exist body is returned unchanged.
func trimByteOrderMark(body []byte) []byte {
	return bytes.TrimPrefix(body, []byte("\xef\xbb\xbf"))
}

func actionsServiceAdminTokenExpiresAt(jwtToken string) (*time.Time, error) {
	type JwtClaims struct {
		jwt.RegisteredClaims
	}
	token, _, err := jwt.NewParser().ParseUnverified(jwtToken, &JwtClaims{})
	if err != nil {
		return nil, fmt.Errorf("failed to parse jwt token: %w", err)
	}

	if claims, ok := token.Claims.(*JwtClaims); ok {
		return &claims.ExpiresAt.Time, nil
	}

	return nil, fmt.Errorf("failed to parse token claims to get expire at")
}

func (c *Client) refreshTokenIfNeeded(ctx context.Context) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	aboutToExpire := time.Now().Add(60 * time.Second).After(*c.ActionsServiceAdminTokenExpiresAt)
	if !aboutToExpire {
		return nil
	}

	c.logger.Info("Admin token is about to expire, refreshing it", "githubConfigUrl", c.githubConfigURL)
	rt, err := c.getRunnerRegistrationToken(ctx, c.githubConfigURL, *c.creds)
	if err != nil {
		return fmt.Errorf("failed to get runner registration token on fresh: %w", err)
	}

	adminConnInfo, err := c.getActionsServiceAdminConnection(ctx, rt, c.githubConfigURL)
	if err != nil {
		return fmt.Errorf("failed to get actions service admin connection on fresh: %w", err)
	}

	c.ActionsServiceURL = adminConnInfo.ActionsServiceUrl
	c.ActionsServiceAdminToken = adminConnInfo.AdminToken
	c.ActionsServiceAdminTokenExpiresAt, err = actionsServiceAdminTokenExpiresAt(*adminConnInfo.AdminToken)
	if err != nil {
		return fmt.Errorf("failed to get admin token expire at on refresh: %w", err)
	}

	return nil
}
