package git

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
	"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
	giturls "github.com/chainguard-dev/git-urls"
	"github.com/google/go-github/v69/github"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"

	gocache "github.com/patrickmn/go-cache"

	argoio "github.com/argoproj/argo-cd/gitops-engine/pkg/utils/io"
	"github.com/argoproj/argo-cd/gitops-engine/pkg/utils/text"
	"github.com/bradleyfalzon/ghinstallation/v2"
	log "github.com/sirupsen/logrus"

	"github.com/argoproj/argo-cd/v3/common"
	argoutils "github.com/argoproj/argo-cd/v3/util"
	certutil "github.com/argoproj/argo-cd/v3/util/cert"
	utilio "github.com/argoproj/argo-cd/v3/util/io"
	"github.com/argoproj/argo-cd/v3/util/workloadidentity"
)

var (
	// In memory cache for storing github APP api token credentials
	githubAppTokenCache *gocache.Cache
	// In memory cache for storing oauth2.TokenSource used to generate Google Cloud OAuth tokens
	googleCloudTokenSource *gocache.Cache

	// In memory cache for storing Azure tokens
	azureTokenCache *gocache.Cache

	// installationIdCache caches installation IDs for organizations to avoid redundant API calls.
	githubInstallationIdCache      *gocache.Cache
	githubInstallationIdCacheMutex sync.RWMutex // For bulk API call coordination
	// In memory cache for storing Azure Service Principal tokens
	azureServicePrincipalTokenCache *gocache.Cache
)

const (
	// githubAccessTokenUsername is a username that is used to with the github access token
	githubAccessTokenUsername = "x-access-token"
	forceBasicAuthHeaderEnv   = "ARGOCD_GIT_AUTH_HEADER"
	bearerAuthHeaderEnv       = "ARGOCD_GIT_BEARER_AUTH_HEADER"
	// This is the resource id of the OAuth application of Azure Devops.
	azureDevopsEntraResourceId = "499b84ac-1321-427f-aa17-267ca6975798/.default"
)

func init() {
	githubAppCredsExp := common.GithubAppCredsExpirationDuration
	if exp := os.Getenv(common.EnvGithubAppCredsExpirationDuration); exp != "" {
		if qps, err := strconv.Atoi(exp); err == nil {
			githubAppCredsExp = time.Duration(qps) * time.Minute
		}
	}
	azureServicePrincipalCredsExp := common.AzureServicePrincipalCredsExpirationDuration
	if exp := os.Getenv(common.EnvAzureServicePrincipalCredsExpirationDuration); exp != "" {
		if qps, err := strconv.Atoi(exp); err == nil {
			// Azure service principal tokens are valid for 60 minutes
			// the cache has a cleanup interval of 1 minute
			// cap the expiration duration to 59 minutes to avoid issues with token expiration
			if qps > 59 {
				log.Warnf("Value in %s is %d, which is greater than maximum 59 minutes allowed. Setting to 59 minutes", common.EnvAzureServicePrincipalCredsExpirationDuration, qps)
				qps = 59
			}
			azureServicePrincipalCredsExp = time.Duration(qps) * time.Minute
		}
	}

	githubAppTokenCache = gocache.New(githubAppCredsExp, 1*time.Minute)
	// oauth2.TokenSource handles fetching new Tokens once they are expired. The oauth2.TokenSource itself does not expire.
	googleCloudTokenSource = gocache.New(gocache.NoExpiration, 0)
	azureTokenCache = gocache.New(gocache.NoExpiration, 0)
	githubInstallationIdCache = gocache.New(60*time.Minute, 60*time.Minute)
	azureServicePrincipalTokenCache = gocache.New(azureServicePrincipalCredsExp, 1*time.Minute)
}

type NoopCredsStore struct{}

func (d NoopCredsStore) Add(_ string, _ string) string {
	return ""
}

func (d NoopCredsStore) Remove(_ string) {
}

func (d NoopCredsStore) Environ(_ string) []string {
	return []string{}
}

type CredsStore interface {
	Add(username string, password string) string
	Remove(id string)
	// Environ returns the environment variables that should be set to use the credentials for the given credential ID.
	Environ(id string) []string
}

type Creds interface {
	Environ() (io.Closer, []string, error)
	// GetUserInfo gets the username and email address for the credentials, if they're available.
	GetUserInfo(ctx context.Context) (string, string, error)
}

// nop implementation
type NopCloser struct{}

func (c NopCloser) Close() error {
	return nil
}

var _ Creds = NopCreds{}

type NopCreds struct{}

func (c NopCreds) Environ() (io.Closer, []string, error) {
	return NopCloser{}, nil, nil
}

// GetUserInfo returns empty strings for user info
func (c NopCreds) GetUserInfo(_ context.Context) (name string, email string, err error) {
	return "", "", nil
}

var _ io.Closer = NopCloser{}

type GenericHTTPSCreds interface {
	HasClientCert() bool
	GetClientCertData() string
	GetClientCertKey() string
	Creds
}

var (
	_ GenericHTTPSCreds = HTTPSCreds{}
	_ Creds             = HTTPSCreds{}
)

// HTTPS creds implementation
type HTTPSCreds struct {
	// Username for authentication
	username string
	// Password for authentication
	password string
	// Bearer token for authentication
	bearerToken string
	// Whether to ignore invalid server certificates
	insecure bool
	// Client certificate to use
	clientCertData string
	// Client certificate key to use
	clientCertKey string
	// temporal credentials store
	store CredsStore
	// whether to force usage of basic auth
	forceBasicAuth bool
}

func NewHTTPSCreds(username string, password string, bearerToken string, clientCertData string, clientCertKey string, insecure bool, store CredsStore, forceBasicAuth bool) GenericHTTPSCreds {
	return HTTPSCreds{
		username,
		password,
		bearerToken,
		insecure,
		clientCertData,
		clientCertKey,
		store,
		forceBasicAuth,
	}
}

// GetUserInfo returns the username and email address for the credentials, if they're available.
func (creds HTTPSCreds) GetUserInfo(_ context.Context) (string, string, error) {
	// Email not implemented for HTTPS creds.
	return creds.username, "", nil
}

func (creds HTTPSCreds) BasicAuthHeader() string {
	h := "Authorization: Basic "
	t := creds.username + ":" + creds.password
	h += base64.StdEncoding.EncodeToString([]byte(t))
	return h
}

func (creds HTTPSCreds) BearerAuthHeader() string {
	h := "Authorization: Bearer " + creds.bearerToken
	return h
}

// Get additional required environment variables for executing git client to
// access specific repository via HTTPS.
func (creds HTTPSCreds) Environ() (io.Closer, []string, error) {
	var env []string

	httpCloser := authFilePaths(make([]string, 0))

	// GIT_SSL_NO_VERIFY is used to tell git not to validate the server's cert at
	// all.
	if creds.insecure {
		env = append(env, "GIT_SSL_NO_VERIFY=true")
	}

	// In case the repo is configured for using a TLS client cert, we need to make
	// sure git client will use it. The certificate's key must not be password
	// protected.
	if creds.HasClientCert() {
		var certFile, keyFile *os.File

		// We need to actually create two temp files, one for storing cert data and
		// another for storing the key. If we fail to create second fail, the first
		// must be removed.
		certFile, err := os.CreateTemp(argoio.TempDir, "")
		if err != nil {
			return NopCloser{}, nil, err
		}
		defer certFile.Close()
		keyFile, err = os.CreateTemp(argoio.TempDir, "")
		if err != nil {
			removeErr := os.Remove(certFile.Name())
			if removeErr != nil {
				log.Errorf("Could not remove previously created tempfile %s: %v", certFile.Name(), removeErr)
			}
			return NopCloser{}, nil, err
		}
		defer keyFile.Close()

		// We should have both temp files by now
		httpCloser = authFilePaths([]string{certFile.Name(), keyFile.Name()})

		_, err = certFile.WriteString(creds.clientCertData)
		if err != nil {
			httpCloser.Close()
			return NopCloser{}, nil, err
		}
		// GIT_SSL_CERT is the full path to a client certificate to be used
		env = append(env, "GIT_SSL_CERT="+certFile.Name())

		_, err = keyFile.WriteString(creds.clientCertKey)
		if err != nil {
			httpCloser.Close()
			return NopCloser{}, nil, err
		}
		// GIT_SSL_KEY is the full path to a client certificate's key to be used
		env = append(env, "GIT_SSL_KEY="+keyFile.Name())
	}
	// If at least password is set, we will set ARGOCD_BASIC_AUTH_HEADER to
	// hold the HTTP authorization header, so auth mechanism negotiation is
	// skipped. This is insecure, but some environments may need it.
	if creds.password != "" && creds.forceBasicAuth {
		env = append(env, fmt.Sprintf("%s=%s", forceBasicAuthHeaderEnv, creds.BasicAuthHeader()))
	} else if creds.bearerToken != "" {
		// If bearer token is set, we will set ARGOCD_BEARER_AUTH_HEADER to	hold the HTTP authorization header
		env = append(env, fmt.Sprintf("%s=%s", bearerAuthHeaderEnv, creds.BearerAuthHeader()))
	}
	nonce := creds.store.Add(text.FirstNonEmpty(creds.username, githubAccessTokenUsername), creds.password)
	env = append(env, creds.store.Environ(nonce)...)
	return utilio.NewCloser(func() error {
		creds.store.Remove(nonce)
		return httpCloser.Close()
	}), env, nil
}

func (creds HTTPSCreds) HasClientCert() bool {
	return creds.clientCertData != "" && creds.clientCertKey != ""
}

func (creds HTTPSCreds) GetClientCertData() string {
	return creds.clientCertData
}

func (creds HTTPSCreds) GetClientCertKey() string {
	return creds.clientCertKey
}

var _ Creds = SSHCreds{}

// SSH implementation
type SSHCreds struct {
	sshPrivateKey string
	caPath        string
	insecure      bool
	proxy         string
}

func NewSSHCreds(sshPrivateKey string, caPath string, insecureIgnoreHostKey bool, proxy string) SSHCreds {
	return SSHCreds{sshPrivateKey, caPath, insecureIgnoreHostKey, proxy}
}

// GetUserInfo returns empty strings for user info.
// TODO: Implement this method to return the username and email address for the credentials, if they're available.
func (c SSHCreds) GetUserInfo(_ context.Context) (string, string, error) {
	// User info not implemented for SSH creds.
	return "", "", nil
}

type sshPrivateKeyFile string

type authFilePaths []string

func (f sshPrivateKeyFile) Close() error {
	return os.Remove(string(f))
}

// Remove a list of files that have been created as temp files while creating
// HTTPCreds object above.
func (f authFilePaths) Close() error {
	var retErr error
	for _, path := range f {
		err := os.Remove(path)
		if err != nil {
			log.Errorf("HTTPSCreds.Close(): Could not remove temp file %s: %v", path, err)
			retErr = err
		}
	}
	return retErr
}

func (c SSHCreds) Environ() (io.Closer, []string, error) {
	// use the SHM temp dir from util, more secure
	file, err := os.CreateTemp(argoio.TempDir, "")
	if err != nil {
		return nil, nil, err
	}

	sshCloser := sshPrivateKeyFile(file.Name())

	defer func() {
		if err = file.Close(); err != nil {
			log.WithFields(log.Fields{
				common.SecurityField:    common.SecurityMedium,
				common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
			}).Errorf("error closing file %q: %v", file.Name(), err)
		}
	}()

	_, err = file.WriteString(c.sshPrivateKey + "\n")
	if err != nil {
		sshCloser.Close()
		return nil, nil, err
	}

	args := []string{"ssh", "-i", file.Name()}
	var env []string
	if c.caPath != "" {
		env = append(env, "GIT_SSL_CAINFO="+c.caPath)
	}
	if c.insecure {
		log.Warn("temporarily disabling strict host key checking (i.e. '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'), please don't use in production")
		// StrictHostKeyChecking will add the host to the knownhosts file,  we don't want that - a security issue really,
		// UserKnownHostsFile=/dev/null is therefore used so we write the new insecure host to /dev/null
		args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null")
	} else {
		knownHostsFile := certutil.GetSSHKnownHostsDataPath()
		args = append(args, "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile="+knownHostsFile)
	}
	// Handle SSH socks5 proxy settings
	proxyEnv := []string{}
	if c.proxy != "" {
		parsedProxyURL, err := url.Parse(c.proxy)
		if err != nil {
			sshCloser.Close()
			return nil, nil, fmt.Errorf("failed to set environment variables related to socks5 proxy, could not parse proxy URL '%s': %w", c.proxy, err)
		}
		args = append(args, "-o", fmt.Sprintf("ProxyCommand='connect-proxy -S %s:%s -5 %%h %%p'",
			parsedProxyURL.Hostname(),
			parsedProxyURL.Port()))
		if parsedProxyURL.User != nil {
			proxyEnv = append(proxyEnv, "SOCKS5_USER="+parsedProxyURL.User.Username())
			if socks5Passwd, isPasswdSet := parsedProxyURL.User.Password(); isPasswdSet {
				proxyEnv = append(proxyEnv, "SOCKS5_PASSWD="+socks5Passwd)
			}
		}
	}
	env = append(env, []string{"GIT_SSH_COMMAND=" + strings.Join(args, " ")}...)
	env = append(env, proxyEnv...)
	return sshCloser, env, nil
}

// GitHubAppCreds to authenticate as GitHub application
type GitHubAppCreds struct {
	appID          int64
	appInstallId   int64
	privateKey     string
	baseURL        string
	clientCertData string
	clientCertKey  string
	insecure       bool
	proxy          string
	noProxy        string
	store          CredsStore
	// repoURL is the full repository URL, used for extracting org for auto-discovery
	repoURL string
}

// NewGitHubAppCreds provide github app credentials
// repoURL is required for automatic installation ID discovery when appInstallId is 0
func NewGitHubAppCreds(appID int64, appInstallId int64, privateKey string, baseURL string, clientCertData string, clientCertKey string, insecure bool, proxy string, noProxy string, store CredsStore, repoURL string) GenericHTTPSCreds {
	return GitHubAppCreds{appID: appID, appInstallId: appInstallId, privateKey: privateKey, baseURL: baseURL, clientCertData: clientCertData, clientCertKey: clientCertKey, insecure: insecure, proxy: proxy, noProxy: noProxy, store: store, repoURL: repoURL}
}

func (g GitHubAppCreds) Environ() (io.Closer, []string, error) {
	token, err := g.getAccessToken()
	if err != nil {
		return NopCloser{}, nil, err
	}
	var env []string
	httpCloser := authFilePaths(make([]string, 0))

	// GIT_SSL_NO_VERIFY is used to tell git not to validate the server's cert at
	// all.
	if g.insecure {
		env = append(env, "GIT_SSL_NO_VERIFY=true")
	}

	// In case the repo is configured for using a TLS client cert, we need to make
	// sure git client will use it. The certificate's key must not be password
	// protected.
	if g.HasClientCert() {
		var certFile, keyFile *os.File

		// We need to actually create two temp files, one for storing cert data and
		// another for storing the key. If we fail to create second fail, the first
		// must be removed.
		certFile, err := os.CreateTemp(argoio.TempDir, "")
		if err != nil {
			return NopCloser{}, nil, err
		}
		defer certFile.Close()
		keyFile, err = os.CreateTemp(argoio.TempDir, "")
		if err != nil {
			removeErr := os.Remove(certFile.Name())
			if removeErr != nil {
				log.Errorf("Could not remove previously created tempfile %s: %v", certFile.Name(), removeErr)
			}
			return NopCloser{}, nil, err
		}
		defer keyFile.Close()

		// We should have both temp files by now
		httpCloser = authFilePaths([]string{certFile.Name(), keyFile.Name()})

		_, err = certFile.WriteString(g.clientCertData)
		if err != nil {
			httpCloser.Close()
			return NopCloser{}, nil, err
		}
		// GIT_SSL_CERT is the full path to a client certificate to be used
		env = append(env, "GIT_SSL_CERT="+certFile.Name())

		_, err = keyFile.WriteString(g.clientCertKey)
		if err != nil {
			httpCloser.Close()
			return NopCloser{}, nil, err
		}
		// GIT_SSL_KEY is the full path to a client certificate's key to be used
		env = append(env, "GIT_SSL_KEY="+keyFile.Name())
	}
	nonce := g.store.Add(githubAccessTokenUsername, token)
	env = append(env, g.store.Environ(nonce)...)
	return utilio.NewCloser(func() error {
		g.store.Remove(nonce)
		return httpCloser.Close()
	}), env, nil
}

// GetUserInfo returns the username and email address for the credentials, if they're available.
func (g GitHubAppCreds) GetUserInfo(ctx context.Context) (string, string, error) {
	// We use the apps transport to get the app slug.
	appTransport, err := g.getAppTransport()
	if err != nil {
		return "", "", fmt.Errorf("failed to create GitHub app transport: %w", err)
	}
	appClient := github.NewClient(&http.Client{Transport: appTransport})
	app, _, err := appClient.Apps.Get(ctx, "")
	if err != nil {
		return "", "", fmt.Errorf("failed to get app info: %w", err)
	}

	// Then we use the installation transport to get the installation info.
	appInstallTransport, err := g.getInstallationTransport()
	if err != nil {
		return "", "", fmt.Errorf("failed to get app installation: %w", err)
	}
	httpClient := http.Client{Transport: appInstallTransport}
	client := github.NewClient(&httpClient)

	appLogin := app.GetSlug() + "[bot]"
	user, _, err := client.Users.Get(ctx, appLogin)
	if err != nil {
		return "", "", fmt.Errorf("failed to get app user info: %w", err)
	}
	authorName := user.GetLogin()
	authorEmail := fmt.Sprintf("%d+%s@users.noreply.github.com", user.GetID(), user.GetLogin())
	return authorName, authorEmail, nil
}

// getAccessToken fetches GitHub token using the app id, install id, and private key.
// the token is then cached for re-use.
func (g GitHubAppCreds) getAccessToken() (string, error) {
	// Timeout
	ctx, cancel := context.WithTimeout(context.Background(), gitClientTimeout)
	defer cancel()

	itr, err := g.getInstallationTransport()
	if err != nil {
		return "", fmt.Errorf("failed to create GitHub app installation transport: %w", err)
	}

	return itr.Token(ctx)
}

// getAppTransport creates a new GitHub transport for the app
func (g GitHubAppCreds) getAppTransport() (*ghinstallation.AppsTransport, error) {
	// GitHub API url
	baseURL := "https://api.github.com"
	if g.baseURL != "" {
		baseURL = strings.TrimSuffix(g.baseURL, "/")
	}

	// Create a new GitHub transport
	c := GetRepoHTTPClient(baseURL, g.insecure, g, g.proxy, g.noProxy)
	itr, err := ghinstallation.NewAppsTransport(c.Transport,
		g.appID,
		[]byte(g.privateKey),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to initialize GitHub installation transport: %w", err)
	}

	itr.BaseURL = baseURL

	return itr, nil
}

// getInstallationTransport creates a new GitHub transport for the app installation
func (g GitHubAppCreds) getInstallationTransport() (*ghinstallation.Transport, error) {
	installationID := g.appInstallId

	// Auto-discover installation ID if not provided
	if installationID == 0 {
		org, err := ExtractOrgFromRepoURL(g.repoURL)
		if err != nil {
			return nil, fmt.Errorf("failed to extract organization from repository URL %s for GitHub App installation discovery: %w", g.repoURL, err)
		}
		if org == "" {
			return nil, fmt.Errorf("could not extract organization from repository URL %s: the URL does not contain an organization/owner", g.repoURL)
		}

		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
		defer cancel()

		discoveredID, err := DiscoverGitHubAppInstallationID(ctx, g.appID, g.privateKey, g.baseURL, org)
		if err != nil {
			return nil, fmt.Errorf("failed to discover GitHub App installation ID for organization %s: ensure the GitHub App (ID: %d) is installed for this organization: %w", org, g.appID, err)
		}
		log.Infof("Auto-discovered GitHub App installation ID %d for org %s", discoveredID, org)
		installationID = discoveredID
	}

	// Compute hash of creds for lookup in cache
	h := sha256.New()
	_, err := fmt.Fprintf(h, "%s %d %d %s", g.privateKey, g.appID, installationID, g.baseURL)
	if err != nil {
		return nil, fmt.Errorf("failed to get SHA256 hash for GitHub app credentials: %w", err)
	}
	key := hex.EncodeToString(h.Sum(nil))

	// Check cache for GitHub transport which helps fetch an API token
	t, found := githubAppTokenCache.Get(key)
	if found {
		itr := t.(*ghinstallation.Transport)
		// This method caches the token and if it's expired retrieves a new one
		return itr, nil
	}

	// GitHub API url
	baseURL := "https://api.github.com"
	if g.baseURL != "" {
		baseURL = strings.TrimSuffix(g.baseURL, "/")
	}

	// Create a new GitHub transport
	c := GetRepoHTTPClient(baseURL, g.insecure, g, g.proxy, g.noProxy)
	itr, err := ghinstallation.New(c.Transport,
		g.appID,
		installationID,
		[]byte(g.privateKey),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to initialize GitHub installation transport: %w", err)
	}

	itr.BaseURL = baseURL

	// Add transport to cache
	githubAppTokenCache.Set(key, itr, time.Minute*60)

	return itr, nil
}

func (g GitHubAppCreds) HasClientCert() bool {
	return g.clientCertData != "" && g.clientCertKey != ""
}

func (g GitHubAppCreds) GetClientCertData() string {
	return g.clientCertData
}

func (g GitHubAppCreds) GetClientCertKey() string {
	return g.clientCertKey
}

// GitHub App installation discovery cache and helper

// DiscoverGitHubAppInstallationID discovers the GitHub App installation ID for a given organization.
// It queries the GitHub API to list all installations for the app and returns the installation ID
// for the matching organization. Results are cached to avoid redundant API calls.
// An optional HTTP client can be provided for custom transport (e.g., for metrics tracking).
func DiscoverGitHubAppInstallationID(ctx context.Context, appId int64, privateKey, enterpriseBaseURL, org string, httpClient ...*http.Client) (int64, error) {
	domain, err := domainFromBaseURL(enterpriseBaseURL)
	if err != nil {
		return 0, fmt.Errorf("failed to get domain from base URL: %w", err)
	}
	org = strings.ToLower(org)
	// Check cache first
	cacheKey := fmt.Sprintf("%s:%s:%d", strings.ToLower(org), domain, appId)
	if id, found := githubInstallationIdCache.Get(cacheKey); found {
		return id.(int64), nil
	}

	// Use provided HTTP client or default
	var transport http.RoundTripper
	if len(httpClient) > 0 && httpClient[0] != nil && httpClient[0].Transport != nil {
		transport = httpClient[0].Transport
	} else {
		transport = http.DefaultTransport
	}

	// Create GitHub App transport
	rt, err := ghinstallation.NewAppsTransport(transport, appId, []byte(privateKey))
	if err != nil {
		return 0, fmt.Errorf("failed to create GitHub app transport: %w", err)
	}

	if enterpriseBaseURL != "" {
		rt.BaseURL = enterpriseBaseURL
	}

	// Create GitHub client
	var client *github.Client
	clientTransport := &http.Client{Transport: rt}
	if enterpriseBaseURL == "" {
		client = github.NewClient(clientTransport)
	} else {
		client, err = github.NewClient(clientTransport).WithEnterpriseURLs(enterpriseBaseURL, enterpriseBaseURL)
		if err != nil {
			return 0, fmt.Errorf("failed to create GitHub enterprise client: %w", err)
		}
	}

	// List all installations and cache them
	var allInstallations []*github.Installation
	opts := &github.ListOptions{PerPage: 100}

	// Lock for the entire loop to avoid multiple concurrent API calls on startup
	githubInstallationIdCacheMutex.Lock()
	defer githubInstallationIdCacheMutex.Unlock()

	// Check cache again inside the write lock in case another goroutine already fetched it
	if id, found := githubInstallationIdCache.Get(cacheKey); found {
		return id.(int64), nil
	}

	for {
		installations, resp, err := client.Apps.ListInstallations(ctx, opts)
		if err != nil {
			return 0, fmt.Errorf("failed to list installations: %w", err)
		}

		allInstallations = append(allInstallations, installations...)

		if resp.NextPage == 0 {
			break
		}
		opts.Page = resp.NextPage
	}

	// Cache each installation under its account's key so multiple orgs do not overwrite each other.
	for _, installation := range allInstallations {
		if installation.Account != nil && installation.Account.Login != nil && installation.ID != nil {
			instKey := fmt.Sprintf("%s:%s:%d", strings.ToLower(*installation.Account.Login), domain, appId)
			githubInstallationIdCache.Set(instKey, *installation.ID, gocache.DefaultExpiration)
		}
	}

	// Return the installation ID for the requested org
	if id, found := githubInstallationIdCache.Get(cacheKey); found {
		return id.(int64), nil
	}
	return 0, fmt.Errorf("installation not found for org: %s", org)
}

// domainFromBaseURL extracts the host (domain) from the given GitHub base URL.
// Supports HTTP(S), SSH URLs, and git@host:org/repo forms.
// Returns an error if a domain cannot be extracted.
func domainFromBaseURL(baseURL string) (string, error) {
	if baseURL == "" {
		return "github.com", nil
	}

	// --- 1. SSH-style Git URL: git@github.com:org/repo.git ---
	if strings.Contains(baseURL, "@") && strings.Contains(baseURL, ":") && !strings.Contains(baseURL, "://") {
		parts := strings.SplitN(baseURL, "@", 2)
		right := parts[len(parts)-1]             // github.com:org/repo
		host := strings.SplitN(right, ":", 2)[0] // github.com
		if host != "" {
			return host, nil
		}
		return "", fmt.Errorf("failed to extract host from SSH-style URL: %q", baseURL)
	}

	// --- 2. Ensure scheme so url.Parse works ---
	if !strings.HasPrefix(baseURL, "http://") &&
		!strings.HasPrefix(baseURL, "https://") &&
		!strings.HasPrefix(baseURL, "ssh://") {
		baseURL = "https://" + baseURL
	}

	// --- 3. Standard URL parse ---
	parsed, err := url.Parse(baseURL)
	if err != nil {
		return "", fmt.Errorf("failed to parse URL %q: %w", baseURL, err)
	}
	if parsed.Host == "" {
		return "", fmt.Errorf("URL %q parsed but host is empty", baseURL)
	}

	host := parsed.Host
	if h, _, err := net.SplitHostPort(host); err == nil {
		host = h
	}
	return host, nil
}

// ExtractOrgFromRepoURL extracts the organization/owner name from a GitHub repository URL.
// Supports formats:
//   - HTTPS: https://github.com/org/repo.git
//   - SSH: git@github.com:org/repo.git
//   - SSH with port: git@github.com:22/org/repo.git or ssh://git@github.com:22/org/repo.git
func ExtractOrgFromRepoURL(repoURL string) (string, error) {
	if repoURL == "" {
		return "", errors.New("repo URL is empty")
	}

	// Handle edge case: ssh://git@host:org/repo (malformed but used in practice)
	// This format mixes ssh:// prefix with colon notation instead of using a slash.
	// Convert it to git@host:org/repo which git-urls can parse correctly.
	// We distinguish this from the valid ssh://git@host:22/org/repo (with port number).
	if strings.HasPrefix(repoURL, "ssh://git@") {
		remainder := strings.TrimPrefix(repoURL, "ssh://")
		if _, after, ok := strings.Cut(remainder, ":"); ok {
			afterColon := after
			slashIdx := strings.Index(afterColon, "/")

			// Check if what follows the colon is a port number
			isPort := false
			if slashIdx > 0 {
				if _, err := strconv.Atoi(afterColon[:slashIdx]); err == nil {
					isPort = true
				}
			}

			// If not a port, it's the malformed format - strip ssh:// prefix
			if !isPort && slashIdx != 0 {
				repoURL = remainder
			}
		}
	}

	// Use git-urls library to parse all Git URL formats
	parsed, err := giturls.Parse(repoURL)
	if err != nil {
		return "", fmt.Errorf("failed to parse repository URL %q: %w", repoURL, err)
	}

	// Clean the path: remove leading/trailing slashes and .git suffix
	path := strings.Trim(parsed.Path, "/")
	path = strings.TrimSuffix(path, ".git")

	if path == "" {
		return "", fmt.Errorf("repository URL %q does not contain a path", repoURL)
	}

	// Extract the first path component (organization/owner)
	// Path format is typically "org/repo" or "org/repo/subpath"
	if idx := strings.Index(path, "/"); idx > 0 {
		org := path[:idx]
		// Normalize to lowercase for case-insensitive comparison
		return strings.ToLower(org), nil
	}

	// If there's no slash, the entire path might be just the org (unusual but handle it)
	// This would fail validation later, but let's return it
	return "", fmt.Errorf("could not extract organization from repository URL %q: path %q does not contain org/repo format", repoURL, path)
}

var _ Creds = GoogleCloudCreds{}

// GoogleCloudCreds to authenticate to Google Cloud Source repositories
type GoogleCloudCreds struct {
	creds *google.Credentials
	store CredsStore
}

func NewGoogleCloudCreds(jsonData string, store CredsStore) GoogleCloudCreds {
	credType := credentialTypeFromJSON([]byte(jsonData))
	creds, err := google.CredentialsFromJSONWithType(
		context.Background(),
		[]byte(jsonData),
		credType,
		"https://www.googleapis.com/auth/cloud-platform",
	)
	if err != nil {
		log.Errorf("Failed reading credentials from JSON: %+v", err)
	}
	return GoogleCloudCreds{creds, store}
}

// GetUserInfo returns the username and email address for the credentials, if they're available.
// TODO: implement getting email instead of just username.
func (c GoogleCloudCreds) GetUserInfo(_ context.Context) (string, string, error) {
	username, err := c.getUsername()
	if err != nil {
		return "", "", fmt.Errorf("failed to get username from creds: %w", err)
	}
	return username, "", nil
}

func (c GoogleCloudCreds) Environ() (io.Closer, []string, error) {
	username, err := c.getUsername()
	if err != nil {
		return NopCloser{}, nil, fmt.Errorf("failed to get username from creds: %w", err)
	}
	token, err := c.getAccessToken()
	if err != nil {
		return NopCloser{}, nil, fmt.Errorf("failed to get access token from creds: %w", err)
	}

	nonce := c.store.Add(username, token)
	env := c.store.Environ(nonce)

	return utilio.NewCloser(func() error {
		c.store.Remove(nonce)
		return NopCloser{}.Close()
	}), env, nil
}

func (c GoogleCloudCreds) getUsername() (string, error) {
	type googleCredentialsFile struct {
		Type string `json:"type"`

		// Service Account fields
		ClientEmail  string `json:"client_email"`
		PrivateKeyID string `json:"private_key_id"`
		PrivateKey   string `json:"private_key"`
		AuthURL      string `json:"auth_uri"`
		TokenURL     string `json:"token_uri"`
		ProjectID    string `json:"project_id"`
	}

	if c.creds == nil {
		return "", errors.New("credentials for Google Cloud Source repositories are invalid")
	}

	var f googleCredentialsFile
	if err := json.Unmarshal(c.creds.JSON, &f); err != nil {
		return "", fmt.Errorf("failed to unmarshal Google Cloud credentials: %w", err)
	}
	return f.ClientEmail, nil
}

func (c GoogleCloudCreds) getAccessToken() (string, error) {
	if c.creds == nil {
		return "", errors.New("credentials for Google Cloud Source repositories are invalid")
	}

	// Compute hash of creds for lookup in cache
	h := sha256.New()
	_, err := h.Write(c.creds.JSON)
	if err != nil {
		return "", err
	}
	key := hex.EncodeToString(h.Sum(nil))

	t, found := googleCloudTokenSource.Get(key)
	if found {
		ts := t.(*oauth2.TokenSource)
		token, err := (*ts).Token()
		if err != nil {
			return "", fmt.Errorf("failed to get token from Google Cloud token source: %w", err)
		}
		return token.AccessToken, nil
	}

	ts := c.creds.TokenSource

	// Add TokenSource to cache
	// As TokenSource handles refreshing tokens once they expire itself, TokenSource itself can be reused. Hence, no expiration.
	googleCloudTokenSource.Set(key, &ts, gocache.NoExpiration)

	token, err := ts.Token()
	if err != nil {
		return "", fmt.Errorf("failed to get SHA256 hash for Google Cloud credentials: %w", err)
	}

	return token.AccessToken, nil
}

var _ Creds = AzureWorkloadIdentityCreds{}

type AzureWorkloadIdentityCreds struct {
	store         CredsStore
	tokenProvider workloadidentity.TokenProvider
}

func NewAzureWorkloadIdentityCreds(store CredsStore, tokenProvider workloadidentity.TokenProvider) AzureWorkloadIdentityCreds {
	return AzureWorkloadIdentityCreds{
		store:         store,
		tokenProvider: tokenProvider,
	}
}

// GetUserInfo returns the username and email address for the credentials, if they're available.
func (creds AzureWorkloadIdentityCreds) GetUserInfo(_ context.Context) (string, string, error) {
	// Email not implemented for HTTPS creds.
	return workloadidentity.EmptyGuid, "", nil
}

func (creds AzureWorkloadIdentityCreds) Environ() (io.Closer, []string, error) {
	token, err := creds.GetAzureDevOpsAccessToken()
	if err != nil {
		return NopCloser{}, nil, err
	}
	nonce := creds.store.Add("", token)
	env := creds.store.Environ(nonce)
	env = append(env, fmt.Sprintf("%s=Authorization: Bearer %s", bearerAuthHeaderEnv, token))

	return utilio.NewCloser(func() error {
		creds.store.Remove(nonce)
		return nil
	}), env, nil
}

func (creds AzureWorkloadIdentityCreds) getAccessToken(scope string) (string, error) {
	// Compute hash of creds for lookup in cache
	key, err := argoutils.GenerateCacheKey("%s", scope)
	if err != nil {
		return "", fmt.Errorf("failed to get SHA256 hash for Azure credentials: %w", err)
	}

	t, found := azureTokenCache.Get(key)
	if found {
		return t.(*workloadidentity.Token).AccessToken, nil
	}

	token, err := creds.tokenProvider.GetToken(scope)
	if err != nil {
		return "", fmt.Errorf("failed to get Azure access token: %w", err)
	}

	cacheExpiry := workloadidentity.CalculateCacheExpiryBasedOnTokenExpiry(token.ExpiresOn)
	if cacheExpiry > 0 {
		azureTokenCache.Set(key, token, cacheExpiry)
	}
	return token.AccessToken, nil
}

func (creds AzureWorkloadIdentityCreds) GetAzureDevOpsAccessToken() (string, error) {
	accessToken, err := creds.getAccessToken(azureDevopsEntraResourceId) // wellknown resourceid of Azure DevOps
	return accessToken, err
}

var _ Creds = AzureServicePrincipalCreds{}

// AzureServicePrincipalCreds to authenticate to Azure DevOps using a Service Principal
type AzureServicePrincipalCreds struct {
	tenantID                string
	clientID                string
	clientSecret            string
	activeDirectoryEndpoint string
	clientCertData          string
	clientCertKey           string
	proxy                   string
	noProxy                 string
	store                   CredsStore
}

// NewAzureServicePrincipalCreds creates new Azure Service Principal credentials
func NewAzureServicePrincipalCreds(tenantID string, clientID string, clientSecret string, store CredsStore) AzureServicePrincipalCreds {
	return AzureServicePrincipalCreds{tenantID: tenantID, clientID: clientID, clientSecret: clientSecret, store: store}
}

// WithActiveDirectoryEndpoint sets a custom Active Directory endpoint. When not set, the default Azure public cloud is used.
func (a AzureServicePrincipalCreds) WithActiveDirectoryEndpoint(activeDirectoryEndpoint string) AzureServicePrincipalCreds {
	if activeDirectoryEndpoint != "" {
		a.activeDirectoryEndpoint = activeDirectoryEndpoint
	}
	return a
}

// WithClientCert sets the client certificate data and key
func (a AzureServicePrincipalCreds) WithClientCert(data string, key string) AzureServicePrincipalCreds {
	if data != "" && key != "" {
		a.clientCertData = data
		a.clientCertKey = key
	}
	return a
}

// WithProxy sets the HTTP/HTTPS proxy used to access the repo
func (a AzureServicePrincipalCreds) WithProxy(proxy string) AzureServicePrincipalCreds {
	if proxy != "" {
		a.proxy = proxy
	}
	return a
}

// WithNoProxy sets a comma separated list of IPs/hostnames that should not use the proxy
func (a AzureServicePrincipalCreds) WithNoProxy(noProxy string) AzureServicePrincipalCreds {
	if noProxy != "" {
		a.noProxy = noProxy
	}
	return a
}

// GetUserInfo doesn't return any user info as they are not present for Azure Service Principals.
func (a AzureServicePrincipalCreds) GetUserInfo(_ context.Context) (string, string, error) {
	return workloadidentity.EmptyGuid, "", nil
}

func (a AzureServicePrincipalCreds) Environ() (io.Closer, []string, error) {
	token, err := a.getAccessToken()
	if err != nil {
		return NopCloser{}, nil, err
	}
	nonce := a.store.Add("", token)
	env := a.store.Environ(nonce)
	env = append(env, fmt.Sprintf("%s=Authorization: Bearer %s", bearerAuthHeaderEnv, token))

	return utilio.NewCloser(func() error {
		a.store.Remove(nonce)
		return nil
	}), env, nil
}

func (a AzureServicePrincipalCreds) getAccessToken() (string, error) {
	// Override the default active directory endpoint if present
	activeDirectoryEndpoint := "https://login.microsoftonline.com"
	disableInstanceDiscovery := false
	if a.activeDirectoryEndpoint != "" {
		activeDirectoryEndpoint = a.activeDirectoryEndpoint
		disableInstanceDiscovery = true
	}

	// Generate cache key for creds
	key, err := argoutils.GenerateCacheKey("%s %s %s %s", a.tenantID, a.clientID, a.clientSecret, activeDirectoryEndpoint)
	if err != nil {
		return "", fmt.Errorf("failed to get get SHA256 hash for Azure Service Principal credentials: %w", err)
	}

	t, found := azureServicePrincipalTokenCache.Get(key)
	if found {
		return t.(string), nil
	}

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	opts := azcore.ClientOptions{}
	opts.Cloud = cloud.Configuration{
		ActiveDirectoryAuthorityHost: activeDirectoryEndpoint,
	}
	// Configure HTTP client with proxy if proxy is set
	if a.proxy != "" {
		opts.Transport = GetRepoHTTPClient(activeDirectoryEndpoint, false, a, a.proxy, a.noProxy)
	}
	cred, err := azidentity.NewClientSecretCredential(a.tenantID, a.clientID, a.clientSecret, &azidentity.ClientSecretCredentialOptions{ClientOptions: opts, DisableInstanceDiscovery: disableInstanceDiscovery})
	if err != nil {
		return "", fmt.Errorf("failed to create Azure client secret credential: %w", err)
	}
	token, err := cred.GetToken(ctx, policy.TokenRequestOptions{
		Scopes: []string{azureDevopsEntraResourceId},
	})
	if err != nil {
		return "", fmt.Errorf("failed to get Azure access token: %w", err)
	}
	azureServicePrincipalTokenCache.Set(key, token.Token, 0)
	return token.Token, nil
}

func (a AzureServicePrincipalCreds) HasClientCert() bool {
	return a.clientCertData != "" && a.clientCertKey != ""
}

func (a AzureServicePrincipalCreds) GetClientCertData() string {
	return a.clientCertData
}

func (a AzureServicePrincipalCreds) GetClientCertKey() string {
	return a.clientCertKey
}

// credentialTypeFromJSON peeks at the "type" field and returns the matching google.CredentialsType.
// Defaults to google.ServiceAccount if the type is unrecognized or missing.
func credentialTypeFromJSON(jsonData []byte) google.CredentialsType {
	var f struct {
		Type string `json:"type"`
	}
	if err := json.Unmarshal(jsonData, &f); err != nil {
		return google.ServiceAccount
	}
	switch f.Type {
	case "external_account":
		return google.ExternalAccount
	case "impersonated_service_account":
		return google.ImpersonatedServiceAccount
	case "authorized_user":
		return google.AuthorizedUser
	default:
		return google.ServiceAccount
	}
}
