package orgsso

import (
	"context"
	"errors"
	"fmt"
	"regexp"
	"strconv"
	"sync"
	"time"

	"github.com/tidbcloud/account/commons/database"

	"github.com/tidbcloud/account/internel/dao"
	"gorm.io/gorm"

	"github.com/form3tech-oss/jwt-go"

	"github.com/tidbcloud/account/internel/dao/entity"

	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"

	"github.com/tidbcloud/account/commons/config"
)

const (
	connectionUsage       = "enterprise-connection"
	enterpriseSignInUsage = "enterprise-sigin"
	personalInviteUsage   = "personal-invite"
	UserHasOrg            = "1"
	UserHasNoOrg          = "0"
)

var connectionNameCompile = regexp.MustCompile(`^(OKTA|SAML|OIDC)-(\d+)$`)

type ActionCallbackSvcIface interface {
	HandleActionCallback(ctx context.Context, state, token string) (string, error)
	HandleActionVerify(ctx context.Context, token string) (*ActionOrgInfo, error)
}

var (
	actionCallbackSvcInitOnce sync.Once
	actionCallbackSvcInstance ActionCallbackSvcIface
)

func GetActionCallbackSvc() (ActionCallbackSvcIface, error) {
	actionCallbackSvcInitOnce.Do(func() {
		actionCallbackSvcInstance = NewActionCallbackSvc()

	})
	return actionCallbackSvcInstance, nil
}

type ActionCallbackSvc struct {
}

func NewActionCallbackSvc() ActionCallbackSvcIface {
	return &ActionCallbackSvc{}
}

func (s *ActionCallbackSvc) HandleActionVerify(ctx context.Context, token string) (*ActionOrgInfo, error) {
	claims, err := ParseFromActionJwt(token)
	if err != nil {
		tilog.Info(ctx, "HandleActionVerify ParseFromActionJwt error", zap.Any("err", err))
		return nil, err
	}
	tilog.Info(ctx, "HandleActionVerify ParseFromActionJwt get claims", zap.Any("claims", claims))
	if claims.Email == "" {
		tilog.Warn(ctx, "HandleActionVerify email is blank in claims")
		return nil, errors.New("email is blank")
	}

	if claims.IgnoreCheck {
		tilog.Info(ctx, "HandleActionVerify ignore to check auth method, just get action org info")
		actionOrgInfo, err := s.GenActionOrgInfo(ctx, claims.Email, "", entity.ClaimsUsageOtherSystem)
		tilog.Info(ctx, "HandleActionVerify GenActionOrgInfo", zap.Any("actionOrgInfo", actionOrgInfo), zap.Any("err", err))
		return actionOrgInfo, err
	}
	if claims.CustomPath == "" {
		tilog.Info(ctx, "HandleActionVerify customPath is blank")
		actionOrgInfo, err := s.GenActionOrgInfo(ctx, claims.Email, "", entity.ClaimsUsageTidbCloud)
		tilog.Info(ctx, "HandleActionVerify GenActionOrgInfo", zap.Any("actionOrgInfo", actionOrgInfo), zap.Any("err", err))
		return actionOrgInfo, err
	}

	loginMethodName, err := GetMethodNameFromSubject(claims.Subject)
	if err != nil {
		tilog.Warn(ctx, "HandleActionVerify GetMethodNameFromSubject error", zap.Any("err", err))
		return nil, err
	}
	tilog.Info(ctx, "HandleActionVerify get login method name", zap.Any("loginMethodName", loginMethodName))
	var companyName string
	usage := GetUsage(claims.CustomPath)
	tilog.Info(ctx, "HandleActionVerify get usage", zap.Any("usage", usage))
	if usage == connectionUsage {
		cn, err := s.checkUserConnectionLoginValidAndGetCompanyName(ctx, claims, loginMethodName)
		if err != nil {
			tilog.Warn(ctx, "HandleActionVerify checkUserConnectionLoginValid error", zap.Any("err", err))
			return nil, err
		}
		companyName = cn
	} else {
		companyName = GetCompanyName(claims.CustomPath)
		tilog.Info(ctx, "HandleActionVerify get company name", zap.Any("companyName", companyName))
		if companyName == "" {
			// user login from common login page
			err = s.checkUserCommonLoginValid(ctx, claims, usage, loginMethodName)
			if err != nil {
				tilog.Warn(ctx, "HandleActionVerify checkUserCommonLoginValid error", zap.Any("err", err))
				return nil, err
			}
		} else {
			// user login from enterprise sso page
			err = s.checkUserEnterpriseLoginValid(ctx, claims, usage, companyName, loginMethodName)
			if err != nil {
				tilog.Warn(ctx, "HandleActionVerify checkUserEnterpriseLoginValid error", zap.Any("err", err))
				return nil, err
			}
		}
	}

	actionOrgInfo, err := s.GenActionOrgInfo(ctx, claims.Email, companyName, entity.ClaimsUsageTidbCloud)
	tilog.Info(ctx, "HandleActionVerify GenActionOrgInfo", zap.Any("actionOrgInfo", actionOrgInfo), zap.Any("err", err))
	if err != nil {
		tilog.Warn(ctx, "HandleActionVerify GenActionOrgInfo error", zap.Any("err", err))
		return nil, err
	}

	return actionOrgInfo, nil
}

func (s *ActionCallbackSvc) checkUserConnectionLoginValidAndGetCompanyName(ctx context.Context, claims *FromActionClaims, loginMethodName string) (string, error) {
	matched := connectionNameCompile.MatchString(loginMethodName)
	if !matched {
		return "", errors.New("invalid_auth_method")
	}
	submatch := connectionNameCompile.FindStringSubmatch(loginMethodName)
	orgID, _ := strconv.ParseUint(submatch[2], 10, 64)

	org, err := dao.GetDaoHub().Org.GetOrgByOrgID(ctx, database.DBConn.DB, orgID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return "", errors.New("invalid_org_id")
		}
		return "", err
	}
	if org.OrgType != entity.OrgTypeEnterprise {
		return "", errors.New("org_not_open_sso")
	}

	authMethod, err := dao.GetDaoHub().OrgAuthMethod.GetOrgAuthMethodByName(ctx, database.DBConn.DB, orgID, loginMethodName)
	if err != nil {
		return "", err
	}
	if authMethod == nil || authMethod.Status != entity.Enable {
		return "", errors.New("invalid_auth_method")
	}
	if !CheckEmailInAllowDomain(claims.Email, authMethod.AllowEmailDomain) {
		return "", errors.New("invalid_auth_domain")
	}

	var userExist = false
	var userInOrg = false
	user, err := dao.GetDaoHub().User.GetUserByEmail(ctx, database.DBConn.DB, claims.Email)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			user = nil
		} else {
			return "", err
		}
	}
	if user != nil && user.Status == entity.UserStatusActive {
		userExist = true
	}
	if userExist {
		bindings, err := dao.GetDaoHub().Role.ListRolesByTargetAndScope(ctx, database.DBConn.DB, entity.RoleBindingTargetUser, user.ID.Uint64(), entity.RoleBindingScopeOrg, org.ID.Uint64(), []string{entity.RoleBindingStatusActive})
		if err != nil {
			return "", err
		}
		if len(bindings) > 0 {
			userInOrg = true
		}
	}
	if (!userExist || !userInOrg) && !authMethod.AutoProvision {
		return "", errors.New("invalid_enterprise_signup")
	}

	return org.CompanyName, nil
}

func (s *ActionCallbackSvc) checkUserEnterpriseLoginValid(ctx context.Context, claims *FromActionClaims, usage, companyName, loginMethodName string) error {
	hub := dao.GetDaoHub()
	db := database.DBConn.DB
	org, err := hub.Org.GetOrgByCompanyName(ctx, db, companyName)
	if err != nil {
		return err
	}
	if org == nil {
		return errors.New("invalid_company_name")
	}

	authMethods, err := hub.OrgAuthMethod.ListOrgAuthMethodsByOrgID(ctx, db, org.ID.Uint64())
	if err != nil {
		return err
	}
	var methodMatched = false
	var domainMatched = false
	var matchedMethod entity.OrgAuthMethod
	for _, method := range authMethods {
		if method.Status != entity.Enable {
			continue
		}
		if method.Name == loginMethodName {
			matchedMethod = method
			methodMatched = true
			domainMatched = CheckEmailInAllowDomain(claims.Email, method.AllowEmailDomain)
			break
		}
	}
	if !methodMatched {
		tilog.Info(ctx, "enterprise login with not enabled auth method", zap.Any("email", claims.Email), zap.Any("loginMethodName", loginMethodName))
		return errors.New("invalid_auth_method")
	}
	if !domainMatched {
		tilog.Info(ctx, "enterprise login with not allowed email domain", zap.Any("email", claims.Email), zap.Any("loginMethodName", loginMethodName))
		return errors.New("invalid_auth_domain")
	}

	var userExist = false
	var userInOrg = false
	user, err := hub.User.GetUserByEmail(ctx, db, claims.Email)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			user = nil
		} else {
			return err
		}
	}
	if user != nil && user.Status == entity.UserStatusActive {
		userExist = true
	}
	if userExist {
		bindings, err := hub.Role.ListRolesByTargetAndScope(ctx, db, entity.RoleBindingTargetUser, user.ID.Uint64(), entity.RoleBindingScopeOrg, org.ID.Uint64(), []string{entity.RoleBindingStatusActive})
		if err != nil {
			return err
		}
		if len(bindings) > 0 {
			userInOrg = true
		}
	}
	if usage == enterpriseSignInUsage && (!userExist || !userInOrg) && !matchedMethod.AutoProvision {
		tilog.Info(ctx, "enterprise login but org not opened auto provision", zap.Any("email", claims.Email), zap.Any("loginMethodName", loginMethodName))
		return errors.New("invalid_enterprise_signup")
	}
	return nil
}

func (s *ActionCallbackSvc) checkUserCommonLoginValid(ctx context.Context, claims *FromActionClaims, usage, loginMethodName string) error {
	// personal org invite just let go
	if usage == personalInviteUsage {
		return nil
	}
	orgs, err := s.GetUserOrgs(ctx, claims.Email)
	if err != nil {
		return err
	}
	// user login from common login page but not found personal org
	var existEnterpriseOrg = false
	var existPersonalOrg = false
	for _, org := range orgs {
		if org.OrgType == entity.OrgTypeEnterprise {
			existEnterpriseOrg = true
		} else {
			existPersonalOrg = true
		}
	}
	if existEnterpriseOrg && !existPersonalOrg {
		// differentiate invite and signin , invite let go and just intercept signin request
		if loginMethodName == string(entity.AuthMethodNameEmail) {
			tilog.Info(ctx, "user is only in a enterprise org but login with common login url by email password", zap.Any("email", claims.Email), zap.Any("loginMethodName", loginMethodName))
			return errors.New("invalid_enterprise_signin_url")
		} else if loginMethodName == string(entity.AuthMethodNameGoogle) ||
			loginMethodName == string(entity.AuthMethodNameGitHub) ||
			loginMethodName == string(entity.AuthMethodNameMicrosoft) {
			tilog.Info(ctx, "user is only in a enterprise org but login with common login url by email social", zap.Any("email", claims.Email), zap.Any("loginMethodName", loginMethodName))
			return errors.New("invalid_enterprise_signin_url")
		} else {
			return errors.New("invalid_method_name")
		}
	}

	return nil
}

func (s *ActionCallbackSvc) HandleActionCallback(ctx context.Context, state, token string) (string, error) {
	claims, err := ParseFromActionJwt(token)
	if err != nil {
		tilog.Info(ctx, "HandleActionCallback ParseFromActionJwt error", zap.Any("err", err))
		return "", err
	}
	tilog.Info(ctx, "HandleActionCallback ParseFromActionJwt get claims", zap.Any("claims", claims))

	actionOrgInfo, err := s.GenActionOrgInfo(ctx, claims.Email, claims.CustomPath, entity.ClaimsUsageTidbCloud)
	tilog.Info(ctx, "HandleActionCallback GenActionOrgInfo",
		zap.Any("email", claims.Email), zap.Any("customPath", claims.CustomPath), zap.Any("actionOrgInfo", actionOrgInfo), zap.Any("err", err))
	if err != nil {
		tilog.Warn(ctx, "HandleActionCallback GenActionOrgInfo error", zap.Any("err", err))
		return "", err
	}

	toActionJwt, err := CreateToActionJwt(state, claims.Subject, actionOrgInfo)
	if err != nil {
		tilog.Warn(ctx, "HandleActionCallback CreateToActionJwt error", zap.Any("err", err))
		return "", err
	}

	return toActionJwt, nil

}

func (s *ActionCallbackSvc) GenActionOrgInfo(ctx context.Context, email, companyName, tokenUsage string) (*ActionOrgInfo, error) {
	if companyName != "" {
		org, err := dao.GetDaoHub().Org.GetOrgByCompanyName(ctx, database.DBConn.DB, companyName)
		if err != nil {
			return nil, err
		}
		if org == nil {
			return nil, errors.New("invalid_company_name")
		}
		return &ActionOrgInfo{
			HasOrg:      UserHasOrg,
			OrgType:     string(org.OrgType),
			OrgID:       fmt.Sprintf("%d", org.ID.Uint64()),
			CompanyName: companyName,
			Usage:       tokenUsage,
		}, nil
	}
	orgs, err := s.GetUserPersonalOrgs(ctx, email)
	if err != nil {
		return nil, err
	}
	if len(orgs) == 0 {
		return &ActionOrgInfo{
			HasOrg:      UserHasNoOrg,
			OrgType:     "",
			OrgID:       "0",
			CompanyName: "",
			Usage:       tokenUsage,
		}, nil
	}
	orgId := selectPersonalOrgID(orgs)
	return &ActionOrgInfo{
		HasOrg:      UserHasOrg,
		OrgType:     string(entity.OrgTypePersonal),
		OrgID:       fmt.Sprintf("%d", orgId),
		CompanyName: "",
		Usage:       tokenUsage,
	}, nil
}

func selectPersonalOrgID(orgs []entity.Tenant) uint64 {
	// find min org id
	var orgID uint64 = 0
	for _, org := range orgs {
		tmpOrgID := org.ID.Uint64()
		if orgID == 0 {
			orgID = tmpOrgID
		} else if tmpOrgID < orgID {
			orgID = tmpOrgID
		}
	}
	return orgID
}

func CreateToActionJwt(state, sub string, actionOrgInfo *ActionOrgInfo) (string, error) {
	ssoConfig := config.Global().SSOConfig
	nowTime := time.Now()
	expireTime := nowTime.Add(300 * time.Second)
	claims := ToActionClaims{
		HasOrg:      actionOrgInfo.HasOrg,
		OrgType:     actionOrgInfo.OrgType,
		OrgID:       actionOrgInfo.OrgID,
		CompanyName: actionOrgInfo.CompanyName,
		State:       state,
		StandardClaims: jwt.StandardClaims{
			IssuedAt:  nowTime.Unix(),
			ExpiresAt: expireTime.Unix(),
			Issuer:    ssoConfig.ClaimIss,
			Subject:   sub,
		},
	}

	token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ssoConfig.ActionKey))
	return token, err
}

func ParseFromActionJwt(token string) (claim *FromActionClaims, err error) {
	key := config.Global().SSOConfig.ActionKey
	tokenClaims, _ := jwt.ParseWithClaims(token, &FromActionClaims{}, func(token *jwt.Token) (interface{}, error) {
		return []byte(key), nil
	})
	if tokenClaims == nil {
		return nil, errors.New("action_claims_is_nil")
	}
	if !tokenClaims.Valid {
		tilog.Info(context.Background(), "ParseFromActionJwt parse claims result: invalid", zap.Any("token", token))
	} else {
		tilog.Info(context.Background(), "ParseFromActionJwt parse claims result: valid", zap.Any("token", token))
	}
	claims, ok := tokenClaims.Claims.(*FromActionClaims)
	if !ok {
		return nil, errors.New("invalid_action_claims_type")
	}
	return claims, nil
}

func (s *ActionCallbackSvc) GetUserPersonalOrgs(ctx context.Context, email string) ([]entity.Tenant, error) {
	orgs, err := s.GetUserOrgs(ctx, email)
	if err != nil {
		tilog.Warn(ctx, "GetUserPersonalOrgs GetUserOrgs error", zap.Any("err", err))
		return nil, err
	}
	var personalOrgs []entity.Tenant
	for _, org := range orgs {
		// just enterprise personal (not set sso, default is personal)
		if org.OrgType != entity.OrgTypeEnterprise {
			personalOrgs = append(personalOrgs, org)
		}
	}
	return personalOrgs, nil
}

func (s *ActionCallbackSvc) GetUserOrgs(ctx context.Context, email string) ([]entity.Tenant, error) {
	hub := dao.GetDaoHub()
	db := database.DBConn.DB
	user, err := hub.User.GetUserByEmail(ctx, db, email)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			user = nil
		} else {
			tilog.Warn(ctx, "GetUserOrgs GetUserByEmail error", zap.Any("err", err))
			return nil, err
		}
	}
	if user == nil {
		tilog.Info(ctx, "GetUserOrgs GetUserByEmail user is nil")
		return []entity.Tenant{}, nil
	}
	orgs, err := hub.Cross.ListOrgsByUserID(ctx, db, user.ID.Uint64(), []string{string(entity.TenantStatusActive)})
	if err != nil {
		tilog.Warn(ctx, "GetUserOrgs ListOrgsByUserID error", zap.Any("err", err))
		return nil, err
	}
	return orgs, nil
}

type FromActionClaims struct {
	jwt.StandardClaims
	Email       string `json:"email"`
	CustomPath  string `json:"custom_path"`
	IgnoreCheck bool   `json:"ignore_check"`
}

type ToActionClaims struct {
	jwt.StandardClaims
	State       string `json:"state"`
	HasOrg      string `json:"has_org"`
	OrgType     string `json:"org_type"`
	OrgID       string `json:"org_id"`
	CompanyName string `json:"company_name"`
}

type ActionOrgInfo struct {
	HasOrg      string `json:"has_org"`
	OrgType     string `json:"org_type"`
	OrgID       string `json:"org_id"`
	CompanyName string `json:"company_name"`
	Usage       string `json:"usage"`
}
