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

import (
	"context"
	"regexp"
	"strconv"
	"strings"

	"github.com/form3tech-oss/jwt-go"
	"github.com/microcosm-cc/bluemonday"
	"github.com/pingcap/dbaas/central/config"
	"github.com/pingcap/dbaas/central/db"
	"github.com/pingcap/dbaas/central/db/entity"
	"github.com/pingcap/dbaas/central/db/tenant"
	grpc_http "github.com/pingcap/dbaas/central/grpc/http"
	"github.com/pingcap/dbaas/central/model"
	"github.com/pingcap/dbaas/central/model/session"
	"github.com/pingcap/dbaas/central/model/testtoken"
	"github.com/pingcap/dbaas/central/rbac"
	"github.com/pingcap/dbaas/pkg/auth/authuser"
	pkg_http "github.com/pingcap/dbaas/pkg/grpc/http"
	"github.com/pingcap/errcode"
	"github.com/pingcap/errors"
	"github.com/tidbcloud/idl/pbgen/proto/central"
	"github.com/tidbcloud/pkgv2/auth/auth0"
	"github.com/tidbcloud/pkgv2/auth/util"
	"github.com/tidbcloud/pkgv2/log"
	"go.uber.org/zap"
	"google.golang.org/grpc/metadata"
	"gorm.io/gorm"
)

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

// 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 syxstem users", zap.String("email", email), zap.Uint64("tenant", tenantID))
		var tenant entity.Tenant
		err := db.Transaction(func(tx *gorm.DB) error {
			return entity.NewTenantQuerySet(tx).IDEq(entity.ID(tenantID)).One(&tenant)
		})
		if err != nil {
			return nil, err
		}
		admin.TenantID = tenant.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 ListAccessLogs(items []model.AccessLog, count int64, err error) (*central.ListAccessLogResp, error) {
	if err != nil {
		return nil, errcode.NewInternalErr(err)
	}
	ret := &central.ListAccessLogResp{Total: uint32(count)}
	for _, item := range items {
		ret.Items = append(ret.Items, &central.AccessLog{
			Id:         item.ID,
			HappenedOn: item.HappenedOn,
			User:       item.User,
			Content:    item.Content,
		},
		)
	}
	return ret, 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
		}

		method := pkg_http.Method(ctx)
		// if client call accept invite interface, use token make auth instead of google auth
		if val, ok := session.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 := session.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
	}
}

func GetClientIP(ctx context.Context) (string, error) {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return "", errcode.NewInternalErr(errors.New("failed to invoke FromIncomingContext"))
	}

	rips := md.Get("X-Forwarded-For")
	if len(rips) == 0 {
		return "", errcode.NewInternalErr(errors.New("remote real addr is empty"))
	}

	return rips[0], 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
}
