package authn

import (
	"context"
	"fmt"
	"math/rand"
	"regexp"
	"strings"
	"time"

	"github.com/tidbcloud/account/internel/svc/auditlog"
	"github.com/tidbcloud/account/internel/svc/dbuser"
	orgsso2 "github.com/tidbcloud/account/internel/svc/orgsso"
	token2 "github.com/tidbcloud/account/internel/svc/token"
	usersvc "github.com/tidbcloud/account/internel/svc/user"

	"github.com/tidbcloud/tiinf/errs"

	"github.com/form3tech-oss/jwt-go"
	grpc_auth "github.com/grpc-ecosystem/go-grpc-middleware/auth"
	"github.com/microcosm-cc/bluemonday"
	"github.com/pingcap/errcode"
	"github.com/pingcap/errors"
	"github.com/tidbcloud/pkgv2/auth/auth0"
	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"

	"github.com/tidbcloud/account/central/utils"
	"github.com/tidbcloud/account/commons/config"
	"github.com/tidbcloud/account/idl/pbgen/proto/account"
	"github.com/tidbcloud/account/idl/pbgen/proto/central"
	"github.com/tidbcloud/account/internel/dao/entity"
	pkg_http "github.com/tidbcloud/account/pkg/grpc/http"
)

// 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"

	EnterpriseUserAutoProvisionDisabledErrMsg = "enterprise user provisioning disabled"

	// 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"
	PendingSignupSSOUserErrMsg = "pending signup sso user"
)

var (
	SkipAuthMethod = map[string]struct{}{
		"/central.MetricsService/LoginMetrics":            {},
		"/central.DbJwtService/GetJwtByToken":             {},
		"/central.DbJwtService/GetJwtByUser":              {},
		"/central.DbJwtService/GetJwtByApiKey":            {},
		"/central.DbJwtService/GetCloudAdminPasswd":       {},
		"/central.DbJwtService/GetJwtByBuiltinUser":       {},
		"/central.TenantService/ListOrgActiveAuthMethods": {},
		"/central.TenantService/GetMspConfig":             {},
		"/central.ApiKeyService/CreateApiKeyForMsp":       {},
		"/central.OrgAuthMethodService/CheckAuthMethod":   {}, // for enterprise connection fast login page to check connection name valid
	}
)

// 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) *usersvc.User
	CurrentUserSub(context.Context) string
	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) *usersvc.User {
	u, err := usersvc.GetUserFromContext(ctx)
	if err != nil {
		panic("expected user claim")
	}
	return u
}

func (s *OperationsServiceState) CurrentUserSub(ctx context.Context) string {
	sub, err := usersvc.GetUserSubFromContext(ctx)
	if err != nil {
		panic("expected user sub")
	}
	return sub
}

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

func shouldBlockImpersonatedUser(httpMethod, url string) bool {
	// impersonated users have readonly access to the tenant
	switch httpMethod {
	case "GET", "OPTIONS":
		return false
	case "POST":
		for _, allowPattern := range ImpersonatedPOSTURLWhiteList {
			if allowPattern.MatchString(url) {
				return false
			}
		}
		return true
	default:
		return true
	}
}

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

	for k := range config.Global().OktaMapperConfig {
		if strings.HasPrefix(sub, fmt.Sprintf("oidc|%s", k)) { // e.g. "sub": "oidc|okta-booking|abcdefg",
			return true
		}
	}

	return false
}

func GetUserClaim() auth0.GetClaim {
	return func(ctx context.Context, claims jwt.MapClaims) (interface{}, error) {
		email, err := auth0.GetEmailClaim(claims)
		if err != nil {
			return nil, errcode.NewNotAuthenticatedErr(err)
		}

		url := pkg_http.GetHttpURI(ctx)
		httpMethod := pkg_http.GetHttpMethod(ctx)

		orgID, found := utils.ParseOrgIDFromPath(url)

		// enterprise org who open auto provision will go auto register user when user not exist or user not in org
		autoProvisionOrgId := orgID
		if autoProvisionOrgId == 0 {
			// get org id from claim for the url who do not contain org_id
			autoProvisionOrgId, _ = orgsso2.GetOrgIDClaim(claims)
		}
		if autoProvisionOrgId != 0 {
			methodName, _ := orgsso2.GetMethodNameFromClaim(claims)
			orgType, _ := orgsso2.GetOrgTypeClaim(claims)
			if orgType == string(entity.OrgTypeEnterprise) {
				svc, _ := orgsso2.GetOrgUserRegisterSvc()
				autoRegisterRet, err := svc.WhetherGoAutoRegister(ctx, email, autoProvisionOrgId, methodName)
				if err != nil {
					return nil, err
				}
				// org open auto provision
				if autoRegisterRet.AutoProvision {
					if !autoRegisterRet.UserExist {
						// user not exist go auto register
						tilog.Info(ctx, "WhetherGoAutoRegister result: user not exist, go terms register", zap.Any("orgID", autoProvisionOrgId), zap.Any("methodName", methodName), zap.Any("email", email))
						return nil, errcode.NewNotAuthenticatedErr(errors.New(UnregisteredSsoUserErrMsg))
					}
					if !autoRegisterRet.UserInAccessOrg {
						// user not in access org go auto added
						tilog.Info(ctx, "WhetherGoAutoRegister result: user exit but not in access org, go auto register", zap.Any("orgID", autoProvisionOrgId), zap.Any("methodName", methodName), zap.Any("email", email))
						err := svc.AutoAddUserToOrg(ctx, email, autoProvisionOrgId)
						if err != nil {
							return nil, err
						}
					}
				} else {
					// not open auto provision and user not exist
					if !autoRegisterRet.UserExist {
						tilog.Info(ctx, "WhetherGoAutoRegister result: not open auto provision and user not exist", zap.Any("orgID", autoProvisionOrgId), zap.Any("methodName", methodName), zap.Any("email", email))
						return nil, errcode.NewNotAuthenticatedErr(errors.New(EnterpriseUserAutoProvisionDisabledErrMsg))

					}
				}
			}
		}

		tilog.Info(ctx, "ParseOrgIDFromPath", zap.Any("orgID", orgID), zap.Any("found", found), zap.Any("url", url))
		u, found, err := usersvc.GetUser(ctx, email, orgID, !found)
		//tilog.Info(ctx, "GetUserClaim GetUser", zap.Any("u", u), zap.Any("found", found))
		if err != nil {
			return nil, err
		}
		sub, err := auth0.GetSubjectClaim(claims)
		if err != nil {
			return nil, errcode.NewNotAuthenticatedErr(err)
		}

		if !found {
			tilog.Info(ctx, "GetUser  not found", zap.String("", UnregisteredSsoUserErrMsg), zap.String("url", url))
			if IsSubSSO(sub) {
				tilog.Info(ctx, "go common sso register", zap.Any("orgID", orgID), zap.Any("email", email))
				return nil, errcode.NewNotAuthenticatedErr(errors.New(UnregisteredSsoUserErrMsg))
			}
			tilog.Info(ctx, "go common register", zap.Any("orgID", orgID), zap.Any("email", email))
			return nil, errcode.NewNotAuthenticatedErr(errors.New(UnregisteredUserErrMsg))
		}
		// check the impersonated user first
		if u.IsImpersonated && shouldBlockImpersonatedUser(httpMethod, url) {
			tilog.Info(ctx, "impersonate user blocked by non http request", zap.String("method", httpMethod), zap.String("url", url))
			return nil, errcode.NewForbiddenErr(errors.New(config.ErrImpersonatedUser))
		}

		if u.Tenant != nil && (u.Tenant.Status == entity.TenantStatusCleared || u.Tenant.Status == entity.TenantStatusDeleted) {
			tilog.Info(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))
		}

		switch u.Status {
		case account.UserStatus_USER_STATUS_PENDING:
			if u.InvitedAt != nil {
				return nil, errcode.NewNotAuthenticatedErr(errors.New(PendingUserErrMsg))
			}
			if u.Tenant != nil && u.Email == u.Tenant.Email && u.Tenant.Status == entity.TenantStatusPending {
				if IsSubSSO(sub) {
					// NOTE if it's sso and user is pending, we need user to change password first
					// this is a security problem: attackers may register with someone else's email
					// after the customer login with SSO and active the account, the user's account was leaked as
					// the password was set by attacker.
					return nil, errcode.NewNotAuthenticatedErr(errors.New(PendingSignupSSOUserErrMsg))
				}

				return nil, errcode.NewNotAuthenticatedErr(errors.New(PendingSignupUserErrMsg))
			}
			return nil, errcode.NewNotAuthenticatedErr(errors.New(PendingUserErrMsg))
		case account.UserStatus_USER_STATUS_INACTIVE:
			return nil, errcode.NewNotAuthenticatedErr(errors.New(InactiveUserErrMsg))
		default:
			return u, nil
		}
	}
}

func ListAccessLogs(items []auditlog.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) grpc_auth.AuthFunc {
	return func(ctx context.Context) (context.Context, error) {
		url := pkg_http.GetHttpURI(ctx)
		method := pkg_http.Method(ctx)

		if _, ok := SkipAuthMethod[method]; ok {
			return ctx, nil
		}

		// if client call accept invite interface, use token make auth instead of google auth
		if val, ok := token2.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 := pkg_http.GetBearerToken(ctx)
		if err != nil {
			return nil, errcode.NewNotAuthenticatedErr(err)
		}
		tilog.Info(ctx, "get raw token", zap.Any("tokenRaw", tokenRaw))

		orgID, orgFound := utils.ParseOrgIDFromPath(url)

		var claims jwt.MapClaims
		var validatorID = auth0.AuthValidatorIDCentral
		var loginOrgType string
		var v interface{}

		if config.Global().ServerConfig.StressTest {
			email := pkg_http.FirstHeader(ctx, "STRESS_TEST_USER")
			if email != "" {
				tilog.Info(ctx, "go server stress mode test", zap.Any("stress_user", email))

				stressTestUser, userFound, err := usersvc.GetUser(ctx, email, orgID, !orgFound)
				if err != nil {
					return nil, err
				}
				if !userFound {
					return nil, errs.NewBadRequest("not found STRESS_TEST_USER", nil)
				}
				v = stressTestUser

				newCtx := context.WithValue(ctx, auth0.AuthValidatorIDKey, validatorID)
				newCtx = context.WithValue(newCtx, auth0.AuthTypeKey, auth0.GmailAuthType)
				newCtx = context.WithValue(newCtx, auth0.AuthTokenClaimKey, v)
				newCtx = context.WithValue(newCtx, auth0.AuthUserIDKey, email)
				newCtx = context.WithValue(newCtx, entity.ClaimsOrgTypeKey, loginOrgType) //nolint
				newCtx = context.WithValue(newCtx, entity.ClaimsOrgIDKey, orgID)          //nolint
				newCtx = context.WithValue(newCtx, entity.ClaimsSubKey, "")               //nolint

				return newCtx, nil
			}
		}

		testUser, userFound, err := TryGetTestUserFromToken(ctx, tokenRaw, orgID, !orgFound)
		if err != nil {
			return nil, err
		}
		if userFound {
			v = testUser
			tilog.Info(ctx, "login using test token", zap.String("email", testUser.Email))

		} else {
			claims, err = tokenValidator.ValidateFor(validatorID, tokenRaw)
			if err != nil {
				return nil, errcode.NewNotAuthenticatedErr(err)
			}
			loginOrgType, err = orgsso2.GetOrgTypeClaim(claims)
			if err != nil {
				return nil, errcode.NewNotAuthenticatedErr(err)
			}
			if val, ok := token2.CheckEmailMap[method]; ok && val {
				email, err := auth0.GetEmailClaim(claims)
				if err != nil {
					return nil, errcode.NewNotAuthenticatedErr(err)
				}
				user := entity.User{
					Email: email,
				}
				v = &usersvc.User{
					User: user,
				}
			} else {
				v, err = GetUserClaim()(ctx, claims)
				if err != nil {
					tilog.Info(ctx, "failed auth by get user claim", zap.Error(err))
					if _, ok := err.(errcode.ErrorCode); ok {
						return nil, err
					}
					return nil, errcode.NewNotAuthenticatedErr(err)
				}

				if !config.IsLocalEnv() {
					// check org token
					err = AuthenticateOrgToken(ctx, claims, orgID, url)
					if err != nil {
						return nil, err
					}
				}

			}
			if !config.IsLocalEnv() {
				go checkDbUsers(context.Background(), claims)
			}
		}
		//tilog.Info(ctx, "MakeAuthGRPC get user claim", zap.Any("user", v))

		// grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags"
		// grpc_ctxtags.Extract(ctx).Set("auth.sub", claims)
		newCtx := context.WithValue(ctx, auth0.AuthValidatorIDKey, validatorID)
		newCtx = context.WithValue(newCtx, auth0.AuthTypeKey, auth0.GmailAuthType)
		newCtx = context.WithValue(newCtx, auth0.AuthTokenClaimKey, v)
		newCtx = context.WithValue(newCtx, auth0.AuthUserIDKey, claims["email"])
		newCtx = context.WithValue(newCtx, entity.ClaimsOrgTypeKey, loginOrgType)                //nolint
		newCtx = context.WithValue(newCtx, entity.ClaimsOrgIDKey, claims[entity.ClaimsOrgIDKey]) //nolint
		newCtx = context.WithValue(newCtx, entity.ClaimsSubKey, claims[entity.ClaimsSubKey])     //nolint

		//if sub, ok := claims["sub"]; ok {
		//	newCtx = context.WithValue(newCtx, "AUTH-SUB", sub)
		//}

		return newCtx, nil
	}
}

func checkDbUsers(ctx context.Context, claims jwt.MapClaims) {
	rand.Seed(time.Now().UnixNano())
	intn := rand.Intn(50)
	// 1  go check
	if intn == 1 {
		email, _ := auth0.GetEmailClaim(claims)
		tilog.Info(ctx, fmt.Sprintf("to check db users in auth logic, email: %s", email))
		svc, _ := dbuser.GetDBUserSvc()
		svc.CheckAndCompensateDbUsers(ctx, entity.RoleBindingTargetUser, email)
	}
}

func checkTokenUsageValid(claims jwt.MapClaims) error {
	tokenUsage, _ := orgsso2.GetUsageClaim(claims)
	if tokenUsage == entity.ClaimsUsageTidbCloud {
		return nil
	}
	// history token is blank
	if tokenUsage == "" {
		var deployTime int64 = 1763607257
		if iiat, ok := claims["iat"]; ok {
			if iatFloat, ok := iiat.(float64); ok {
				iat := int64(iatFloat)
				// just release history token issued in 15h
				if iat < deployTime && deployTime-iat <= 60*60*15 {
					return nil
				}
			}
		}

	}
	return errors.New("invalid token")
}

func TryGetTestUserFromToken(ctx context.Context, rawToken string, orgID uint64, skipCheckOrg bool) (*usersvc.User, bool, error) {
	testTokenSvc, _ := token2.GetTestTokenSvc()
	email := testTokenSvc.TryGetEmailFromTestToken(rawToken)
	if email == "" {
		return nil, false, nil
	}
	return usersvc.GetUser(ctx, email, orgID, skipCheckOrg)
}

func AuthenticateOrgToken(ctx context.Context, claims jwt.MapClaims, urlOrgID uint64, url string) error {
	bm := utils.Begin(ctx, "AuthenticateOrgToken")
	defer bm.OnExit(ctx)
	tilog.Info(ctx, "AuthenticateOrgToken start", zap.Any("claims", claims), zap.Any("urlOrgID", urlOrgID), zap.Any("url", url))

	err := checkTokenUsageValid(claims)
	if err != nil {
		tilog.Info(ctx, "AuthenticateOrgToken checkTokenUsageValid fail", zap.Any("claims", claims), zap.Any("err", err))
		return errcode.NewNotAuthenticatedErr(err)
	}

	hasOrg, err := orgsso2.CheckHasOrgClaim(claims)
	if err != nil {
		// history login token do not contain has_org org_type org_id, ignore to check
		tilog.Info(ctx, "AuthenticateOrgToken not found has_org claim, history token, ignore")
		return nil
	}
	if !hasOrg {
		// user has no org when login, it must be CreateOrg last request, so set personal org to check
		claims[entity.ClaimsOrgTypeKey] = string(entity.OrgTypePersonal)
		claims[entity.ClaimsOrgIDKey] = "0"
		tilog.Info(ctx, "AuthenticateOrgToken has_org claim is true, set default personal org value")
	}

	authParam, err := orgsso2.GenOrgTokenAuthParam(ctx, claims, urlOrgID)
	if err != nil {
		tilog.Warn(ctx, "AuthenticateOrgToken GenOrgTokenAuthParam error", zap.Any("err", err))
		return errcode.NewNotAuthenticatedErr(err)
	}
	if authParam.AccessOrgID == 0 {
		// org_id in token is 0 and url not found org_id
		tilog.Info(ctx, "AuthenticateOrgToken access org id not found, ignore to check")
		return nil
	}
	svc, _ := orgsso2.GetOrgTokenAuthSvc()
	authRet, err := svc.AuthOrgToken(ctx, authParam)
	tilog.Info(ctx, "AuthenticateOrgToken result", zap.Any("ret", authRet), zap.Any("err", err), zap.Any("param", authParam), zap.Any("url", url))
	if err != nil {
		tilog.Warn(ctx, "AuthenticateOrgToken error", zap.Any("err", err), zap.Any("url", url))
		return errcode.NewNotAuthenticatedErr(err)
	}
	if authRet.Code != orgsso2.CodeAuthPass {
		tilog.Info(ctx, "AuthenticateOrgToken not pass", zap.Any("message", authRet.Message), zap.Any("url", url))
		return errcode.NewNotAuthenticatedErr(errors.New(authRet.Message))
	}
	tilog.Info(ctx, "AuthenticateOrgToken passed", zap.Any("message", authRet.Message), zap.Any("url", url))
	return nil
}
