package discount

import (
	"errors"
	"fmt"
	"sort"
	"time"

	"github.com/google/uuid"
	"github.com/pingcap/dbaas/billing2/billing_errors"
	"github.com/pingcap/dbaas/billing2/config"
	"github.com/pingcap/dbaas/billing2/constant"
	"github.com/pingcap/dbaas/billing2/db"
	"github.com/pingcap/dbaas/billing2/db/entity"
	"github.com/pingcap/dbaas/billing2/forms"
	"github.com/pingcap/dbaas/billing2/handler/cachehandler"
	"github.com/pingcap/dbaas/billing2/handler/centralhandler"
	"github.com/pingcap/dbaas/billing2/model/marketing/unit_filter"
	"github.com/pingcap/dbaas/billing2/utils/timeutils"
	"github.com/pingcap/dbaas/billing2/utils/transformer"
	"github.com/pingcap/dbaas/billing2/utils/uncutils"
	"github.com/pingcap/dbaas/billing2/utils/user"
	"github.com/pingcap/log"
	"github.com/shopspring/decimal"
	"go.uber.org/zap"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

type Model struct{}

var NoDiscount = &forms.Discount{
	Value: decimal.NewFromInt(1),
}

var BetaDiscount = &forms.Discount{
	Value: decimal.NewFromInt(0),
}

const (
	DiscountCreateNotification  = "discount_create_notification"
	DiscountUpdateNotification  = "discount_update_notification"
	DiscountReclaimNotification = "discount_reclaim_notification"
	DiscountExpireNotification  = "discount_expire_notification"
)

const ListTenantActiveDiscountsCacheFuncName = "ListTenantActiveDiscounts"

func ValidateDiscountReq(d *forms.Discount) (err error) {
	now := timeutils.GetNow()
	if !d.ExpiredAt.After(d.StartedAt) {
		return billing_errors.NewInvalidInputError(errors.New("expired_at before start_at"))
	}
	if d.Usage == constant.DiscountUsageRerun {
		if d.ExpiredAt.After(timeutils.GetDayDate(now)) {
			return billing_errors.NewInvalidInputError(errors.New("rerun discount can only target past usage windows"))
		}
		if d.Value.LessThan(decimal.Zero) {
			return billing_errors.NewInvalidInputError(errors.New("discount value should be positive"))
		}
		return nil
	}
	if d.ExpiredAt.Before(now) {
		return billing_errors.NewInvalidInputError(errors.New("discount is expired"))
	}
	if d.Value.LessThan(decimal.Zero) {
		return billing_errors.NewInvalidInputError(errors.New("discount value should be positive"))
	}
	return
}

func discountFromEntity(ent *entity.Discount) (ret *forms.Discount) {
	status := ent.Status
	now := timeutils.GetNow()
	if ent.Status != constant.DiscountStatus_Reclaimed {
		if ent.ExpiredAt.After(now) {
			status = constant.DiscountStatus_Valid
		} else {
			status = constant.DiscountStatus_Expired
		}
	}

	orderedUnitFilters := transformer.OrderedUnitFilter(ent.UnitFilters)
	return &forms.Discount{
		DiscountID:          ent.ID,
		DiscountSID:         fmt.Sprint(ent.ID),
		Code:                ent.Code,
		Status:              status,
		Value:               ent.Value,
		Version:             ent.Version,
		TenantID:            ent.TenantID,
		Usage:               ent.Usage,
		Note:                ent.Note,
		Description:         ent.Description,
		StartedAt:           ent.StartedAt,
		ExpiredAt:           ent.ExpiredAt,
		ClosedAt:            ent.ClosedAt,
		Active:              ent.Active,
		UnitFilters:         transformer.UnitFiltersSlimFromEntities(orderedUnitFilters),
		UnitFiltersInternal: transformer.UnitFiltersFromEntities(orderedUnitFilters),
		CopyUnitFilterArgs:  (&unit_filter.Model{}).CopyUnitFilterArgsFromEntity(ent.CopyUnitFilterArgs),
	}
}

func (m *Model) SetDiscount(changeInfo *forms.ChangeBindedDiscountInput) (ret *forms.Discount, err error) {
	var unitFilters []*entity.UnitFilter
	err = db.GetDB().Model(&entity.UnitFilter{}).Where("name in (?)", config.Get().GetStringSlice(config.DefaultDiscountUnitFilters)).Find(&unitFilters).Error
	if err != nil {
		return nil, err
	}

	discountEntity := entity.Discount{
		Code:                  uuid.NewString(),
		TenantID:              changeInfo.ChangerTenantID,
		Value:                 NoDiscount.Value,
		OldDiscountUniqueMark: &changeInfo.ChangerTenantID,
		Status:                constant.DiscountStatus_Valid,
		UnitFilters:           unitFilters,
		StartedAt:             timeutils.GetNow(),
		ExpiredAt:             config.Get().GetTime(config.LatestExpiration),
		BindedBillingConfigID: *changeInfo.BindedBillingConfigID,
	}
	// TODO: The following code will split to create and update after sku management will have completed.
	// TODO: Now every discount entity is unique for one tenant, although this check is weak.
	err = db.GetDB().Clauses(clause.Insert{Modifier: "IGNORE"}).Create(&discountEntity).Error
	if err != nil {
		return nil, err
	}
	err = entity.NewDiscountQuerySet(db.GetDB()).OldDiscountUniqueMarkEq(changeInfo.ChangerTenantID).TenantIDEq(changeInfo.ChangerTenantID).One(&discountEntity)
	if err != nil {
		return nil, err
	}
	return m.UpdateDiscount(discountEntity.TenantID, discountEntity.ID, changeInfo.BindedDiscount)
}

// TODO this function will remove on sku management refactor
func (m *Model) GetTenantOnDemandDiscount(tenantID uint, billingConfigID uint) (ret *forms.Discount, err error) {
	discounts, err := m.ListTenantDiscounts(tenantID, billingConfigID)
	if err != nil {
		return
	} else if len(discounts) == 0 {
		return NoDiscount, nil
	} else {
		return discounts[0], nil
	}
}

func (m *Model) ListTenantDiscounts(tenantID uint, billingConfigID uint) (ret []*forms.Discount, err error) {
	var discountEntities []entity.Discount
	err = entity.NewDiscountQuerySet(db.GetDB().Preload(clause.Associations)).TenantIDEq(tenantID).
		OrderDescByCreatedAt().BindedBillingConfigIDEq(billingConfigID).All(&discountEntities)
	if err != nil {
		return
	}
	ret = make([]*forms.Discount, len(discountEntities))
	for i, ent := range discountEntities {
		ret[i] = discountFromEntity(&ent)
	}
	return ret, nil
}

func (m *Model) ListTenantActiveDiscounts(tenantID uint, billingConfigID uint) ([]*forms.Discount, error) {
	result := []*forms.Discount{}
	var discountEntities []entity.Discount

	// we cache entity here for info from entity has its own logic related by time
	found := cachehandler.Get().Get(ListTenantActiveDiscountsCacheFuncName, &discountEntities, tenantID, billingConfigID)
	if !found {
		err := entity.NewDiscountQuerySet(db.GetDB().Preload(clause.Associations)).TenantIDEq(tenantID).BindedBillingConfigIDEq(billingConfigID).ActiveEq(constant.DiscountActive).OrderDescByCreatedAt().All(&discountEntities)
		if err != nil {
			return nil, err
		}
		cachehandler.Get().Set(ListTenantActiveDiscountsCacheFuncName, &discountEntities, tenantID, billingConfigID)
	}

	for _, ent := range discountEntities {
		discount := discountFromEntity(&ent)
		result = append(result, discount)
	}
	return result, nil
}

func (m *Model) ListGlobalActiveDiscounts() (ret []*forms.Discount, err error) {
	return m.ListTenantActiveDiscounts(0, 0)
}

func (m *Model) ListGlobalDiscounts() (ret []*forms.Discount, err error) {
	return m.ListTenantDiscounts(0, 0)
}

func (m *Model) ListDiscountsConsoleView(tenantID uint, billingConfigID uint) (ret []*forms.Discount, err error) {
	// this func result contains global discount
	tenantDiscounts, err := m.ListTenantDiscounts(tenantID, billingConfigID)
	if err != nil {
		return nil, err
	}
	for _, discount := range tenantDiscounts {
		if (discount.Value.Cmp(decimal.Zero) < 0) || (discount.Value.Cmp(decimal.NewFromInt(1)) >= 0) {
			continue
		}
		ret = append(ret, discount)
	}

	globalDiscounts, err := m.ListGlobalActiveDiscounts()
	if err != nil {
		return nil, err
	}
	now := timeutils.GetNow()
	for _, discount := range globalDiscounts {
		if (discount.Value.Cmp(decimal.Zero) < 0) || (discount.Value.Cmp(decimal.NewFromInt(1)) >= 0) {
			continue
		}
		if discount.StartedAt.After(now) {
			continue
		}
		ret = append(ret, discount)
	}

	sort.Slice(ret, func(i, j int) bool {
		return ret[i].CreatedAt > ret[j].CreatedAt
	})
	return
}

func (m *Model) ListEffectiveDiscounts(tenantID uint, billingConfigID uint) ([]*forms.Discount, error) {
	tenantDiscounts, err := m.ListTenantActiveDiscounts(tenantID, billingConfigID)
	if err != nil {
		return nil, err
	}
	globalDiscounts, err := m.ListGlobalActiveDiscounts()
	if err != nil {
		return nil, err
	}

	ret := append(tenantDiscounts, globalDiscounts...)
	sort.Slice(ret, func(i, j int) bool {
		return ret[i].CreatedAt > ret[j].CreatedAt
	})
	return ret, nil
}

func (m *Model) CreateDiscountVersion(tx *gorm.DB, tenantID uint, info *forms.Discount) (version uint, err error) {
	// TODO metrics
	versionEntity := &entity.DiscountVersion{
		Value:      info.Value,
		TenantID:   tenantID,
		OperatorID: user.GetCurrentUserID(),
	}
	err = versionEntity.Create(tx)
	if err != nil {
		return
	}
	return versionEntity.ID, nil
}

func (m *Model) CreateDiscountChangeAuditLog(tx *gorm.DB, tenantID uint, info *forms.Discount) (err error) {
	actionEntity := &entity.AuditRecord{
		Action:    entity.AuditRecordActionChangeBindedDiscount,
		TenantID:  tenantID,
		UserID:    user.GetCurrentUserID(),
		ExtraInfo: fmt.Sprintf(`{"binded_discount": "%s"}`, info.Value),
		UserEmail: user.GetCurrentUserEmail(),
	}
	return actionEntity.Create(tx)
}

func (m *Model) GetDiscountWithTx(tx *gorm.DB, tenantID uint, discountID uint) (ret *forms.Discount, err error) {
	var ent entity.Discount
	err = entity.NewDiscountQuerySet(tx.Preload(clause.Associations)).TenantIDEq(tenantID).IDEq(discountID).One(&ent)
	if err != nil {
		return
	}
	return discountFromEntity(&ent), nil
}

func (m *Model) GetDiscount(tenantID uint, discountID uint) (ret *forms.Discount, err error) {
	return m.GetDiscountWithTx(db.GetDB(), tenantID, discountID)
}

func (m *Model) CreateDiscountWithTx(tx *gorm.DB, tenantID uint, info *forms.Discount) (ret *forms.Discount, err error) {
	err = ValidateDiscountReq(info)
	if err != nil {
		return nil, err
	}
	ent := entity.Discount{
		Code:                  uuid.NewString(),
		TenantID:              tenantID,
		Value:                 info.Value,
		Status:                constant.DiscountStatus_Valid,
		StartedAt:             info.StartedAt,
		ExpiredAt:             info.ExpiredAt,
		Description:           info.Description,
		Usage:                 info.Usage,
		Note:                  info.Note,
		UnitFilters:           transformer.UnitFiltersEntitiesFromUnitFilterSlimForm(info.UnitFilters),
		BindedBillingConfigID: *info.BindedBillingConfigID,
		CopyUnitFilterArgs:    entity.CopyUnitFilterArgs(info.CopyUnitFilterArgs),
	}
	version, err := m.CreateDiscountVersion(tx, tenantID, info)
	if err != nil {
		return
	}
	err = m.CreateDiscountChangeAuditLog(tx, tenantID, info)
	if err != nil {
		return
	}
	ent.Version = version
	err = ent.Create(tx)
	if err != nil {
		return
	}
	cachehandler.Get().Delete(ListTenantActiveDiscountsCacheFuncName, tenantID, *info.BindedBillingConfigID)
	ret, err = m.GetDiscountWithTx(tx, tenantID, ent.ID)
	if err != nil {
		return nil, err
	}
	m.sendDiscountChangeNotificationIfNeed(ret, DiscountCreateNotification)
	return ret, err
}

func (m *Model) CreateDiscount(tenantID uint, info *forms.Discount) (ret *forms.Discount, err error) {
	err = db.GetDB().Transaction(func(tx *gorm.DB) error {
		ret, err = m.CreateDiscountWithTx(tx, tenantID, info)
		return err
	})
	return
}

func (m *Model) UpdateDiscountWithTx(tx *gorm.DB, tenantID uint, discountID uint, value decimal.Decimal) (ret *forms.Discount, err error) {
	info, err := m.GetDiscountWithTx(tx, tenantID, discountID)
	if err != nil {
		return
	}
	info.Value = value
	err = ValidateDiscountReq(info)
	if err != nil {
		return nil, err
	}
	version, err := m.CreateDiscountVersion(tx, tenantID, info)
	if err != nil {
		return
	}
	err = m.CreateDiscountChangeAuditLog(tx, tenantID, info)
	if err != nil {
		return
	}
	err = entity.NewDiscountQuerySet(tx).IDEq(discountID).GetUpdater().SetVersion(version).SetValue(value).Update()
	if err != nil {
		return
	}
	ret, err = m.GetDiscountWithTx(tx, tenantID, discountID)
	if err != nil {
		return nil, err
	}
	cachehandler.Get().Delete(ListTenantActiveDiscountsCacheFuncName, tenantID, ret.BindedBillingConfigID)
	m.sendDiscountChangeNotificationIfNeed(ret, DiscountUpdateNotification)
	return ret, err
}

func (m *Model) UpdateDiscount(tenantID uint, discountID uint, value decimal.Decimal) (ret *forms.Discount, err error) {
	err = db.GetDB().Transaction(func(tx *gorm.DB) error {
		ret, err = m.UpdateDiscountWithTx(tx, tenantID, discountID, value)
		return err
	})
	return
}

func (m *Model) DeleteDiscountWithTx(tx *gorm.DB, tenantID uint, discountID uint) (err error) {
	now := timeutils.GetNow()
	discount, err := m.GetDiscountWithTx(tx, tenantID, discountID)
	if err != nil {
		return err
	}
	affectedRows, err := entity.NewDiscountQuerySet(tx).IDEq(discountID).TenantIDEq(tenantID).
		GetUpdater().SetStatus(constant.DiscountStatus_Reclaimed).SetClosedAt(&now).UpdateNum()
	if err != nil {
		return
	} else if affectedRows == 0 {
		return billing_errors.ObjectNotExistsError
	}
	cachehandler.Get().Delete(ListTenantActiveDiscountsCacheFuncName, tenantID, discount.BindedBillingConfigID)
	m.sendDiscountChangeNotificationIfNeed(discount, DiscountReclaimNotification)
	return
}

func (m *Model) DeleteDiscount(tenantID uint, discountID uint) (err error) {
	return m.DeleteDiscountWithTx(db.GetDB(), tenantID, discountID)
}

func (m *Model) InactiveTimeoutDiscount(tenantIDs ...uint) (rows int64, err error) {
	// this func can skip invalidate cache cause metering will check expire at double time, and ui can be updated after cache timeout
	now := timeutils.GetNow()
	timeoutDate := now.Add(-config.Get().GetDuration(config.DiscountInactiveDelayHour) * time.Hour)
	log.Info("start inactive closed discounts")
	query := entity.NewDiscountQuerySet(db.GetDB()).ClosedAtLte(timeoutDate).ActiveEq(constant.DiscountActive)
	if len(tenantIDs) != 0 {
		query = query.TenantIDIn(tenantIDs...)
	}
	closedNum, err := query.GetUpdater().SetActive(constant.DiscountInactive).SetInactiveTime(&now).UpdateNum()
	if err != nil {
		log.Error("failed to inactive closed discounts", zap.Error(err))
	} else if closedNum != 0 {
		log.Info("Success inactive closed discounts", zap.Int64("closedNum", closedNum))
	}
	log.Info("start inactive expired discounts")
	query = entity.NewDiscountQuerySet(db.GetDB()).ClosedAtIsNull().ExpiredAtLte(timeoutDate).ActiveEq(constant.DiscountActive)
	if len(tenantIDs) != 0 {
		query = query.TenantIDIn(tenantIDs...)
	}
	expiredNum, err := query.GetUpdater().SetActive(constant.DiscountInactive).SetInactiveTime(&now).UpdateNum()
	if err != nil {
		log.Error("failed to inactive expired discounts", zap.Error(err))
	} else if expiredNum != 0 {
		log.Info("Success inactive expired discounts", zap.Int64("expiredNum", expiredNum))
	}
	rows = closedNum + expiredNum
	if closedNum+expiredNum != 0 {
		allExpiredDiscounts := []entity.Discount{}
		err = entity.NewDiscountQuerySet(db.GetDB()).ActiveEq(constant.DiscountInactive).InactiveTimeEq(now).Select(entity.DiscountDBSchema.TenantID, entity.DiscountDBSchema.BindedBillingConfigID, entity.DiscountDBSchema.Code, entity.DiscountDBSchema.StartedAt, entity.DiscountDBSchema.ExpiredAt, entity.DiscountDBSchema.ClosedAt).All(&allExpiredDiscounts)
		if err != nil {
			return 0, err
		}
		for _, discount := range allExpiredDiscounts {
			cachehandler.Get().Delete(ListTenantActiveDiscountsCacheFuncName, discount.TenantID, discount.BindedBillingConfigID)
			m.sendDiscountChangeNotificationIfNeed(discountFromEntity(&discount), DiscountExpireNotification)
		}
	}
	return rows, err
}

func (m *Model) sendDiscountChangeNotificationIfNeed(targetDiscount *forms.Discount, notificationType string) {
	notificationEvent := ""
	startDateTime := timeutils.GetNow()
	// if it is 'create' notification, no matter that the targetDiscount is effective or non-effective, send in-app notification
	if notificationType == DiscountCreateNotification {
		notificationEvent = uncutils.UpdateDiscount
		startDateTime = targetDiscount.StartedAt
	}
	// if it is 'reclaim' notification, no matter that the targetDiscount is effective or non-effective, send in-app notification
	if notificationType == DiscountUpdateNotification || notificationType == DiscountReclaimNotification {
		if targetDiscount.Active != constant.DiscountActive {
			log.Info("no need to send in-app notification for inactive discount reclaim", zap.Uint("tenantID", targetDiscount.TenantID), zap.Any("targetDiscount", targetDiscount))
			return
		}
		notificationEvent = uncutils.ReclaimDiscount
	}
	// if it is 'expired' notification, when it has closed time, means it is reclaimed before, has sent notification, no need to send again
	if notificationType == DiscountExpireNotification {
		if targetDiscount.ClosedAt != nil {
			log.Info("no need to send in-app notification for discount update, it is reclaimed before", zap.Uint("tenantID", targetDiscount.TenantID), zap.Any("targetDiscount", targetDiscount))
			return
		}
		notificationEvent = uncutils.ExpireDiscount
		startDateTime = targetDiscount.ExpiredAt
	}
	// tenant_id = 0, it may be global discount, no need to send notification
	if targetDiscount.TenantID == 0 {
		log.Info("no need to send in-app notification for discount update, tenant_id = 0", zap.Uint("tenantID", targetDiscount.TenantID))
		return
	}
	tenant, err := centralhandler.GetCentralHandler().GetTenant(targetDiscount.TenantID)
	if err != nil {
		log.Error("fail to send in-app notification for discount update, get tenant info error", zap.Uint("tenantID", targetDiscount.TenantID), zap.String("event", notificationEvent), zap.Any("targetDiscount", targetDiscount), zap.Error(err))
		return
	}
	uncutils.SendInAppNotification(targetDiscount.TenantID, notificationEvent, map[string]string{
		"orgName":       tenant.Name,
		"discountCode":  targetDiscount.Code,
		"startDateTime": startDateTime.Format("2006-01-02 15:04:05 UTC±00:00"),
		"orgId":         fmt.Sprintf("%d", targetDiscount.TenantID),
	})
}
