package tenant

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/gogo/protobuf/types"
	"github.com/pingcap/dbaas/central/config"
	"github.com/pingcap/dbaas/central/db"
	"github.com/pingcap/dbaas/central/db/dbutil"
	"github.com/pingcap/dbaas/central/db/entity"
	"github.com/pingcap/dbaas/central/model/session"
	modelUtil "github.com/pingcap/dbaas/central/model/util"
	"github.com/pingcap/errcode"
	"github.com/pingcap/errors"
	"github.com/tidbcloud/idl/pbgen/proto/central"
	"github.com/tidbcloud/pkgv2/log"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

const (
	ErrDeleteOwnerUser = "Please change the role to member before moving out of the project."

	ErrFailedEmailListTemplate = `
	<p>
	  The following email invitation failed: %s. Please try again or request 
	  <a
		href="https://support.pingcap.com/hc/en-us/"
		target="_blank"
		rel="noopener noreferrer"
	  >support</a>.
	</p>
	`

	ErrInOtherOrgEmailListTemplate = "<p>The email %s is already in another organization. Each user can only be in one organization.</p>"
)

func (c Context) ListProjects(ctx context.Context, orgID entity.ID) ([]*central.Project, error) {
	err := c.CheckOrgID(orgID)
	if err != nil {
		return nil, err
	}

	var projects []entity.Project
	if c.tenantAndUser.IsImpersonated {
		err = db.Transaction(func(tx *gorm.DB) error {
			projects, err = dbutil.GetActiveProjects(ctx, tx, orgID)
			return err
		})
		if err != nil {
			c.Log().Error("failed to query all projects of tenant", zap.Error(err))
			return nil, errcode.NewInternalErr(errors.Errorf("failed to query all projects of tenant %d", orgID))
		}
	} else {
		projects = c.tenantAndUser.Projects
	}

	var userProjects []*central.Project
	for _, project := range projects {
		userProject := central.Project{
			Id:   project.ID.Uint64(),
			Name: project.Name,
		}

		createdAt, err := types.TimestampProto(project.CreatedAt)
		if err != nil {
			c.log.Error("failed to parse project's CreatedAt field", zap.Uint64("projectID", project.ID.Uint64()), zap.Error(err))
			return nil, errcode.NewInternalErr(errors.Errorf("failed to parse CreatedAt field of project %d", project.ID.Uint64()))
		}
		userProject.CreatedAt = createdAt

		var activeClustersCount int64
		var usersCount int64
		err = db.Transaction(func(tx *gorm.DB) error {
			err = entity.NewProjectQuerySet(tx.Preload("Users")).IDEq(project.ID).One(&project)
			if err != nil {
				return err
			}
			activeClustersCount, err = entity.NewClusterQuerySet(tx).StatusNotIn(
				entity.DeletedOrClearedClusterStatus...).ProjectIDEq(project.ID).Count()
			return err
		})
		if err != nil {
			c.Log().Error("failed to query active clusters count and users of project", zap.Uint64("projectID", project.ID.Uint64()), zap.Error(err))
			return nil, errors.New("failed to query active clusters count and users of project")
		}

		userProject.ActiveClustersCount = uint32(activeClustersCount)
		for _, user := range project.Users {
			// Ignore system user
			if user.FirstName == "system" && user.LastName == "user" {
				continue
			}
			if user.Status == entity.UserStatusActive || user.Status == entity.UserStatusPending {
				usersCount++
			}
		}
		userProject.UsersCount = uint32(usersCount)
		userProjects = append(userProjects, &userProject)
	}
	return userProjects, nil
}

type CreateProjectReq struct {
	OrgID                entity.ID
	ProjectName          string
	EnableXAccountDeploy bool
}

const (
	ProjectNameUniqueErrMsg = "The project name already exists."
)

func (c Context) CreateProject(req CreateProjectReq) (*central.ProjectId, error) {
	t, err := c.GetTenant()
	if err != nil {
		return nil, errcode.NewInternalErr(errors.New("failed to get tenant"))
	}
	plan := t.Plan
	if plan == entity.TenantPlanNewUser || plan == entity.TenantPlanFreeTrial {
		return nil, errcode.NewInvalidInputErr(errors.New("free trial users are only allowed to own one project"))
	}

	project := &entity.Project{
		TenantID:       req.OrgID,
		Name:           req.ProjectName,
		Status:         entity.ProjectStatusActive,
		IsCrossAccount: req.EnableXAccountDeploy,
	}

	err = db.Transaction(func(tx *gorm.DB) error {
		count, err := entity.NewProjectQuerySet(tx).TenantIDEq(req.OrgID).NameEq(req.ProjectName).StatusEq(entity.ProjectStatusActive).Count()
		if err != nil {
			return errcode.NewInternalErr(errors.New("failed to query project count by name"))
		}
		if count > 0 {
			return errcode.NewInvalidInputErr(errors.New(ProjectNameUniqueErrMsg))
		}

		var users []entity.User
		err = entity.NewUserQuerySet(tx).TenantIDEq(req.OrgID).RoleEq(entity.UserRoleOwner).All(&users)
		if err != nil {
			return errcode.NewInternalErr(errors.New("failed to list org users"))
		}
		project.Users = users

		err = project.Create(tx)
		if err != nil {
			return errcode.NewInternalErr(errors.New("failed to create project"))
		}
		return nil
	})
	if err != nil {
		c.log.Error("failed to create project", zap.Error(err))
		return nil, err
	}
	return &central.ProjectId{
		Id: project.ID.Uint64(),
	}, nil
}

type UpdateProjectReq struct {
	OrgID       entity.ID
	ProjectID   entity.ID
	ProjectName string
}

func (c Context) UpdateProject(req UpdateProjectReq) error {
	err := db.Transaction(func(tx *gorm.DB) error {
		var project entity.Project
		err := entity.NewProjectQuerySet(tx).TenantIDEq(req.OrgID).IDEq(req.ProjectID).StatusEq(entity.ProjectStatusActive).One(&project)
		if err != nil {
			return errcode.NewInternalErr(errors.New("failed to query project by id"))
		}
		if project.Name == req.ProjectName {
			return nil
		}
		project.Name = req.ProjectName

		count, err := entity.NewProjectQuerySet(tx).TenantIDEq(req.OrgID).NameEq(req.ProjectName).StatusEq(entity.ProjectStatusActive).Count()
		if err != nil {
			return errcode.NewInternalErr(errors.New("failed to query project count by name"))
		}
		if count > 0 {
			return errcode.NewInvalidInputErr(errors.New(ProjectNameUniqueErrMsg))
		}

		err = project.Update(tx, entity.ProjectDBSchema.Name)
		if err != nil {
			return errcode.NewInternalErr(errors.New("failed to update project"))
		}
		return nil
	})
	if err != nil {
		c.log.Error("failed to update project", zap.Error(err))
		return err
	}
	return nil
}

type ListProjectUsersReq struct {
	ProjectID entity.ID
	Email     *string
}

func (c Context) ListProjectUsers(req ListProjectUsersReq) ([]*User, error) {
	projectID := req.ProjectID
	filter := dbutil.ProjectUserFilter{
		ProjectID: projectID,
		Statuses:  []entity.UserStatus{entity.UserStatusActive, entity.UserStatusPending},
		Email:     req.Email,
	}

	var users []entity.User
	err := db.Transaction(func(tx *gorm.DB) error {
		var err error
		users, err = dbutil.ListProjectUsers(tx, filter)
		return err
	})
	if err != nil {
		c.Log().Error("failed to query users of project", zap.Uint64("projectID", projectID.Uint64()), zap.Error(err))
		return nil, errcode.NewInternalErr(errors.Errorf("failed to query users of project %d", projectID))
	}

	return convertUsers(users), nil
}

type InviteUserReq struct {
	OrgID     entity.ID
	ProjectID entity.ID
	Role      entity.UserRole
	EmailList []string
}

func (c Context) InviteUser(ctx context.Context, req InviteUserReq) error {
	org, err := c.GetTenant()
	if err != nil {
		c.Log().Error("failed to get organization", zap.String("feature", "invitation"), zap.Strings("email", req.EmailList), zap.Error(err))
		return errcode.NewInternalErr(errors.New("failed to get organization"))
	}

	now := time.Now()
	failedEmailList := []string{}
	inOtherOrgEmailList := []string{}

	for _, email := range req.EmailList {
		needSendInvitation := true
		status := entity.UserStatusPending
		var token string

		err := db.Transaction(func(tx *gorm.DB) error {
			var (
				project entity.Project
				user    entity.User
				err     error
			)
			err = entity.NewProjectQuerySet(tx).TenantIDEq(req.OrgID).IDEq(req.ProjectID).StatusEq(entity.ProjectStatusActive).One(&project)
			if err != nil {
				c.Log().Error("failed to query project by id", zap.String("feature", "invitation"), zap.String("email", email), zap.Uint64("projectID", req.ProjectID.Uint64()), zap.Error(err))
				return errors.New("failed to query project by id")
			}
			user, err = dbutil.GetUserByEmail(tx, email)
			if err != nil && errors.Cause(err) != gorm.ErrRecordNotFound {
				c.Log().Error("failed to get user from db by email", zap.String("feature", "invitation"), zap.String("email", email), zap.Error(err))
				return errors.New("failed to get user from db by email")
			}
			newUser := true
			if err == nil {
				newUser = false
			}

			userProjects := []entity.Project{}
			inviteAt := &now
			role := req.Role
			firstName := ""
			lastName := ""

			if newUser {
				userProjects = append(userProjects, project)
			} else {
				userInOtherOrg := false
				if user.TenantID != req.OrgID {
					userInOtherOrg = true
				}
				if user.Status == entity.UserStatusActive {
					status = entity.UserStatusActive
				}

				if userInOtherOrg {
					if status == entity.UserStatusActive {
						c.Log().Warn("the email has been exist in other organization", zap.String("feature", "invitation"), zap.String("email", email))
						inOtherOrgEmailList = append(inOtherOrgEmailList, email)
						return nil
					}

					// The user is a pending signup user
					if status == entity.UserStatusPending && user.Email == user.Tenant.Email {
						c.Log().Warn("the email has been exist in other organization", zap.String("feature", "invitation"), zap.String("email", email))
						inOtherOrgEmailList = append(inOtherOrgEmailList, email)
						needSendInvitation = false
						return nil
					}
					userProjects = append(userProjects, project)
				} else {
					if status == entity.UserStatusActive {
						userProjects = user.Projects
						inProject := dbutil.UserInProject(userProjects, req.ProjectID)
						if inProject {
							return nil
						}

						userProjects = append(userProjects, project)
						role = user.Role
						inviteAt = user.InvitedAt
						firstName = user.FirstName
						lastName = user.LastName
					} else {
						userProjects = append(userProjects, project)
					}
				}
			}

			if role == entity.UserRoleOwner {
				projects, err := dbutil.GetActiveProjects(ctx, tx, req.OrgID)
				if err != nil {
					c.Log().Error("failed to query active projects of tenant", zap.Error(err))
					return errors.New("failed to query active projects of tenant")
				}
				userProjects = projects
			}

			err = dbutil.UpsertUser(tx, dbutil.CreateUserRequired{
				Email:     email,
				FirstName: firstName,
				LastName:  lastName,
				InviteAt:  inviteAt,
				Status:    status,
				TenantID:  req.OrgID,
				Role:      role,
				Projects:  userProjects,
			})
			if err != nil {
				c.Log().Error("failed to upsert user in db", zap.String("feature", "invitation"), zap.String("email", email), zap.Error(err))
				return errors.New("failed to upsert user in db")
			}

			if status == entity.UserStatusPending && needSendInvitation {
				token, err = c.session.InsertUserInviteToken(tx, c.tenantAndUser.TenantID, session.InvitationVerificationMethod, email, 30*24*time.Hour, true)
				if err != nil {
					c.Log().Error("failed to create user invite token", zap.String("feature", "invitation"), zap.String("email", email), zap.Error(err))
					return errors.New("failed to create user invite token")
				}
				return dbutil.AddAccessLogNow(tx, c.tenantAndUser.User.TenantID, c.tenantAndUser.Email, fmt.Sprintf("invite user: %s", email))
			}
			return nil
		})
		if err != nil {
			failedEmailList = append(failedEmailList, email)
			continue
		}

		if status == entity.UserStatusPending && needSendInvitation {
			url := fmt.Sprintf("https://%s/invitation-verification?token=%s", modelUtil.GetCentralBackendDns(), token)
			inviter := c.tenantAndUser.Email
			subject := fmt.Sprintf("Invitation to TiDB Cloud: %s", org.Name)
			vars := map[string]string{
				"organizationName": org.Name,
				"inviterName":      inviter,
				"url":              url,
			}
			if err = c.mail.SendMailWithTemplate(ctx, []string{email}, subject, config.TNInviteUser, vars, nil); err != nil {
				c.Log().Error("failed to send invitation email", zap.String("feature", "invitation"), zap.String("email", email), zap.Error(err))
				failedEmailList = append(failedEmailList, email)
				continue
			}
			c.log.Info("send invitation email", zap.String("feature", "invitation"), zap.String("email", email), zap.Time("time", time.Now().UTC()))
		}
	}

	var ErrMessage string
	if len(failedEmailList) != 0 {
		ErrMessage = fmt.Sprintf(ErrFailedEmailListTemplate, strings.Join(failedEmailList, ", "))
	}
	if len(inOtherOrgEmailList) != 0 {
		ErrMessage += fmt.Sprintf(ErrInOtherOrgEmailListTemplate, strings.Join(inOtherOrgEmailList, ", "))
	}
	if ErrMessage != "" {
		return errcode.NewInternalErr(errors.New(ErrMessage))
	}
	return nil
}

func (c Context) DeleteProjectUser(projectID, userID entity.ID) error {
	var project entity.Project
	err := db.Transaction(func(tx *gorm.DB) error {
		err := entity.NewProjectQuerySet(tx.Preload("Users")).IDEq(projectID).StatusEq(entity.ProjectStatusActive).One(&project)
		if err != nil {
			c.Log().Error("failed to query users of project", zap.Uint64("projectID", projectID.Uint64()), zap.Error(err))
			return errcode.NewInternalErr(errors.Errorf("failed to query users of project %d", projectID))
		}

		projectUsers := project.Users
		var found bool
		for i, user := range projectUsers {
			if user.ID == userID {
				if user.Role == entity.UserRoleOwner {
					return errcode.NewInvalidInputErr(errors.New(ErrDeleteOwnerUser))
				}

				found = true
				projectUsers = append(projectUsers[:i], projectUsers[i+1:]...)
			}
		}
		if found {
			project.Users = projectUsers
			err = tx.Model(&project).Association("Users").Replace(project.Users)
			if err != nil {
				c.Log().Error("failed to update project users", zap.Uint64("projectID", projectID.Uint64()), zap.Error(err))
				return errcode.NewInternalErr(errors.Errorf("failed to update users of project %d", projectID))
			}
			return nil
		}
		return errcode.NewNotFoundErr(errors.Errorf("user %d does not belong to project %d", userID, projectID))
	})

	return err
}

func DeleteProject(ctx context.Context, orgID, projectID entity.ID) error {
	var (
		clusters []entity.Cluster
		err      error
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		clusters, err = dbutil.GetProjectActiveClusters(tx, projectID)
		if err != nil {
			log.Error(ctx, "query active clusters under the project", zap.Uint64("projectID", projectID.Uint64()), zap.Error(err))
			return errcode.NewInternalErr(errors.New("failed to query active clusters under the project"))
		}
		if len(clusters) > 0 {
			return errcode.NewInvalidInputErr(errors.New(config.ErrDeleteProject))
		}

		clusters, err = dbutil.ListDeletedClustersByProject(tx, orgID, projectID)
		if err != nil {
			log.Error(ctx, "query deleted clusters under the project", zap.Uint64("projectID", projectID.Uint64()), zap.Error(err))
			return errcode.NewInternalErr(errors.New("failed to query deleted clusters under the project"))
		}

		for _, cluster := range clusters {
			err = dbutil.DeleteDeletedClusterBackups(tx, orgID, projectID, cluster.ID, entity.BackupType_All)
			if err != nil {
				log.Error(ctx, "delete backup of deleted cluster", zap.Uint64("projectID", projectID.Uint64()), zap.Uint64("clusterID", cluster.ID.Uint64()), zap.Error(err))
				return errcode.NewInternalErr(errors.New("failed to delete backup of deleted cluster"))
			}
		}

		err := dbutil.DeleteProject(ctx, tx, orgID, projectID)
		if err != nil {
			log.Error(ctx, "failed to delete project", zap.Uint64("projectID", projectID.Uint64()), zap.Error(err))
			return errcode.NewInternalErr(errors.New("failed to delete project"))
		}
		return nil
	})
	if err != nil {
		return err
	}
	return nil
}

func (c Context) GetProjectTrafficFilter(projectID entity.ID) (*central.TrafficFilterList, error) {
	var cidr entity.CidrTemplate
	err := db.Transaction(func(tx *gorm.DB) error {
		return entity.NewCidrTemplateQuerySet(tx).ProjectIDEq(projectID).One(&cidr)
	})
	if err != nil {
		if err != gorm.ErrRecordNotFound {
			c.Log().Error("failed to get project traffic filter", zap.Error(err))
			return nil, err
		}
		return &central.TrafficFilterList{}, nil
	}

	return modelUtil.UnmarshalTrafficFilter(&cidr.Cidr), nil
}

func (c Context) UpdateProjectTrafficFilter(projectID entity.ID, trafficFilterList *central.TrafficFilterList) error {
	if err := modelUtil.ValidateCidr(trafficFilterList); err != nil {
		return errcode.NewInvalidInputErr(err)
	}
	tfList := modelUtil.MarshalTrafficFilter(trafficFilterList)
	cidr := entity.CidrTemplate{
		ProjectID: projectID,
		Cidr:      *tfList,
	}

	err := db.Transaction(func(tx *gorm.DB) error {
		return dbutil.UpsertCidrTemplate(tx, &cidr)
	})
	if err != nil {
		c.Log().Error("failed to upsert project cidr template", zap.Error(err))
		return errcode.NewInternalErr(errors.New("failed to upsert project cidr template"))
	}
	return nil
}
