// Package backup encapsulates all backup functionality
package backup

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

	"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/db/tenant"
	api "github.com/pingcap/dbaas/central/infra-api"
	tenantmodel "github.com/pingcap/dbaas/central/model/tenant"
	"github.com/pingcap/errcode"
	"github.com/pingcap/errors"
	"github.com/tidbcloud/config-service/sdk"
	"github.com/tidbcloud/idl/pbgen/proto/central"
	"github.com/tidbcloud/pkgv2/log"
	mailv2 "github.com/tidbcloud/pkgv2/mail/v2"
	"github.com/tidbcloud/pkgv2/secret/encrypt"
	"github.com/tidbcloud/pkgv2/tracing"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

//revive:enable:expd

// Model is created with NewModel
type Model struct {
	log         *zap.Logger
	infraAPI    *api.API
	mailFacade  mailv2.Facade
	encryptor   encrypt.Encryptor
	tenantModel tenantmodel.Model
}

// NewModel returns a Model
func NewModel(log *zap.Logger, infraAPI *api.API, mailFacade mailv2.Facade, encryptor encrypt.Encryptor, tenantModel tenantmodel.Model) Model {
	return Model{
		log:         log,
		mailFacade:  mailFacade,
		infraAPI:    infraAPI,
		encryptor:   encryptor,
		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
}

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

var Tracer = otel.Tracer("central/model/backup")

// User returns a UserContext
func (m Model) User(ctx context.Context, 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")).With(log.FieldsFromContext(ctx)...),
		ConfigService: configService,
	}
}

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,
	}, 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 {
			if errors.IsNotFound(err) {
				return errcode.NewInvalidInputErr(errors.Errorf("can't find backup, backID=%d", backupID))
			}
			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()))
	})
}

const (
	ErrExceedDevTierMaxManualBackups = "Dev Tier clusters are allowed two manual backups. Please delete one or more of your backups before creating another backup."
)

// CreateManualBackup saves a manual backup schedule record to the database
func (c UserContext) CreateManualBackup(ctx context.Context, orgID entity.ID, projectID entity.ID, input dbutil.CreateBackupRequired) (_ Backup, err error) {
	ctx, span := Tracer.Start(ctx, "UserContext.CreateManualBackup")
	defer func() { tracing.EndSpan(span, err) }()
	var (
		org                entity.Tenant
		cluster            entity.Cluster
		devTierBackupCount uint64
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		org, err = dbutil.GetTenantByID(tx, c.tenantAndUser.TenantID)
		if err != nil {
			return errors.Annotate(err, "failed to get tenant by ID")
		}

		cluster, err = dbutil.GetClusterByID(tx, orgID, projectID, input.ClusterID)
		if err != nil {
			return errors.Annotate(err, "failed to get cluster by ID")
		}

		if cluster.Free {
			systemUser, err := dbutil.SystemUser(tx, c.tenantAndUser.TenantID)
			if err != nil {
				return errors.Annotate(err, "failed to get tenant system user")
			}

			backupSchedules, err := dbutil.BackupScheduleFetch(dbutil.ClusterBackupSchedules(tx, cluster.ID).TypeEq(entity.BackupType_Manual))
			if err != nil {
				return errors.Annotatef(err, "failed to get cluster manual backup schedule of clsuter %d", cluster.ID)
			}
			for _, schedule := range backupSchedules {
				if schedule.Name == config.FinalBackupName && schedule.UserID == systemUser.ID {
					continue
				}
				backups, err := dbutil.BackupFetch(dbutil.ScheduleBackups(tx, schedule.ID))
				if err != nil {
					return errors.Annotatef(err, "failed to get backups of backup schedule %d", schedule.ID)
				}
				for _, backup := range backups {
					if backup.DeletedAt == nil {
						devTierBackupCount++
					}
				}
			}
		}
		return nil
	})
	if err != nil {
		c.log.Error("failed to validate create manual backup request", zap.Error(err))
		return Backup{}, errcode.NewInternalErr(errors.New("failed to validate create manual backup request"))
	}
	span.AddEvent("ValidateRequest")

	// TODO(Louis): Compatible with the previous logic, the cluster of free trial users does not allow automatic backup.
	// When the free trial user does not have old clusters running (not dev tier tier clusters), remove the following logic.
	if (org.Plan == entity.TenantPlanFreeTrial || org.Plan == entity.TenantPlanNewUser) && !cluster.Free {
		return Backup{}, errcode.NewInvalidInputErr(errors.New("free trial organization does not have permission to perform backup"))
	}
	if devTierBackupCount >= config.DevTierMaxManualBackups {
		return Backup{}, errcode.NewInvalidInputErr(errors.New(ErrExceedDevTierMaxManualBackups))
	}

	var bs *entity.BackupSchedule
	now := time.Now()
	err = db.Transaction(func(tx *gorm.DB) error {
		bs = &entity.BackupSchedule{
			Name:           input.Name,
			Type:           input.Type,
			ClusterID:      cluster.ID,
			MaxBackups:     input.MaxBackups,
			Schedule:       input.Schedule,
			UserID:         c.tenantAndUser.User.ID,
			Targets:        input.Targets,
			Method:         input.Method,
			NextBackupTime: &now,
		}

		if err := bs.Create(tx); err != nil {
			return errors.Annotate(err, "failed to create backup schedule record")
		}
		span.AddEvent("CreateBackupSchedule")
		err = dbutil.CreateBackup(ctx, tx, *bs, cluster)
		if err != nil {
			return errors.Annotate(err, "failed to create backup record")
		}

		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, bs.Name, bs.Schedule))
	})
	if err != nil {
		c.Log().Error("failed to create backup for cluster", zap.Uint64("clusterID", bs.ClusterID.Uint64()),
			zap.String("backup type", string(bs.Type)), zap.Error(err))
		return Backup{}, errcode.NewInternalErr(errors.New("failed to create manual backup for cluster"))
	}

	span.AddEvent("CreateBackup")
	return Backup{
		ID:        bs.ID,
		ClusterID: bs.ClusterID,
	}, nil
}

// UpsertAutoBackup saves a auto Backup record to the database
func (c UserContext) UpsertAutoBackup(ctx context.Context, orgID entity.ID, projectID entity.ID, input dbutil.CreateBackupRequired) (_ Backup, err error) {
	ctx, span := Tracer.Start(ctx, "UserContext.UpsertAutoBackup")
	span.SetAttributes(attribute.String("orgID", strconv.FormatUint(uint64(orgID), 10)))
	span.SetAttributes(attribute.String("projectID", strconv.FormatUint(uint64(projectID), 10)))
	defer func() { tracing.EndSpan(span, err) }()
	var (
		org     entity.Tenant
		cluster entity.Cluster
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		org, err = dbutil.GetTenantByID(tx, c.tenantAndUser.TenantID)
		if err != nil {
			return errors.Annotate(err, "failed to get tenant by ID")
		}

		cluster, err = dbutil.GetClusterByID(tx, orgID, projectID, input.ClusterID)
		if err != nil {
			return errors.Annotate(err, "failed to get cluster by ID")
		}
		return nil
	})
	if err != nil {
		c.log.Error("failed to validate create auto backup request", zap.Error(err))
		return Backup{}, err
	}
	span.AddEvent("ValidateRequest")

	// TODO(Louis): Compatible with the previous logic, the cluster of free trial users does not allow automatic backup.
	// When the free trial user does not have old clusters running (not dev tier tier clusters), remove the following logic.
	if (org.Plan == entity.TenantPlanFreeTrial || org.Plan == entity.TenantPlanNewUser) && !cluster.Free {
		return Backup{}, errcode.NewInvalidInputErr(errors.New("free trial organization does not have permission to perform backup"))
	}
	if cluster.Free && input.MaxBackups != config.DevTierMaxAutoBackups {
		return Backup{}, errcode.NewInvalidInputErr(errors.Errorf("the dev tier cluster can only have %d automatic backup at most", config.DevTierMaxAutoBackups))
	}

	var backupTime string
	backupTime, err = c.convertBackupTime(org.TimeZone, input.Schedule, false)
	if err != nil {
		c.log.Error("failed to convert backup time", zap.Error(err))
		return Backup{}, errcode.NewInternalErr(errors.New("failed to convert backup time"))
	}
	cronSchedule, err := getCron(backupTime)
	if err != nil {
		c.log.Error("failed to convert backup time to schedule", zap.Error(err))
		return Backup{}, errcode.NewInternalErr(errors.New("failed to convert backup time to schedule"))
	}

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

	var backupSchedule *entity.BackupSchedule
	err = db.Transaction(func(tx *gorm.DB) error {
		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(ctx, tx, cluster, newBackupSchedule, change)
		if err != nil {
			return errors.Annotate(err, "failed to upsert backup schedule")
		}
		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 {
		c.log.Error("failed to update auto backup setting", zap.Error(err))
		return Backup{}, errcode.NewInternalErr(errors.New("failed to update auto backup setting"))
	}
	span.AddEvent("CreateBackup")

	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,
	}

	var (
		org     entity.Tenant
		cluster entity.Cluster
		err     error
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		org, err = dbutil.GetTenantByID(tx, c.tenantAndUser.TenantID)
		if err != nil {
			return errors.Annotate(err, "failed to get tenant by ID")
		}

		cluster, err = dbutil.GetClusterByID(tx, orgID, projectID, clusterID)
		if err != nil {
			return errors.Annotate(err, "failed to get cluster by ID")
		}
		return nil
	})
	if err != nil {
		c.log.Error("failed to validate get auto backup setting request", zap.Error(err))
		return setting, err
	}

	// TODO(Louis): Compatible with the previous logic, the cluster of free trial users does not allow automatic backup.
	// When the free trial user does not have old clusters running (not dev tier tier clusters), remove the following logic.
	if org.Plan == entity.TenantPlanFreeTrial && !cluster.Free {
		return setting, nil
	}

	var (
		backupTime     string
		backupSchedule entity.BackupSchedule
	)
	err = db.Transaction(func(tx *gorm.DB) error {
		backupSchedule, err = dbutil.GetBackupAutoSchedule(tx, cluster.ID)
		if err != nil {
			return errors.Annotate(err, "failed to get backup schedule")
		}
		setting.MaxBackups = backupSchedule.MaxBackups

		backupTime, err = parseCron(backupSchedule.Schedule)
		if err != nil {
			return errors.Annotate(err, "failed to parse backup schedule")
		}
		backupTime, err = c.convertBackupTime(org.TimeZone, backupTime, true)
		if err != nil {
			return errors.Annotate(err, "failed to convert backup time")
		}
		setting.BackupTime = backupTime
		return nil
	})
	if err != nil {
		c.log.Error("failed to get auto backup setting", zap.Error(err))
		return setting, errcode.NewInternalErr(errors.New("failed to get auto backup setting"))
	}
	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(timeZone string, backupTime string, opposite bool) (string, error) {
	timezone, err := strconv.Atoi(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
	ClusterComponents *central.ClusterComponents
	ExpirationTime    *time.Time
}

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