package github

import (
	"fmt"
	"net/http"
	"strconv"
	"strings"

	"github.com/pkg/errors"
	"github.com/rancher/apiserver/pkg/apierror"
	"github.com/rancher/norman/types"
	"github.com/rancher/norman/types/convert"
	apiv3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3"
	"github.com/rancher/rancher/pkg/auth/accessor"
	"github.com/rancher/rancher/pkg/auth/providers/common"
	"github.com/rancher/rancher/pkg/auth/tokens"
	util2 "github.com/rancher/rancher/pkg/auth/util"
	client "github.com/rancher/rancher/pkg/client/generated/management/v3"
	publicclient "github.com/rancher/rancher/pkg/client/generated/management/v3public"
	v3 "github.com/rancher/rancher/pkg/generated/norman/management.cattle.io/v3"
	"github.com/rancher/rancher/pkg/types/config"
	"github.com/rancher/rancher/pkg/user"
	wcorev1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1"
	"github.com/rancher/wrangler/v3/pkg/schemas/validation"
	"github.com/sirupsen/logrus"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
)

const (
	Name = "github"
)

type tokensManager interface {
	GetSecret(userID string, provider string, fallbackTokens []accessor.TokenAccessor) (string, error)
	CreateTokenAndSetCookie(userID string, userPrincipal apiv3.Principal, groupPrincipals []apiv3.Principal, providerToken string, ttl int, description string, request *types.APIContext) error
}

type Provider struct {
	authConfigs  v3.AuthConfigInterface
	secrets      wcorev1.SecretController
	getConfig    func() (*apiv3.GithubConfig, error)
	githubClient *GClient
	userMGR      user.Manager
	tokenMGR     tokensManager
}

func Configure(mgmtCtx *config.ScaledContext, userMGR user.Manager, tokenMGR *tokens.Manager) common.AuthProvider {
	githubClient := &GClient{
		httpClient: common.NewHTTPClientWithTimeouts(),
	}

	provider := &Provider{
		authConfigs:  mgmtCtx.Management.AuthConfigs(""),
		secrets:      mgmtCtx.Wrangler.Core.Secret(),
		githubClient: githubClient,
		userMGR:      userMGR,
		tokenMGR:     tokenMGR,
	}
	provider.getConfig = provider.getGithubConfigCR

	return provider
}

func (g *Provider) LogoutAll(w http.ResponseWriter, r *http.Request, token accessor.TokenAccessor) error {
	return nil
}

func (g *Provider) Logout(w http.ResponseWriter, r *http.Request, token accessor.TokenAccessor) error {
	return nil
}

func (g *Provider) GetName() string {
	return Name
}

func (g *Provider) CustomizeSchema(schema *types.Schema) {
	schema.ActionHandler = g.actionHandler
	schema.Formatter = g.formatter
}

func (g *Provider) TransformToAuthProvider(authConfig map[string]any) (map[string]any, error) {
	p := common.TransformToAuthProvider(authConfig)
	p[publicclient.GithubProviderFieldRedirectURL] = formGithubRedirectURLFromMap(authConfig)
	return p, nil
}

func (g *Provider) getGithubConfigCR() (*apiv3.GithubConfig, error) {
	authConfigObj, err := g.authConfigs.ObjectClient().UnstructuredClient().Get(Name, metav1.GetOptions{})
	if err != nil {
		return nil, fmt.Errorf("failed to retrieve GithubConfig, error: %v", err)
	}
	u, ok := authConfigObj.(runtime.Unstructured)
	if !ok {
		return nil, fmt.Errorf("failed to retrieve GithubConfig, cannot read k8s Unstructured data")
	}
	storedGithubConfigMap := u.UnstructuredContent()

	storedGithubConfig := &apiv3.GithubConfig{}
	err = common.Decode(storedGithubConfigMap, storedGithubConfig)
	if err != nil {
		return nil, fmt.Errorf("unable to decode Github Config: %w", err)
	}

	if storedGithubConfig.ClientSecret != "" {
		data, err := common.ReadFromSecretData(g.secrets, storedGithubConfig.ClientSecret)
		if err != nil {
			return nil, err
		}
		for k, v := range data {
			if strings.EqualFold(k, client.GithubConfigFieldClientSecret) {
				storedGithubConfig.ClientSecret = string(v)
			} else {
				if storedGithubConfig.AdditionalClientIDs == nil {
					storedGithubConfig.AdditionalClientIDs = map[string]string{}
				}
				storedGithubConfig.AdditionalClientIDs[k] = strings.TrimSpace(string(v))
			}
		}
	}

	return storedGithubConfig, nil
}

func (g *Provider) saveGithubConfig(config *apiv3.GithubConfig) error {
	storedGithubConfig, err := g.getGithubConfigCR()
	if err != nil {
		return err
	}
	config.APIVersion = "management.cattle.io/v3"
	config.Kind = v3.AuthConfigGroupVersionKind.Kind
	config.Type = client.GithubConfigType
	config.ObjectMeta = storedGithubConfig.ObjectMeta

	secretInfo := convert.ToString(config.ClientSecret)
	field := strings.ToLower(client.GithubConfigFieldClientSecret)
	name, err := common.CreateOrUpdateSecrets(g.secrets, secretInfo, field, strings.ToLower(config.Type))
	if err != nil {
		return err
	}

	config.ClientSecret = name

	_, err = g.authConfigs.ObjectClient().Update(config.ObjectMeta.Name, config)
	if err != nil {
		return err
	}
	return nil
}

func (g *Provider) AuthenticateUser(_ http.ResponseWriter, req *http.Request, input any) (apiv3.Principal, []apiv3.Principal, string, error) {
	login, ok := input.(*apiv3.GithubLogin)
	if !ok {
		return apiv3.Principal{}, nil, "", errors.New("unexpected input type")
	}
	return g.LoginUser(util2.GetHost(req), login, nil, false)
}

func choseClientID(host string, config *apiv3.GithubConfig) *apiv3.GithubConfig {
	if host == "" {
		return config
	}

	clientID := config.HostnameToClientID[host]
	secretID := config.AdditionalClientIDs[clientID]
	if secretID == "" {
		return config
	}

	copy := *config
	copy.ClientID = clientID
	copy.ClientSecret = secretID

	return &copy
}

func (g *Provider) LoginUser(host string, githubCredential *apiv3.GithubLogin, config *apiv3.GithubConfig, test bool) (apiv3.Principal, []apiv3.Principal, string, error) {
	var groupPrincipals []apiv3.Principal
	var userPrincipal apiv3.Principal
	var err error

	if config == nil {
		config, err = g.getConfig()
		if err != nil {
			return apiv3.Principal{}, nil, "", err
		}
	}

	config = choseClientID(host, config)
	securityCode := githubCredential.Code

	accessToken, err := g.githubClient.getAccessToken(securityCode, config)
	if err != nil {
		logrus.Infof("Error generating accessToken from github %v", err)
		return apiv3.Principal{}, nil, "", err
	}

	user, err := g.githubClient.getUser(accessToken, config)
	if err != nil {
		return apiv3.Principal{}, nil, "", err
	}
	userPrincipal = g.toPrincipal(userType, user, nil)
	userPrincipal.Me = true

	orgAccts, err := g.githubClient.getOrgs(accessToken, config)
	if err != nil {
		return apiv3.Principal{}, nil, "", err
	}
	for _, orgAcct := range orgAccts {
		groupPrincipal := g.toPrincipal(orgType, orgAcct, nil)
		groupPrincipal.MemberOf = true
		groupPrincipals = append(groupPrincipals, groupPrincipal)
	}

	teamAccts, err := g.githubClient.getTeams(accessToken, config)
	if err != nil {
		return apiv3.Principal{}, nil, "", err
	}
	for _, teamAcct := range teamAccts {
		groupPrincipal := g.toPrincipal(teamType, teamAcct, nil)
		groupPrincipal.MemberOf = true
		groupPrincipals = append(groupPrincipals, groupPrincipal)
	}

	testAllowedPrincipals := config.AllowedPrincipalIDs
	if test && config.AccessMode == "restricted" {
		testAllowedPrincipals = append(testAllowedPrincipals, userPrincipal.Name)
	}

	allowed, err := g.userMGR.CheckAccess(config.AccessMode, testAllowedPrincipals, userPrincipal.Name, groupPrincipals)
	if err != nil {
		return apiv3.Principal{}, nil, "", err
	}
	if !allowed {
		return apiv3.Principal{}, nil, "", apierror.NewAPIError(validation.Unauthorized, "unauthorized")
	}

	return userPrincipal, groupPrincipals, accessToken, nil
}

func (g *Provider) RefetchGroupPrincipals(principalID string, secret string) ([]apiv3.Principal, error) {
	var groupPrincipals []apiv3.Principal
	var err error
	var config *apiv3.GithubConfig

	config, err = g.getConfig()
	if err != nil {
		return nil, err
	}

	orgAccts, err := g.githubClient.getOrgs(secret, config)
	if err != nil {
		return nil, err
	}
	for _, orgAcct := range orgAccts {
		groupPrincipal := g.toPrincipal(orgType, orgAcct, nil)
		groupPrincipal.MemberOf = true
		groupPrincipals = append(groupPrincipals, groupPrincipal)
	}

	teamAccts, err := g.githubClient.getTeams(secret, config)
	if err != nil {
		return nil, err
	}
	for _, teamAcct := range teamAccts {
		groupPrincipal := g.toPrincipal(teamType, teamAcct, nil)
		groupPrincipal.MemberOf = true
		groupPrincipals = append(groupPrincipals, groupPrincipal)
	}

	return groupPrincipals, nil
}

func (g *Provider) SearchPrincipals(searchKey, principalType string, token accessor.TokenAccessor) ([]apiv3.Principal, error) {
	var principals []apiv3.Principal
	var err error

	config, err := g.getConfig()
	if err != nil {
		return principals, err
	}

	accessToken, err := g.tokenMGR.GetSecret(token.GetUserID(), token.GetAuthProvider(), []accessor.TokenAccessor{token})
	if err != nil {
		if !apierrors.IsNotFound(err) {
			return nil, err
		}
		accessToken = token.GetProviderInfo()["access_token"]
	}

	accts, err := g.githubClient.searchUsers(searchKey, principalType, accessToken, config)
	if err != nil {
		logrus.Errorf("problem searching github: %v", err)
	}

	for _, acct := range accts {
		pType := strings.ToLower(acct.Type)
		if pType == "organization" {
			pType = orgType
		}
		p := g.toPrincipal(pType, acct, token)
		principals = append(principals, p)
	}

	if principalType == "" || principalType == "group" {
		// Additionally see if there are any matching teams since GitHub user search API doesn't cover those.
		teamAccts, err := g.githubClient.searchTeams(searchKey, accessToken, config)
		if err != nil {
			return nil, err
		}

		for _, acct := range teamAccts {
			p := g.toPrincipal(teamType, acct, token)
			principals = append(principals, p)
		}
	}

	return principals, nil
}

const (
	userType = "user"
	teamType = "team"
	orgType  = "org"
)

func (g *Provider) GetPrincipal(principalID string, token accessor.TokenAccessor) (apiv3.Principal, error) {
	config, err := g.getConfig()
	if err != nil {
		return apiv3.Principal{}, err
	}

	accessToken, err := g.tokenMGR.GetSecret(token.GetUserID(), token.GetAuthProvider(), []accessor.TokenAccessor{token})
	if err != nil {
		if !apierrors.IsNotFound(err) {
			return apiv3.Principal{}, err
		}
		accessToken = token.GetProviderInfo()["access_token"]
	}
	// parsing id to get the external id and type. id looks like github_[user|org|team]://12345
	var externalID string
	parts := strings.SplitN(principalID, ":", 2)
	if len(parts) != 2 {
		return apiv3.Principal{}, errors.Errorf("invalid id %v", principalID)
	}
	externalID = strings.TrimPrefix(parts[1], "//")
	parts = strings.SplitN(parts[0], "_", 2)
	if len(parts) != 2 {
		return apiv3.Principal{}, errors.Errorf("invalid id %v", principalID)
	}

	principalType := parts[1]
	var acct common.GitHubAccount
	switch principalType {
	case userType, orgType:
		acct, err = g.githubClient.getUserOrgByID(externalID, accessToken, config)
		if err != nil {
			return apiv3.Principal{}, err
		}
	case teamType:
		acct, err = g.githubClient.getTeamByID(externalID, accessToken, config)
		if err != nil {
			return apiv3.Principal{}, err
		}
	default:
		return apiv3.Principal{}, fmt.Errorf("cannot get the github account due to invalid externalIDType %v", principalType)
	}

	princ := g.toPrincipal(principalType, acct, token)
	return princ, nil
}

func (g *Provider) toPrincipal(principalType string, acct common.GitHubAccount, token accessor.TokenAccessor) apiv3.Principal {
	displayName := acct.Name
	if displayName == "" {
		displayName = acct.Login
	}

	princ := apiv3.Principal{
		ObjectMeta:     metav1.ObjectMeta{Name: Name + "_" + principalType + "://" + strconv.Itoa(acct.ID)},
		DisplayName:    displayName,
		LoginName:      acct.Login,
		Provider:       Name,
		Me:             false,
		ProfilePicture: acct.AvatarURL,
	}

	if principalType == userType {
		princ.PrincipalType = "user"
		if token != nil {
			princ.Me = common.SamePrincipal(token.GetUserPrincipal(), princ)
		}
	} else {
		princ.PrincipalType = "group"
		if token != nil {
			princ.MemberOf = g.userMGR.IsMemberOf(token, princ)
		}
	}

	return princ
}

func (g *Provider) CanAccessWithGroupProviders(userPrincipalID string, groupPrincipals []apiv3.Principal) (bool, error) {
	config, err := g.getConfig()
	if err != nil {
		logrus.Errorf("Error fetching github config: %v", err)
		return false, err
	}

	return g.userMGR.CheckAccess(config.AccessMode, config.AllowedPrincipalIDs, userPrincipalID, groupPrincipals)
}

func (g *Provider) GetUserExtraAttributes(userPrincipal apiv3.Principal) map[string][]string {
	return common.GetCommonUserExtraAttributes(userPrincipal)
}

// IsDisabledProvider checks if the GitHub auth provider is currently disabled in Rancher.
func (g *Provider) IsDisabledProvider() (bool, error) {
	ghConfig, err := g.getConfig()
	if err != nil {
		return false, err
	}
	return !ghConfig.Enabled, nil
}
