package gitauth

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"regexp"
	"time"

	"golang.org/x/oauth2"
	"golang.org/x/xerrors"

	"github.com/google/go-github/v43/github"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/httpapi"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/retry"
)

type OAuth2Config interface {
	AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
	Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
	TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource
}

// Config is used for authentication for Git operations.
type Config struct {
	OAuth2Config
	// ID is a unique identifier for the authenticator.
	ID string
	// Regex is a regexp that URLs will match against.
	Regex *regexp.Regexp
	// Type is the type of provider.
	Type codersdk.GitProvider
	// NoRefresh stops Coder from using the refresh token
	// to renew the access token.
	//
	// Some organizations have security policies that require
	// re-authentication for every token.
	NoRefresh bool
	// ValidateURL ensures an access token is valid before
	// returning it to the user. If omitted, tokens will
	// not be validated before being returned.
	ValidateURL string
	// AppInstallURL is for GitHub App's (and hopefully others eventually)
	// to provide a link to install the app. There's installation
	// of the application, and user authentication. It's possible
	// for the user to authenticate but the application to not.
	AppInstallURL string
	// InstallationsURL is an API endpoint that returns a list of
	// installations for the user. This is used for GitHub Apps.
	AppInstallationsURL string
	// DeviceAuth is set if the provider uses the device flow.
	DeviceAuth *DeviceAuth
}

// RefreshToken automatically refreshes the token if expired and permitted.
// It returns the token and a bool indicating if the token was refreshed.
func (c *Config) RefreshToken(ctx context.Context, db database.Store, gitAuthLink database.GitAuthLink) (database.GitAuthLink, bool, error) {
	// If the token is expired and refresh is disabled, we prompt
	// the user to authenticate again.
	if c.NoRefresh && gitAuthLink.OAuthExpiry.Before(database.Now()) {
		return gitAuthLink, false, nil
	}

	token, err := c.TokenSource(ctx, &oauth2.Token{
		AccessToken:  gitAuthLink.OAuthAccessToken,
		RefreshToken: gitAuthLink.OAuthRefreshToken,
		Expiry:       gitAuthLink.OAuthExpiry,
	}).Token()
	if err != nil {
		// Even if the token fails to be obtained, we still return false because
		// we aren't trying to surface an error, we're just trying to obtain a valid token.
		return gitAuthLink, false, nil
	}
	r := retry.New(50*time.Millisecond, 200*time.Millisecond)
	// See the comment below why the retry and cancel is required.
	retryCtx, retryCtxCancel := context.WithTimeout(ctx, time.Second)
	defer retryCtxCancel()
validate:
	valid, _, err := c.ValidateToken(ctx, token.AccessToken)
	if err != nil {
		return gitAuthLink, false, xerrors.Errorf("validate git auth token: %w", err)
	}
	if !valid {
		// A customer using GitHub in Australia reported that validating immediately
		// after refreshing the token would intermittently fail with a 401. Waiting
		// a few milliseconds with the exact same token on the exact same request
		// would resolve the issue. It seems likely that the write is not propagating
		// to the read replica in time.
		//
		// We do an exponential backoff here to give the write time to propagate.
		if c.Type == codersdk.GitProviderGitHub && r.Wait(retryCtx) {
			goto validate
		}
		// The token is no longer valid!
		return gitAuthLink, false, nil
	}

	if token.AccessToken != gitAuthLink.OAuthAccessToken {
		// Update it
		gitAuthLink, err = db.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
			ProviderID:        c.ID,
			UserID:            gitAuthLink.UserID,
			UpdatedAt:         database.Now(),
			OAuthAccessToken:  token.AccessToken,
			OAuthRefreshToken: token.RefreshToken,
			OAuthExpiry:       token.Expiry,
		})
		if err != nil {
			return gitAuthLink, false, xerrors.Errorf("update git auth link: %w", err)
		}
	}
	return gitAuthLink, true, nil
}

// ValidateToken ensures the Git token provided is valid!
// The user is optionally returned if the provider supports it.
func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *codersdk.GitAuthUser, error) {
	if c.ValidateURL == "" {
		// Default that the token is valid if no validation URL is provided.
		return true, nil, nil
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ValidateURL, nil)
	if err != nil {
		return false, nil, err
	}
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return false, nil, err
	}
	defer res.Body.Close()
	if res.StatusCode == http.StatusUnauthorized {
		// The token is no longer valid!
		return false, nil, nil
	}
	if res.StatusCode != http.StatusOK {
		data, _ := io.ReadAll(res.Body)
		return false, nil, xerrors.Errorf("status %d: body: %s", res.StatusCode, data)
	}

	var user *codersdk.GitAuthUser
	if c.Type == codersdk.GitProviderGitHub {
		var ghUser github.User
		err = json.NewDecoder(res.Body).Decode(&ghUser)
		if err == nil {
			user = &codersdk.GitAuthUser{
				Login:      ghUser.GetLogin(),
				AvatarURL:  ghUser.GetAvatarURL(),
				ProfileURL: ghUser.GetHTMLURL(),
				Name:       ghUser.GetName(),
			}
		}
	}

	return true, user, nil
}

type AppInstallation struct {
	ID int
	// Login is the username of the installation.
	Login string
	// URL is a link to configure the app install.
	URL string
}

// AppInstallations returns a list of app installations for the given token.
// If the provider does not support app installations, it returns nil.
func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk.GitAuthAppInstallation, bool, error) {
	if c.AppInstallationsURL == "" {
		return nil, false, nil
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.AppInstallationsURL, nil)
	if err != nil {
		return nil, false, err
	}
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, false, err
	}
	defer res.Body.Close()
	// It's possible the installation URL is misconfigured, so we don't
	// want to return an error here.
	if res.StatusCode != http.StatusOK {
		return nil, false, nil
	}
	installs := []codersdk.GitAuthAppInstallation{}
	if c.Type == codersdk.GitProviderGitHub {
		var ghInstalls struct {
			Installations []*github.Installation `json:"installations"`
		}
		err = json.NewDecoder(res.Body).Decode(&ghInstalls)
		if err != nil {
			return nil, false, err
		}
		for _, installation := range ghInstalls.Installations {
			account := installation.GetAccount()
			if account == nil {
				continue
			}
			installs = append(installs, codersdk.GitAuthAppInstallation{
				ID:           int(installation.GetID()),
				ConfigureURL: installation.GetHTMLURL(),
				Account: codersdk.GitAuthUser{
					Login:      account.GetLogin(),
					AvatarURL:  account.GetAvatarURL(),
					ProfileURL: account.GetHTMLURL(),
					Name:       account.GetName(),
				},
			})
		}
	}
	return installs, true, nil
}

// ConvertConfig converts the SDK configuration entry format
// to the parsed and ready-to-consume in coderd provider type.
func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Config, error) {
	ids := map[string]struct{}{}
	configs := []*Config{}
	for _, entry := range entries {
		var typ codersdk.GitProvider
		switch codersdk.GitProvider(entry.Type) {
		case codersdk.GitProviderAzureDevops:
			typ = codersdk.GitProviderAzureDevops
		case codersdk.GitProviderBitBucket:
			typ = codersdk.GitProviderBitBucket
		case codersdk.GitProviderGitHub:
			typ = codersdk.GitProviderGitHub
		case codersdk.GitProviderGitLab:
			typ = codersdk.GitProviderGitLab
		default:
			return nil, xerrors.Errorf("unknown git provider type: %q", entry.Type)
		}
		if entry.ID == "" {
			// Default to the type.
			entry.ID = string(typ)
		}
		if valid := httpapi.NameValid(entry.ID); valid != nil {
			return nil, xerrors.Errorf("git auth provider %q doesn't have a valid id: %w", entry.ID, valid)
		}

		_, exists := ids[entry.ID]
		if exists {
			if entry.ID == string(typ) {
				return nil, xerrors.Errorf("multiple %s git auth providers provided. you must specify a unique id for each", typ)
			}
			return nil, xerrors.Errorf("multiple git providers exist with the id %q. specify a unique id for each", entry.ID)
		}
		ids[entry.ID] = struct{}{}

		if entry.ClientID == "" {
			return nil, xerrors.Errorf("%q git auth provider: client_id must be provided", entry.ID)
		}
		authRedirect, err := accessURL.Parse(fmt.Sprintf("/gitauth/%s/callback", entry.ID))
		if err != nil {
			return nil, xerrors.Errorf("parse gitauth callback url: %w", err)
		}
		regex := regex[typ]
		if entry.Regex != "" {
			regex, err = regexp.Compile(entry.Regex)
			if err != nil {
				return nil, xerrors.Errorf("compile regex for git auth provider %q: %w", entry.ID, entry.Regex)
			}
		}

		oc := &oauth2.Config{
			ClientID:     entry.ClientID,
			ClientSecret: entry.ClientSecret,
			Endpoint:     endpoint[typ],
			RedirectURL:  authRedirect.String(),
			Scopes:       scope[typ],
		}

		if entry.AuthURL != "" {
			oc.Endpoint.AuthURL = entry.AuthURL
		}
		if entry.TokenURL != "" {
			oc.Endpoint.TokenURL = entry.TokenURL
		}
		if entry.Scopes != nil && len(entry.Scopes) > 0 {
			oc.Scopes = entry.Scopes
		}
		if entry.ValidateURL == "" {
			entry.ValidateURL = validateURL[typ]
		}
		if entry.AppInstallationsURL == "" {
			entry.AppInstallationsURL = appInstallationsURL[typ]
		}

		var oauthConfig OAuth2Config = oc
		// Azure DevOps uses JWT token authentication!
		if typ == codersdk.GitProviderAzureDevops {
			oauthConfig = &jwtConfig{oc}
		}

		cfg := &Config{
			OAuth2Config:        oauthConfig,
			ID:                  entry.ID,
			Regex:               regex,
			Type:                typ,
			NoRefresh:           entry.NoRefresh,
			ValidateURL:         entry.ValidateURL,
			AppInstallationsURL: entry.AppInstallationsURL,
			AppInstallURL:       entry.AppInstallURL,
		}

		if entry.DeviceFlow {
			if entry.DeviceCodeURL == "" {
				entry.DeviceCodeURL = deviceAuthURL[typ]
			}
			if entry.DeviceCodeURL == "" {
				return nil, xerrors.Errorf("git auth provider %q: device auth url must be provided", entry.ID)
			}
			cfg.DeviceAuth = &DeviceAuth{
				ClientID: entry.ClientID,
				TokenURL: oc.Endpoint.TokenURL,
				Scopes:   entry.Scopes,
				CodeURL:  entry.DeviceCodeURL,
			}
		}

		configs = append(configs, cfg)
	}
	return configs, nil
}
