// Package service contains functionality that is common to all services.
package service

import (
	"context"
	"encoding/json"
	"fmt"
	"regexp"
	"strconv"
	"strings"

	"github.com/form3tech-oss/jwt-go"
	"github.com/microcosm-cc/bluemonday"
	"github.com/pingcap/errcode"
	"github.com/pingcap/errors"
	"github.com/tidbcloud/account/idl/pbgen/proto/acl"
	"github.com/tidbcloud/core-portal/internal/central/config"
	"github.com/tidbcloud/core-portal/internal/central/db"
	"github.com/tidbcloud/core-portal/internal/central/db/dbutil"
	"github.com/tidbcloud/core-portal/internal/central/db/tenant"
	"github.com/tidbcloud/core-portal/internal/central/grpc/grpcli"
	"github.com/tidbcloud/core-portal/internal/central/grpc/http"
	"github.com/tidbcloud/core-portal/internal/central/model/testtoken"
	"github.com/tidbcloud/core-portal/internal/central/pkg/auth/authuser"
	pkg_http "github.com/tidbcloud/core-portal/internal/central/pkg/grpc/http"
	"github.com/tidbcloud/core-portal/internal/central/rbac"
	entity "github.com/tidbcloud/core-svc/model"
	"github.com/tidbcloud/pkgv2/auth/auth0"
	"github.com/tidbcloud/pkgv2/auth/kong"
	"github.com/tidbcloud/pkgv2/auth/util"
	"github.com/tidbcloud/pkgv2/log"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

const (
	tenantIDHeader = "X-Tenant-ID"
)

const (
	BillingCleanClusters        = "/core.CoreService/CleanClusters"
	BillingCleanProjectResource = "/core.CoreService/CleanProjectResource"
	BillingCleanTenantResource  = "/core.CoreService/CleanTenantResource"
	BillingListClusters         = "/core.CoreService/ListClusters"
	CoreSvcDeleteCluster        = "/core.CoreService/DeleteCluster"

	RegistrationMethod          = "/central.TenantService/Registration"
	SendVerificationEmailMethod = "/central.TenantService/SendVerificationEmail"
	CreateAuth0UserMethod       = "/central.TenantService/CreateAuth0User"
	AcceptInvitationMethod      = "/central.TenantService/AcceptInvitation"
)

var (
	SkipGRPCAuthMap map[string]bool = map[string]bool{
		BillingCleanClusters:        true,
		BillingCleanProjectResource: true,
		BillingCleanTenantResource:  true,
		BillingListClusters:         true,
		CoreSvcDeleteCluster:        true,
	}

	CheckMap map[string]bool = map[string]bool{
		CreateAuth0UserMethod:       true,
		RegistrationMethod:          true,
		SendVerificationEmailMethod: true,
	}
	CheckEmailMap map[string]bool = map[string]bool{
		AcceptInvitationMethod: true,
	}
)

// The frontend relies on the error message, if you modify the content, you need to synchronize with the frontend.
const (
	// The custome-email user has not registered in the system
	UnregisteredUserErrMsg = "unregistered user"

	// The sso user has not registered in the system
	UnregisteredSsoUserErrMsg = "unregistered sso user"

	// User has been deleted
	InactiveUserErrMsg = "inactive user"

	// The user did not accept the invitation in the email
	PendingUserErrMsg = "pending user"

	// The user has self-registered and has not activated the account in the email
	PendingSignupUserErrMsg = "pending signup user"
)

// OperationsServiceState contains the service state
type OperationsServiceState struct {
	sanitizerPolicy *bluemonday.Policy
}

// OperationsService is the common interface for our services
type OperationsService interface {
	CurrentUser(context.Context) *tenant.User
	SanitizerPolicy() *bluemonday.Policy
}

// New constructs an OperationsService
func New() *OperationsServiceState {
	return &OperationsServiceState{
		sanitizerPolicy: bluemonday.UGCPolicy(),
	}
}

func (s *OperationsServiceState) SanitizerPolicy() *bluemonday.Policy {
	return s.sanitizerPolicy
}

// CurrentUser gets the user from the request.
// This function panics if there is no user in the db for the email or no email claim in the context.
func (s *OperationsServiceState) CurrentUser(ctx context.Context) *tenant.User {
	u, err := authuser.GetUserFromContext(ctx)
	if err != nil {
		panic("expected user claim")
	}
	return u
}

// GetUserClaimFunc satisfies the IntoGetClaim interface
type GetUserClaimFunc func(context.Context, jwt.MapClaims) (*tenant.User, error)

var ImpersonatedPOSTURLWhiteList = []*regexp.Regexp{
	regexp.MustCompile(`.*/query$`),
	regexp.MustCompile(`^/api_proxy/billing/api/v1/billing/tenants/(?P<tenant_id>\d+)/costexplorer.*$`),
}

func filterHttpRequest(httpMethod, url string) bool {
	switch httpMethod {
	case "GET", "OPTIONS":
		return false
	case "POST":
		for _, allowPattern := range ImpersonatedPOSTURLWhiteList {
			if allowPattern.MatchString(url) {
				return false
			}
		}
		return true
	default:
		return true
	}
}

// GetSubClaim retrieves the "sub" field from claims
func GetSubClaim(claims jwt.MapClaims) (string, error) {
	if sub, ok := claims["sub"]; ok {
		return sub.(string), nil
	}
	return "", errors.New("no sub claim")
}

// IsSubSSO checks if the "sub" claim of auth0 token is sso
func IsSubSSO(sub string) bool {
	return strings.HasPrefix(sub, config.Auth0SocialConnectionGoogle.String()) ||
		strings.HasPrefix(sub, config.Auth0SocialConnectionGithub.String())
}

// GetUserClaim queries the User from the email claim
func GetUserClaim(acceptBot bool) GetUserClaimFunc {
	return func(ctx context.Context, claims jwt.MapClaims) (*tenant.User, error) {
		email, err := auth0.GetEmailClaim(claims)
		if err != nil {
			if !acceptBot {
				return nil, errcode.NewNotAuthenticatedErr(err)
			}
			email, err = auth0.GetCustomEmailClaim(claims)
			if err != nil {
				return nil, errcode.NewNotAuthenticatedErr(err)
			}
		}

		url := grpc_http.HttpURI(ctx)
		if strings.HasPrefix(url, "/api/v1/admin/") {
			return centralAdminLogin(ctx, email)
		}
		u, found := tenant.GetUser(email)
		if !found {
			sub, err := GetSubClaim(claims)
			if err != nil {
				return nil, errcode.NewNotAuthenticatedErr(err)
			}

			if IsSubSSO(sub) {
				return nil, errcode.NewNotAuthenticatedErr(errors.New(UnregisteredSsoUserErrMsg))
			}
			return nil, errcode.NewNotAuthenticatedErr(errors.New(UnregisteredUserErrMsg))
		}
		if u.Tenant.Status == entity.TenantStatusCleared || u.Tenant.Status == entity.TenantStatusDeleted {
			log.Warn(ctx, "tenant has been cleared/deleted", zap.Uint64("tenantID", u.TenantID.Uint64()), zap.String("status", string(u.Tenant.Status)))
			return nil, errcode.NewNotAuthenticatedErr(errors.New(InactiveUserErrMsg))
		}
		// check the impersonated user first
		u, found = tenant.GetImpersonatedUser(u)
		if found {
			httpMethod := strings.ToUpper(grpc_http.HttpMethod(ctx))
			httpMethodFromContext := ctx.Value(auth0.AuthHttpMethod)
			if httpMethodFromContext != nil {
				httpMethod = strings.ToUpper(httpMethodFromContext.(string))
			}

			if filterHttpRequest(httpMethod, url) {
				return nil, errcode.NewForbiddenErr(errors.New(config.ErrImpersonatedUser))
			}
		}
		if u != nil && !u.IsImpersonated {
			switch u.Status {
			case entity.UserStatusActive:
				return u, nil
			case entity.UserStatusPending:
				if u.InvitedAt != nil {
					return nil, errcode.NewNotAuthenticatedErr(errors.New(PendingUserErrMsg))
				}
				if u.Email == u.Tenant.Email && u.Tenant.Status == entity.TenantStatusPending {
					return nil, errcode.NewNotAuthenticatedErr(errors.New(PendingSignupUserErrMsg))
				}
				return nil, errcode.NewNotAuthenticatedErr(errors.New(PendingUserErrMsg))
			case entity.UserStatusInactive:
				return nil, errcode.NewNotAuthenticatedErr(errors.New(InactiveUserErrMsg))
			}
		}
		return u, nil
	}
}

func canAccessAdminEndpoint(user *tenant.User, url string, method string) bool {
	return user.User.TenantID == entity.PingCAPTenantID && rbac.HasPrivilege(user.Roles, url, method)
}

func centralAdminLogin(ctx context.Context, email string) (*tenant.User, error) {
	admin, found := tenant.GetUser(email)
	if !found {
		return nil, errcode.NewNotAuthenticatedErr(errors.New("email is not found"))
	}
	if !canAccessAdminEndpoint(admin, grpc_http.HttpURI(ctx), grpc_http.HttpMethod(ctx)) {
		return nil, errcode.NewForbiddenErr(errors.New("forbidden"))
	}
	tenantID, err := strconv.ParseUint(getTenantID(ctx), 10, 64)
	if tenantID > 0 && err == nil {
		log.Info(ctx, "login as tenant system users", zap.String("email", email), zap.Uint64("tenant", tenantID))
		var tenantEntity entity.Tenant
		err := db.Transaction(func(tx *gorm.DB) error {
			return entity.NewTenantQuerySet(tx).IDEq(entity.ID(tenantID)).One(&tenantEntity)
		})
		if err != nil {
			return nil, err
		}
		admin.TenantID = tenantEntity.ID
	}
	return admin, nil
}

// ToGetClaim satisfies the IntoGetClaim interface
func (f GetUserClaimFunc) ToGetClaim() auth0.GetClaim {
	return func(ctx context.Context, claims jwt.MapClaims) (interface{}, error) {
		return f(ctx, claims)
	}
}

func getTenantID(ctx context.Context) string {
	if id := pkg_http.FirstHeader(ctx, tenantIDHeader); id != "" {
		return id
	}
	return grpc_http.Cookie(ctx, tenantIDHeader)
}

func getOpenAPIPublicKey(ctx context.Context) (string, error) {
	authType := pkg_http.FirstHeader(ctx, "X-Auth-Type")
	if authType != string(kong.AuthTypeOpenAPI) {
		return "", fmt.Errorf("unexpected auth type: %s", authType)
	}

	authContent := pkg_http.FirstHeader(ctx, "X-Auth-Content")
	result := new(kong.OpenAPIAuthResult)
	err := json.Unmarshal([]byte(authContent), result)
	if err != nil {
		return "", fmt.Errorf("failed to unmarshal: auth content: %s, error: %s", authContent, err)
	}
	publicKey := result.PublicKey
	if publicKey == "" {
		return "", fmt.Errorf("failed to retrieve public_key: auth content: %s", authContent)
	}
	return publicKey, nil
}

func getApiKeyUser(ctx context.Context) (*tenant.User, error) {
	publicKey, err := getOpenAPIPublicKey(ctx)
	if err != nil {
		log.Error(ctx, "failed to get open api public key", zap.Error(err))
		return nil, err
	}

	log.Info(ctx, "request to account-acl-svc GetOpenAPIAccess API", zap.String("publicKey", publicKey))
	getOpenAPIAccessReq := &acl.GetOpenAPIAccessReq{
		PublicKey: publicKey,
	}
	resp, err := grpcli.GetOpenAPIAccess(ctx, getOpenAPIAccessReq)
	if err != nil {
		log.Error(ctx, "failed to call account-acl-svc GetOpenAPIAccess API", zap.String("publicKey", publicKey), zap.Error(err))
		return nil, err
	}

	if resp.Org == nil {
		log.Error(ctx, "failed to get org id of public key", zap.String("publicKey", publicKey))
		return nil, fmt.Errorf("failed to retrieve tenantID: public key: %s", publicKey)
	}
	if len(resp.Projects) == 0 {
		log.Error(ctx, "failed to get projects of public key", zap.String("publicKey", publicKey))
		return nil, fmt.Errorf("failed to retrieve projectID: public key: %s", publicKey)
	}
	orgID := entity.ID(resp.Org.Id)

	var systemUser entity.User
	err = db.Transaction(func(tx *gorm.DB) error {
		systemUser, err = dbutil.SystemUser(tx, orgID)
		return err
	})
	if err != nil {
		log.Error(ctx, "failed to get system user", zap.String("publicKey", publicKey), zap.Uint64("tenantID", orgID.Uint64()), zap.Error(err))
		return nil, fmt.Errorf("failed to get api key user: public key: %s", publicKey)
	}

	user := &tenant.User{
		User: entity.User{
			Updatable: entity.Updatable{
				Base: entity.Base{
					ID: systemUser.ID,
				},
			},
			TenantID: orgID,
			Projects: nil,
		},
		TenantID:       orgID,
		IsImpersonated: false,
	}

	for _, project := range resp.Projects {
		user.User.Projects = append(user.User.Projects, entity.Project{
			Updatable: entity.Updatable{
				Base: entity.Base{
					ID: entity.ID(project.Id),
				},
			},
		})
	}
	return user, nil
}

// MakeAuthGRPC can be used in a GRPC middleware
// getClaim can be GetEmail
func MakeAuthGRPC(tokenValidator *auth0.RawTokenValidator, getClaim auth0.IntoGetClaim, testTokenModel testtoken.Model) func(context.Context) (context.Context, error) {
	return func(ctx context.Context) (context.Context, error) {
		url := grpc_http.HttpURI(ctx)

		if strings.HasPrefix(url, "/debug-api/") {
			// Debug Portal service has its own authentication. Skip here.
			return ctx, nil
		}
		if strings.HasPrefix(url, "/api/v1/test-token") {
			//get test token, Skip here.
			return ctx, nil
		}

		// Open API
		if strings.HasPrefix(url, "/api/v1beta") {
			user, err := getApiKeyUser(ctx)
			if err != nil {
				return nil, errcode.NewNotAuthenticatedErr(errors.New("unauthorized"))
			}
			return context.WithValue(ctx, auth0.AuthTokenClaimKey, user), nil
		}

		method := pkg_http.Method(ctx)
		// some gRPC service used only by internal microservices components, skip auth
		if val, ok := SkipGRPCAuthMap[method]; ok && val {
			return ctx, nil
		}

		// if client call accept invite interface, use token make auth instead of google auth
		if val, ok := CheckMap[method]; ok && val {
			// the header can't parse parameters across grpc-gateway
			// token auth leave to the logic layer
			// TODO: research on grpc header
			return ctx, nil
		}
		tokenRaw, err := GetBearerTokenFromGrpc(ctx)
		if err != nil {
			return nil, errcode.NewNotAuthenticatedErr(err)
		}

		var claims jwt.MapClaims

		var validatorID = auth0.AuthValidatorIDCentral
		newCtx := context.WithValue(ctx, auth0.AuthValidatorIDKey, validatorID)

		testUser := testTokenModel.TryGetTestUserFromToken(tokenRaw)
		var v interface{}
		if testUser == nil {
			claims, err = tokenValidator.ValidateFor(validatorID, tokenRaw)
			if err != nil {
				return nil, errcode.NewNotAuthenticatedErr(err)
			}

			if val, ok := CheckEmailMap[method]; ok && val {
				email, err := auth0.GetEmailClaim(claims)
				if err != nil {
					return nil, errcode.NewNotAuthenticatedErr(err)
				}
				user := entity.User{
					Email: email,
				}
				v = &tenant.User{
					User: user,
				}
			} else {
				v, err = getClaim.ToGetClaim()(ctx, claims)
				if err != nil {
					if _, ok := err.(errcode.ErrorCode); ok {
						return nil, err
					}
					return nil, errcode.NewNotAuthenticatedErr(err)
				}
			}
		} else {
			v = testUser
			log.Info(ctx, "login using test token", zap.String("email", testUser.Email))
		}

		// grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags"
		// grpc_ctxtags.Extract(ctx).Set("auth.sub", claims)
		newCtx = context.WithValue(newCtx, auth0.AuthTypeKey, auth0.GmailAuthType)
		newCtx = context.WithValue(newCtx, auth0.AuthTokenClaimKey, v)
		newCtx = context.WithValue(newCtx, auth0.AuthUserIDKey, claims["email"])
		return newCtx, nil
	}
}

// GetBearerTokenFromGrpc gets the bearer token string in `Bearer {xxx}` format in the Authorization header
func GetBearerTokenFromGrpc(ctx context.Context) (string, error) {
	bearToken := pkg_http.FirstHeader(ctx, "authorization")
	if bearToken != "" {
		tokenRaw, err := util.ExtractBearerToken(bearToken)
		if err != nil {
			newErr := errors.Annotate(err, "Auth Header invalid")
			return "", newErr
		}
		return tokenRaw, nil
	}
	return grpc_http.Query(ctx, "jwt"), nil
}

func getUserById(ctx context.Context, uid entity.ID) (*tenant.User, error) {
	var user *entity.User
	if err := db.Transaction(func(tx *gorm.DB) error {
		var err error
		user, err = dbutil.GetUserByUID(tx, uid)
		return err
	}); err != nil {
		return nil, err
	}

	return &tenant.User{
		User:           *user,
		TenantID:       user.TenantID,
		IsImpersonated: false,
	}, nil
}

// GetAndSetUserById populate a mock caller user as the as called by user
func GetAndSetUserById(ctx context.Context, uid entity.ID) (*tenant.User, context.Context, error) {
	user, err := getUserById(ctx, uid)
	if err != nil {
		return nil, ctx, err
	}

	ctx = context.WithValue(ctx, auth0.AuthTokenClaimKey, user)
	return user, ctx, nil
}
