// Package backup encapsulates all backup functionality
package backup

import (
	"context"
	"fmt"
	"strconv"
	"time"

	"github.com/pingcap/dbaas/pkg/mail"
	"github.com/pingcap/dbaas/pkg/secret/encrypt"
	"github.com/pingcap/errcode"
	"github.com/pingcap/errors"
	"github.com/pingcap/log"
	"github.com/pingcap/tidb-operator/pkg/apis/pingcap/v1alpha1"
	"github.com/tidbcloud/central-sdk/config"
	"github.com/tidbcloud/central-sdk/db"
	"github.com/tidbcloud/central-sdk/db/dbutil"
	"github.com/tidbcloud/central-sdk/db/entity"
	"github.com/tidbcloud/central-sdk/db/tenant"
	api "github.com/tidbcloud/central-sdk/infra-api"
	awsprovider "github.com/tidbcloud/central-sdk/infra/util/aws"
	tenantmodel "github.com/tidbcloud/central-sdk/model/tenant"
	"github.com/tidbcloud/central-sdk/model/util"
	central "github.com/tidbcloud/central-sdk/proto/output"
	"github.com/tidbcloud/central-sdk/runtime/deployer/gardener"
	runtimemanager "github.com/tidbcloud/central-sdk/runtime/manager"
	runtimeutil "github.com/tidbcloud/central-sdk/runtime/util"
	"github.com/tidbcloud/config-service/sdk"
	cloudv1 "github.com/tidbcloud/infra-api/api/v1"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

//revive:enable:exported

// Model is created with NewModel
type Model struct {
	log          *zap.Logger
	runtimes     runtimemanager.Manager
	infraAPI     *api.API
	mail         mail.Interface
	encryptor    encrypt.Encryptor
	shootCluster *gardener.ShootClusters
	tenantModel  tenantmodel.Model
}

// NewModel returns a Model
func NewModel(log *zap.Logger,
	runtimes runtimemanager.Manager,
	infraAPI *api.API,
	mail mail.Interface,
	encryptor encrypt.Encryptor,
	shootCluster *gardener.ShootClusters,
	tenantModel tenantmodel.Model) Model {
	return Model{
		log:          log,
		mail:         mail,
		runtimes:     runtimes,
		infraAPI:     infraAPI,
		encryptor:    encryptor,
		shootCluster: shootCluster,
		tenantModel:  tenantModel,
	}
}

// UserContext attaches a User to a Context.
// This helps audit the User
type UserContext struct {
	Model
	tenantAndUser *tenant.User
	log           *zap.Logger

	ConfigService     sdk.Interface
	getAwsBaseRoleArn func() (string, error)
}

// BgContext is meant for the system worker/controller
type BgContext UserContext

// User returns a UserContext
func (m Model) User(user *tenant.User) UserContext {
	configService := sdk.New(config.Context.ConfigServiceURL())

	return UserContext{
		Model:             m,
		tenantAndUser:     user,
		log:               m.log.With(zap.Uint64("tenantID", user.TenantID.Uint64()), zap.Uint64("userID", uint64(user.ID)), zap.String("module", "backup")),
		ConfigService:     configService,
		getAwsBaseRoleArn: awsprovider.GetBaseRoleArn,
	}
}

func (m Model) TenantLog(tenantID entity.ID) *zap.Logger {
	return m.log.With(
		zap.Uint64("tenantID", uint64(tenantID)),
		zap.String("model", "backup"),
	)
}

// BgContext returns a BgContext
func (m Model) BgContext(tenantID entity.ID) (BgContext, error) {
	var (
		systemUser entity.User
		err        error
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		systemUser, err = dbutil.SystemUser(tx, tenantID)
		return err
	})
	if err != nil {
		return BgContext{}, err
	}

	configService := sdk.New(config.Context.ConfigServiceURL())

	return BgContext{m,
		&tenant.User{User: systemUser, TenantID: tenantID},
		m.log.With(zap.Uint64("tenantID", tenantID.Uint64()), zap.Uint64("userID", uint64(systemUser.ID)), zap.String("module", "backup")),
		configService,
		awsprovider.GetBaseRoleArn,
	}, nil
}

func (c UserContext) Log() *zap.Logger {
	return c.log
}

func (c UserContext) CheckOrgIDAndProjectID(orgID, projectID entity.ID) error {
	user := c.tenantAndUser
	if user.IsImpersonated {
		return nil
	}

	if orgID != user.TenantID {
		return errcode.NewForbiddenErr(errors.New("org_id parameter is inconsistent with the user's org ID"))
	}

	return dbutil.CheckProjectID(user.Projects, projectID)
}

func (c UserContext) CheckOrgIDAndProjectIDAndClusterIDAndBackupID(orgID, projectID, clusterID, backupID entity.ID) error {
	user := c.tenantAndUser
	if user.IsImpersonated {
		return nil
	}

	if orgID != user.TenantID {
		return errcode.NewForbiddenErr(errors.New("org_id parameter is inconsistent with the user's org ID"))
	}

	err := db.Transaction(func(tx *gorm.DB) error {
		var backup entity.Backup
		err := entity.NewBackupQuerySet(tx).IDEq(backupID).DeletedAtIsNull().PreloadBackupSchedule().One(&backup)
		if err != nil {
			c.log.Error("failed to get backup by ID", zap.Uint64("backupID", uint64(backupID)), zap.Error(err))
			return errcode.NewInternalErr(errors.Annotate(err, "failed to get backup by ID"))
		}

		// load the cluster of backup
		clusterOfBackup, err := dbutil.GetCluster(tx, backup.BackupSchedule.ClusterID)
		if err != nil {
			return errcode.NewInvalidInputErr(errors.Annotate(err, "load cluster of backup failed"))
		}

		// parameter accuracy check
		if projectID != clusterOfBackup.ProjectID {
			return errcode.NewForbiddenErr(errors.New("project_id parameter is inconsistent with the backup's project ID"))
		}
		if clusterID != clusterOfBackup.ID {
			return errcode.NewForbiddenErr(errors.New("cluster_id parameter is inconsistent with the backup's cluster ID"))
		}

		// Does user have permission to access the backup
		if !dbutil.UserInProject(user.Projects, clusterOfBackup.ProjectID) {
			return errcode.NewForbiddenErr(errors.New("the backup don't belong current user"))
		}
		return nil
	})
	return err
}

// Delete soft deletes a backup
func (c UserContext) Delete(orgID, projectID, clusterID, backupID entity.ID) error {
	return db.Transaction(func(tx *gorm.DB) error {
		return dbutil.DeleteBackup(tx, orgID, projectID, clusterID, backupID)
	})
}

func (c UserContext) DeleteDeletedClusterAndBackups(orgID, projectID, clusterID entity.ID) error {
	err := db.Transaction(func(tx *gorm.DB) error {
		_, err := dbutil.GetDeletedClusterByID(tx, orgID, projectID, clusterID)
		return err
	})
	if err != nil {
		return err
	}

	return db.Transaction(func(tx *gorm.DB) error {
		if err := dbutil.DeleteDeletedClusterBackups(tx, orgID, projectID, clusterID, entity.BackupType_All); err != nil {
			return err
		}
		return dbutil.AddAccessLogNow(tx, c.tenantAndUser.User.TenantID, c.tenantAndUser.Email, fmt.Sprintf("delete backups for cluster: %d", clusterID.Uint64()))
	})
}

// Create saves a backup schedule record to the database
func (c UserContext) CreateBackup(orgID entity.ID, projectID entity.ID, input dbutil.CreateBackupRequired) (Backup, error) {
	org, err := c.Model.tenantModel.User(c.tenantAndUser).GetTenant()
	if err != nil {
		c.log.Error("failed to get organization", zap.Error(err))
		return Backup{}, errcode.NewInternalErr(errors.New("failed to get organization"))
	}
	if org.Plan == entity.TenantPlanFreeTrial || org.Plan == entity.TenantPlanNewUser {
		return Backup{}, errcode.NewInvalidInputErr(errors.New("free trial organization does not have permission to perform backup"))
	}

	var (
		cluster           entity.Cluster
		newBackupSchedule *entity.BackupSchedule
		now               time.Time
	)
	now = time.Now()
	err = db.Transaction(func(tx *gorm.DB) error {
		cluster, err = dbutil.GetClusterByID(tx, orgID, projectID, input.ClusterID)
		if err != nil {
			return err
		}

		newBackupSchedule = &entity.BackupSchedule{
			Type:       input.Type,
			ClusterID:  cluster.ID,
			MaxBackups: input.MaxBackups,
			Schedule:   input.Schedule,
			UserID:     c.tenantAndUser.User.ID,
			Targets:    input.Targets,
			Method:     input.Method,
		}

		newBackupSchedule.Name = input.Name
		newBackupSchedule.NextBackupTime = &now

		if err := newBackupSchedule.Create(tx); err != nil {
			return errors.Annotate(err, "create backup schedule failed")
		}
		err = dbutil.CreateBackup(tx, *newBackupSchedule, cluster)
		if err != nil {
			return err
		}

		return dbutil.AddAccessLogNow(tx, c.tenantAndUser.User.TenantID, c.tenantAndUser.Email, fmt.Sprintf("create backup schedule for cluster: %d, name: %s, schedule: %s", cluster.ID, newBackupSchedule.Name, newBackupSchedule.Schedule))
	})
	if err != nil {
		c.Log().Error("failed to create backup for cluster", zap.Uint64("clusterID", newBackupSchedule.ClusterID.Uint64()),
			zap.String("backup type", string(newBackupSchedule.Type)), zap.Error(err))
		return Backup{}, errcode.NewInternalErr(errors.Errorf("failed to create %s backup for cluster", newBackupSchedule.Type))
	}

	return Backup{
		ID:        newBackupSchedule.ID,
		ClusterID: newBackupSchedule.ClusterID,
	}, nil
}

// Update saves a auto Backup record to the database
func (c UserContext) UpsertAutoBackup(orgID entity.ID, projectID entity.ID, input dbutil.CreateBackupRequired) (Backup, error) {
	org, err := c.Model.tenantModel.User(c.tenantAndUser).GetTenant()
	if err != nil {
		c.log.Error("failed to get organization", zap.Error(err))
		return Backup{}, errcode.NewInternalErr(errors.New("failed to get organization"))
	}
	if org.Plan == entity.TenantPlanFreeTrial || org.Plan == entity.TenantPlanNewUser {
		return Backup{}, errcode.NewInvalidInputErr(errors.New("free trial organization does not have permission to perform backup"))
	}

	var backupTime string
	err = db.Transaction(func(tx *gorm.DB) error {
		backupTime, err = c.convertBackupTime(tx, input.Schedule, false)
		return err
	})
	if err != nil {
		return Backup{}, err
	}
	cronSchedule, err := getCron(backupTime)
	if err != nil {
		return Backup{}, err
	}

	change := func(oldBS, newBS *entity.BackupSchedule) bool {
		if newBS.Method == oldBS.Method &&
			newBS.MaxBackups == oldBS.MaxBackups &&
			newBS.Schedule == oldBS.Schedule {
			return false
		}
		return true
	}

	// TODO convert cluster to new cluster entity
	var backupSchedule *entity.BackupSchedule
	err = db.Transaction(func(tx *gorm.DB) error {
		cluster, err := dbutil.GetClusterByID(tx, orgID, projectID, input.ClusterID)
		if err != nil {
			return err
		}

		newBackupSchedule := &entity.BackupSchedule{
			Type:       input.Type,
			Name:       dbutil.GenerateAutoBackupName(cluster.ID),
			ClusterID:  cluster.ID,
			MaxBackups: input.MaxBackups,
			Schedule:   cronSchedule,
			UserID:     c.tenantAndUser.User.ID,
			Targets:    input.Targets,
			Method:     input.Method,
		}
		backupSchedule, err = dbutil.UpsertBackupSchedule(tx, cluster, newBackupSchedule, change)
		if err != nil {
			return err
		}
		return dbutil.AddAccessLogNow(tx, c.tenantAndUser.User.TenantID, c.tenantAndUser.Email, fmt.Sprintf("update backup schedule for cluster: %d, name: %s, schedule: %s", cluster.ID, newBackupSchedule.Name, newBackupSchedule.Schedule))
	})
	if err != nil {
		return Backup{}, err
	}

	return Backup{
		ID:        backupSchedule.ID,
		ClusterID: backupSchedule.ClusterID,
	}, nil
}

func (c UserContext) GetAutoSetting(orgID, projectID, clusterID entity.ID) (*central.AutoBackupSetting, error) {
	setting := &central.AutoBackupSetting{
		BackupMode: central.BackupMode_FULL,
	}

	t, err := c.Model.tenantModel.User(c.tenantAndUser).GetTenant()
	if err != nil {
		c.log.Error("failed to get tenant", zap.Error(err))
		return setting, err
	}
	tenantPlan := t.Plan
	if tenantPlan == entity.TenantPlanNewUser || tenantPlan == entity.TenantPlanFreeTrial {
		return setting, nil
	}

	var (
		backupTime     string
		backupSchedule entity.BackupSchedule
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		cluster, err := dbutil.GetClusterByID(tx, orgID, projectID, clusterID)
		if err != nil {
			return err
		}
		backupSchedule, err = dbutil.GetBackupAutoSchedule(tx, cluster.ID)
		if err != nil {
			return err
		}

		backupTime, err = parseCron(backupSchedule.Schedule)
		if err != nil {
			return err
		}
		backupTime, err = c.convertBackupTime(tx, backupTime, true)
		return err
	})
	if err != nil {
		return setting, err
	}

	setting.BackupTime = backupTime
	setting.MaxBackups = backupSchedule.MaxBackups
	return setting, nil
}

// If opposite is true , the backup time should add the UTC offset, otherwise the schedule should minus the UTC offset
// Get BackupTime via API: opposite(true)
// Store BackupTime to db: opposite(false)
func (c UserContext) convertBackupTime(tx *gorm.DB, backupTime string, opposite bool) (string, error) {
	var (
		tenantEntity entity.Tenant
		err          error
	)
	tenantEntity, err = dbutil.GetTenantByID(tx, c.tenantAndUser.TenantID)
	if err != nil {
		return "", errors.Annotate(err, "Get tenant by ID")
	}

	timezone, err := strconv.Atoi(tenantEntity.TimeZone)
	if err != nil {
		return "", err
	}
	utcOffset := int32(timezone)
	if !opposite {
		utcOffset = -utcOffset
	}

	s, err := convertBackupTimeByUTCOffset(backupTime, utcOffset)
	if err != nil {
		return "", errors.Annotate(err, "Convert backup time by UTC offset")
	}
	return s, nil
}

// Backup models a backup, including its job via Operation
type Backup struct {
	ID             entity.ID
	ClusterID      entity.ID
	Status         entity.BackupStatus
	Name           string
	BackupCRDName  string
	Type           entity.BackupType
	Method         entity.BackupMethod
	Description    string
	CreatedAt      time.Time
	Size           uint64
	TidbCount      uint32
	TikvCount      uint32
	TiFlashCount   uint32
	ExpirationTime *time.Time
}

// ListForCluster lists Backups for a cluster
func (c UserContext) ListForCluster(orgID, projectID, clusterID entity.ID) ([]Backup, error) {
	var (
		cluster         entity.Cluster
		backupSchedules []entity.BackupSchedule
		err             error
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		cluster, err = dbutil.GetClusterByID(tx, orgID, projectID, clusterID)
		if err != nil {
			return err
		}
		return entity.NewBackupScheduleQuerySet(tx).
			ClusterIDEq(clusterID).
			DeletedAtIsNull().
			PreloadCluster().
			All(&backupSchedules)
	})
	if err != nil {
		return nil, err
	}
	if cluster.Status.Deleted() {
		return c.convertList(cluster, backupSchedules, true)
	}
	return c.convertList(cluster, backupSchedules, false)
}

func (c UserContext) convertList(cluster entity.Cluster, backupSchedules []entity.BackupSchedule, ignoreFailedBackup bool) ([]Backup, error) {
	var (
		items          []Backup
		expireDuration time.Duration
		backupMap      = map[string]*v1alpha1.Backup{}
		backupMapInfra = map[string]*cloudv1.Backup{}
	)

	org, err := c.Model.tenantModel.User(c.tenantAndUser).GetTenant()
	if err != nil {
		c.log.Error("failed to get organization", zap.Error(err))
		return nil, errcode.NewInternalErr(errors.New("failed to get organization"))
	}

	enableInfraBackupRestoreRead := c.infraAPI.InfraAPIConfig.EnableBackupRestoreRead
	if enableInfraBackupRestoreRead {
		backupCRs, err := c.infraAPI.ListBackups(context.TODO(), cluster.ProjectID.Uint64(), cluster.ID.Uint64())
		if err != nil {
			c.log.Error("failed to get backup CRs", zap.Error(err))
			return nil, errcode.NewInternalErr(errors.New("failed to get backup CRs"))
		}
		for _, backupCR := range backupCRs {
			b := backupCR
			backupMapInfra[b.Name] = &b
		}
	} else {
		backupCRs := c.getBackupCRs(cluster)
		for _, backupCR := range backupCRs {
			backupMap[backupCR.Name] = backupCR
		}
	}

	for _, schedule := range backupSchedules {
		s := schedule
		var (
			backups []entity.Backup
			err     error
		)
		err = db.Transaction(func(tx *gorm.DB) error {
			backups, err = dbutil.BackupFetch(dbutil.ScheduleBackups(tx, s.ID))
			return err
		})
		if err != nil {
			return nil, err
		}
		for _, backup := range backups {
			backup.BackupSchedule = &s
			item := convertBackup(backup)
			var (
				found        bool
				backupStatus string
			)
			if enableInfraBackupRestoreRead {
				var matched *cloudv1.Backup
				matched, found = backupMapInfra[backupCRDName(&backup)]
				if found {
					item.Size = uint64(matched.Status.BackupSize)
					backupStatus = util.GetBackupStatusForInfraAPI(matched)
				}
			} else {
				var matched *v1alpha1.Backup
				matched, found = backupMap[backupCRDName(&backup)]
				if found {
					item.Size = uint64(matched.Status.BackupSize)
					backupStatus = util.GetBackupStatus(matched)
				}
			}
			// For deleted cluster, expire time should be attached
			if cluster.Status.Deleted() {
				// We do not delete any manual backups of on_demand user
				if !(org.Plan == entity.TenantPlanOnDemand && schedule.Type == entity.BackupType_Manual) {
					expireDuration, err = util.GetBackupExpireTime(org.Plan, schedule.Type)
					if err != nil {
						return nil, err
					}
					expirationTime := cluster.DeletedTime.Add(expireDuration)
					item.ExpirationTime = &expirationTime
				}
			}
			if !ignoreFailedBackup || (found && entity.BackupStatus(backupStatus) == entity.BackupStatus_SUCCESS) {
				items = append(items, item)
			}
		}
	}
	return items, nil
}

func convertBackup(backup entity.Backup) Backup {
	tiFlashCount := uint32(0)
	if backup.TiflashComponentMachines.IDs != nil {
		tiFlashCount = uint32(len(backup.TiflashComponentMachines.IDs))
	}
	return Backup{
		ID:            backup.ID,
		ClusterID:     backup.BackupSchedule.ClusterID,
		Type:          backup.BackupSchedule.Type,
		Method:        backup.BackupSchedule.Method,
		Name:          backup.BackupSchedule.Name,
		BackupCRDName: backupCRDName(&backup),
		CreatedAt:     backup.CreatedAt,
		Description:   backup.BackupSchedule.Description,
		Status:        backup.Status,
		TidbCount:     uint32(len(backup.TidbComponentMachines.IDs)),
		TikvCount:     uint32(len(backup.TikvComponentMachines.IDs)),
		TiFlashCount:  tiFlashCount,
	}
}

func (c UserContext) getBackupCRs(cluster entity.Cluster) []*v1alpha1.Backup {
	var (
		provider *entity.Provider
		region   *entity.Region
		err      error
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		provider, region, err = dbutil.GetClusterProviderAndRegion(tx, cluster)
		return err
	})
	if err != nil {
		log.Error("failed to get region and provider", zap.Error(err))
		return nil
	}
	rx, err := c.runtimes.Context(cluster.ProjectID.Uint64(), provider.Name, region.Name)
	if err != nil {
		log.Error("failed to get context", zap.Error(err))
		return nil
	}
	namespace := runtimeutil.TiDBNamespace(cluster.ID.Uint64())
	backupCRs, err := rx.Runtime.ListBackups(namespace)
	if err != nil {
		log.Error("failed to list backup cr", zap.Error(err))
		return nil
	}
	return backupCRs
}

func (c BgContext) Log() *zap.Logger {
	return c.log
}
