package service

import (
	"context"
	cryptorand "crypto/rand"
	"errors"
	"fmt"
	"strings"
	"time"

	infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
	"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
	"github.com/oklog/ulid/v2"
	"github.com/redis/go-redis/v9"
	"github.com/shopspring/decimal"
)

const (
	InviteCodeCharset    = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
	InviteCodeLength     = 8
	InviteCodeMaxRetries = 5

	InviteMaxSettlementLevels  = 3
	InviteLoopValidationMaxHop = 10000
	InviteCommissionPoolRateBP = 1000
	InviteLevel1RateBP         = 5000
	InviteLevel2RateBP         = 3000
	InviteLevel3RateBP         = 2000
	InviteMinOrderAmountFen    = 100
	InviteIPBindLimitPerDay    = 10

	InviteCommissionStatusPending  = "pending"
	InviteCommissionStatusSettled  = "settled"
	InviteCommissionStatusFailed   = "failed"
	InviteCommissionStatusReversed = "reversed"

	InviteCommissionBizTypeInviteReward = "invite_commission_reward"
	InviteCommissionBizRefTypePayment   = "payment_order"

	inviteSettlementLockPrefix = "lock:invite_commission:order:"
)

var (
	ErrInviteCodeNotFound       = infraerrors.BadRequest("INVITE_CODE_NOT_FOUND", "invite code not found")
	ErrInviteSelfNotAllowed     = infraerrors.BadRequest("INVITE_SELF_NOT_ALLOWED", "cannot bind self invite code")
	ErrInviteLoopDetected       = infraerrors.BadRequest("INVITE_LOOP_DETECTED", "invite loop detected")
	ErrInviteChainTooDeep       = infraerrors.Conflict("INVITE_CHAIN_TOO_DEEP", "invite chain too deep to validate")
	ErrInviteAlreadyBound       = infraerrors.BadRequest("INVITE_ALREADY_BOUND", "invite relation already bound")
	ErrInviteIPLimitExceeded    = infraerrors.TooManyRequests("INVITE_IP_LIMIT_EXCEEDED", "invite bind ip limit exceeded")
	ErrInviteCodeGenerateFailed = infraerrors.ServiceUnavailable("INVITE_CODE_GENERATE_FAILED", "failed to generate unique invite code")
	ErrCommissionAlreadySettled = infraerrors.Conflict("COMMISSION_ALREADY_SETTLED", "commission already settled")
)

type inviteRequestContextKey string

const inviteRequestIPContextKey inviteRequestContextKey = "invite_request_ip"

func WithRequestClientIP(ctx context.Context, ip string) context.Context {
	if ctx == nil {
		ctx = context.Background()
	}
	return context.WithValue(ctx, inviteRequestIPContextKey, strings.TrimSpace(ip))
}

func RequestClientIPFromContext(ctx context.Context) string {
	if ctx == nil {
		return ""
	}
	value, _ := ctx.Value(inviteRequestIPContextKey).(string)
	return strings.TrimSpace(value)
}

type InviteBindingDecision struct {
	InviteCode     string
	Inviter        *User
	InviterUserID  *int64
	InviteBoundAt  *time.Time
	IPLimitSkipped bool
}

type InviteBindingLimiter interface {
	AllowBinding(ctx context.Context, inviteCode, requestIP string) (bool, error)
}

type InviteCommissionOrder struct {
	ID             int64
	BuyerUserID    int64
	OutTradeNo     string
	OrderAmountFen int64
}

type InviteLevelRate struct {
	Level  int   `json:"level"`
	RateBP int64 `json:"rate_bp"`
}

type InviteSummary struct {
	InviteCode            string            `json:"invite_code"`
	InviteLink            string            `json:"invite_link"`
	PointsBalance         string            `json:"points_balance"`
	TotalCommissionEarned string            `json:"total_commission_earned"`
	InvitedCount          int64             `json:"invited_count"`
	PaidInvitedCount      int64             `json:"paid_invited_count"`
	Level1Count           int64             `json:"level_1_count"`
	Level2Count           int64             `json:"level_2_count"`
	Level3Count           int64             `json:"level_3_count"`
	CommissionPoolRateBP  int64             `json:"commission_pool_rate_bp"`
	LevelRates            []InviteLevelRate `json:"level_rates"`
}

type InviteTreeSummary struct {
	Level1Count int64 `json:"level_1_count"`
	Level2Count int64 `json:"level_2_count"`
	Level3Count int64 `json:"level_3_count"`
	TotalCount  int64 `json:"total_count"`
}

type InvitePointsLevelSummary struct {
	Level            int    `json:"level"`
	InviteCount      int64  `json:"invite_count"`
	RewardPoints     string `json:"reward_points"`
	CalculatedPoints string `json:"calculated_points"`
}

type InvitePointsSummary struct {
	PointsBalance         string                     `json:"points_balance"`
	TotalPointsEarned     string                     `json:"total_points_earned"`
	CalculatedPointsTotal string                     `json:"calculated_points_total"`
	InvitedCount          int64                      `json:"invited_count"`
	Level1Count           int64                      `json:"level_1_count"`
	Level2Count           int64                      `json:"level_2_count"`
	Level3Count           int64                      `json:"level_3_count"`
	Levels                []InvitePointsLevelSummary `json:"levels"`
}

type InvitePointsChangeRecord struct {
	ID              string     `json:"id"`
	UserAssetID     string     `json:"user_asset_id"`
	UserID          int64      `json:"user_id"`
	AssetCode       string     `json:"asset_code"`
	Direction       string     `json:"direction"`
	AvailableDelta  string     `json:"available_delta"`
	AvailableBefore string     `json:"available_before"`
	AvailableAfter  string     `json:"available_after"`
	BizType         string     `json:"biz_type"`
	BizRefType      string     `json:"biz_ref_type"`
	BizRefID        string     `json:"biz_ref_id"`
	RelatedUserID   *int64     `json:"related_user_id,omitempty"`
	IdempotencyKey  string     `json:"idempotency_key"`
	Remark          string     `json:"remark"`
	OccurredAt      *time.Time `json:"occurred_at,omitempty"`
	CreatedAt       time.Time  `json:"created_at"`
}

type InviteCommissionRecord struct {
	ID                string     `json:"id"`
	PaymentOrderID    int64      `json:"payment_order_id"`
	OutTradeNo        string     `json:"out_trade_no"`
	BuyerUserID       int64      `json:"buyer_user_id"`
	BuyerDisplayName  string     `json:"buyer_display_name"`
	BeneficiaryUserID int64      `json:"beneficiary_user_id"`
	InviteLevel       int        `json:"invite_level"`
	OrderAmountFen    int64      `json:"order_amount_fen"`
	OrderAmount       string     `json:"order_amount"`
	CommissionAmount  string     `json:"commission_amount"`
	Status            string     `json:"status"`
	FailureReason     *string    `json:"failure_reason,omitempty"`
	SettledAt         *time.Time `json:"settled_at,omitempty"`
	CreatedAt         time.Time  `json:"created_at"`
}

type InviteePlanSummary struct {
	PlanKey         string `json:"plan_key"`
	PlanVariantKey  string `json:"plan_variant_key,omitempty"`
	PlanDisplayName string `json:"plan_display_name"`
	PaidOrderCount  int64  `json:"paid_order_count"`
}

type InviteeSummary struct {
	UserID                int64                `json:"user_id"`
	InviteLevel           int                  `json:"invite_level"`
	DisplayNameMasked     string               `json:"display_name_masked"`
	InviteBoundAt         *time.Time           `json:"invite_bound_at,omitempty"`
	CreatedAt             time.Time            `json:"created_at"`
	HasPaidSubscription   bool                 `json:"has_paid_subscription"`
	PaidOrderCount        int64                `json:"paid_order_count"`
	PaidPlanSummaries     []InviteePlanSummary `json:"paid_plan_summaries"`
	LatestPaidAt          *time.Time           `json:"latest_paid_at,omitempty"`
	HasCommission         bool                 `json:"has_commission"`
	CommissionRecordCount int64                `json:"commission_record_count"`
}

type InviteCommissionMetrics struct {
	PaidInvitedCount      int64
	TotalCommissionEarned string
}

type InviteCommissionLine struct {
	BeneficiaryUserID int64
	InviteLevel       int
	LevelRateBP       int
	CommissionAmount  string
}

type ApplyInviteCommissionSettlementInput struct {
	Order InviteCommissionOrder
	Lines []InviteCommissionLine
}

type MarkInviteSettlementFailedInput struct {
	Order         InviteCommissionOrder
	Lines         []InviteCommissionLine
	FailureReason string
}

type InviteCommissionRepository interface {
	ApplySettlement(ctx context.Context, input *ApplyInviteCommissionSettlementInput) error
	MarkSettlementFailed(ctx context.Context, input *MarkInviteSettlementFailedInput) error
	ListRetryableOrderIDs(ctx context.Context, limit int) ([]int64, error)
	GetMetrics(ctx context.Context, beneficiaryUserID int64) (*InviteCommissionMetrics, error)
	GetTreeSummary(ctx context.Context, inviterUserID int64) (*InviteTreeSummary, error)
	ListInvitees(ctx context.Context, inviterUserID int64, params pagination.PaginationParams) ([]InviteeSummary, *pagination.PaginationResult, error)
	ListByBeneficiary(ctx context.Context, beneficiaryUserID int64, params pagination.PaginationParams) ([]InviteCommissionRecord, *pagination.PaginationResult, error)
	HasOtherRewardHistory(ctx context.Context, buyerUserID int64, excludeOrderID int64) (bool, error)
}

type CommissionSettlementDispatcher interface {
	DispatchRetry(ctx context.Context, orderID int64, reason string) error
}

type SyncOnlyDispatcher struct{}

func (SyncOnlyDispatcher) DispatchRetry(context.Context, int64, string) error { return nil }

type InviteCommissionService struct {
	userRepo    UserRepository
	assetRepo   UserAssetRepository
	repo        InviteCommissionRepository
	redisClient *redis.Client
	dispatcher  CommissionSettlementDispatcher
}

func NewInviteCommissionService(
	userRepo UserRepository,
	assetRepo UserAssetRepository,
	repo InviteCommissionRepository,
	redisClient *redis.Client,
) *InviteCommissionService {
	return &InviteCommissionService{
		userRepo:    userRepo,
		assetRepo:   assetRepo,
		repo:        repo,
		redisClient: redisClient,
		dispatcher:  SyncOnlyDispatcher{},
	}
}

func (s *InviteCommissionService) SetDispatcher(dispatcher CommissionSettlementDispatcher) {
	if dispatcher == nil {
		s.dispatcher = SyncOnlyDispatcher{}
		return
	}
	s.dispatcher = dispatcher
}

func GenerateInviteCode(ctx context.Context, repo interface {
	InviteCodeExists(context.Context, string) (bool, error)
}) (string, error) {
	if repo == nil {
		return "", ErrInviteCodeGenerateFailed
	}
	for i := 0; i < InviteCodeMaxRetries; i++ {
		code, err := randomInviteCode()
		if err != nil {
			return "", err
		}
		exists, err := repo.InviteCodeExists(ctx, code)
		if err != nil {
			return "", err
		}
		if !exists {
			return code, nil
		}
	}
	return "", ErrInviteCodeGenerateFailed
}

func ResolveInviteBinding(
	ctx context.Context,
	repo interface {
		GetByInviteCode(context.Context, string) (*User, error)
		GetByID(context.Context, int64) (*User, error)
	},
	inviteCode string,
	currentUserID *int64,
	limiter InviteBindingLimiter,
) (*InviteBindingDecision, error) {
	inviteCode = normalizeInviteCode(inviteCode)
	if inviteCode == "" {
		return nil, nil
	}
	inviter, err := repo.GetByInviteCode(ctx, inviteCode)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, ErrInviteCodeNotFound
		}
		return nil, err
	}
	if inviter == nil || !inviter.IsActive() {
		return nil, ErrInviteCodeNotFound
	}
	if currentUserID != nil {
		if inviter.ID == *currentUserID {
			return nil, ErrInviteSelfNotAllowed
		}
		visited := map[int64]struct{}{
			inviter.ID: {},
		}
		cursor := inviter
		for depth := 0; cursor != nil && cursor.InviterUserID != nil; depth++ {
			if depth >= InviteLoopValidationMaxHop {
				return nil, ErrInviteChainTooDeep
			}
			parentUserID := *cursor.InviterUserID
			if parentUserID == *currentUserID {
				return nil, ErrInviteLoopDetected
			}
			if _, exists := visited[parentUserID]; exists {
				return nil, ErrInviteLoopDetected
			}
			visited[parentUserID] = struct{}{}
			nextUser, nextErr := repo.GetByID(ctx, parentUserID)
			if nextErr != nil {
				if errors.Is(nextErr, ErrUserNotFound) {
					break
				}
				return nil, nextErr
			}
			cursor = nextUser
		}
	}

	decision := &InviteBindingDecision{
		InviteCode: inviteCode,
		Inviter:    inviter,
	}

	requestIP := RequestClientIPFromContext(ctx)
	if limiter != nil && requestIP != "" {
		allowed, limitErr := limiter.AllowBinding(ctx, inviteCode, requestIP)
		if limitErr != nil {
			return nil, limitErr
		}
		if !allowed {
			decision.IPLimitSkipped = true
			return decision, nil
		}
	}

	now := time.Now().UTC()
	decision.InviterUserID = &inviter.ID
	decision.InviteBoundAt = &now
	return decision, nil
}

func DefaultInviteLevelRates() []InviteLevelRate {
	return []InviteLevelRate{
		{Level: 1, RateBP: InviteLevel1RateBP},
		{Level: 2, RateBP: InviteLevel2RateBP},
		{Level: 3, RateBP: InviteLevel3RateBP},
	}
}

func InviteLevelRateBP(level int) int {
	switch level {
	case 1:
		return InviteLevel1RateBP
	case 2:
		return InviteLevel2RateBP
	case 3:
		return InviteLevel3RateBP
	default:
		return 0
	}
}

func CalcInviteCommissionAmount(orderAmountFen int64, poolRateBP, levelRateBP int) string {
	if orderAmountFen <= 0 || poolRateBP <= 0 || levelRateBP <= 0 {
		return decimal.Zero.StringFixed(8)
	}
	amount := decimal.NewFromInt(orderAmountFen).
		Div(decimal.NewFromInt(100)).
		Mul(decimal.NewFromInt(int64(poolRateBP))).
		Div(decimal.NewFromInt(10000)).
		Mul(decimal.NewFromInt(int64(levelRateBP))).
		Div(decimal.NewFromInt(10000))
	return amount.Truncate(8).StringFixed(8)
}

func FenToDisplayAmount(orderAmountFen int64) string {
	return decimal.NewFromInt(orderAmountFen).Div(decimal.NewFromInt(100)).StringFixed(2)
}

func NewStringID() string {
	return ulid.Make().String()
}

func (s *InviteCommissionService) SettleForPaidOrder(ctx context.Context, order *InviteCommissionOrder) error {
	if s == nil || s.repo == nil || order == nil {
		return nil
	}
	if order.ID <= 0 || order.BuyerUserID <= 0 {
		return nil
	}
	if order.OrderAmountFen < InviteMinOrderAmountFen {
		return nil
	}

	release, acquired, err := s.acquireSettlementLock(ctx, order.ID)
	if err != nil {
		return err
	}
	if !acquired {
		return nil
	}
	defer release()

	lines, lineErr := s.buildSettlementLines(ctx, order)
	if lineErr != nil {
		return lineErr
	}
	if len(lines) == 0 {
		return nil
	}

	applyInput := &ApplyInviteCommissionSettlementInput{Order: *order, Lines: lines}
	if err := s.repo.ApplySettlement(ctx, applyInput); err != nil {
		if errors.Is(err, ErrCommissionAlreadySettled) {
			return nil
		}
		failureReason := truncateInviteFailureReason(err.Error())
		_ = s.repo.MarkSettlementFailed(ctx, &MarkInviteSettlementFailedInput{
			Order:         *order,
			Lines:         lines,
			FailureReason: failureReason,
		})
		if s.dispatcher != nil {
			_ = s.dispatcher.DispatchRetry(ctx, order.ID, failureReason)
		}
		return err
	}
	return nil
}

func (s *InviteCommissionService) GetSummary(ctx context.Context, userID int64, baseURL string) (*InviteSummary, error) {
	if s == nil || s.userRepo == nil {
		return nil, nil
	}
	user, err := s.userRepo.GetByID(ctx, userID)
	if err != nil {
		return nil, err
	}

	pointsBalance := decimal.Zero.StringFixed(8)
	if s.assetRepo != nil {
		asset, assetErr := s.assetRepo.GetByLogicalIdentity(ctx, userID, AssetCodePoints, AssetNetworkInternal, AssetChainRefInternal, "")
		if assetErr != nil && !errors.Is(assetErr, ErrUserAssetNotFound) {
			return nil, assetErr
		}
		if assetErr == nil && asset != nil {
			pointsBalance = normalizeDecimalString(asset.AvailableAmount, 8)
		}
	}

	metrics := &InviteCommissionMetrics{TotalCommissionEarned: decimal.Zero.StringFixed(8)}
	if s.repo != nil {
		loadedMetrics, metricsErr := s.repo.GetMetrics(ctx, userID)
		if metricsErr != nil {
			return nil, metricsErr
		}
		if loadedMetrics != nil {
			metrics = loadedMetrics
		}
	}

	tree := &InviteTreeSummary{}
	if s.repo != nil {
		loadedTree, treeErr := s.repo.GetTreeSummary(ctx, userID)
		if treeErr != nil {
			return nil, treeErr
		}
		if loadedTree != nil {
			tree = loadedTree
		}
	}

	return &InviteSummary{
		InviteCode:            user.InviteCode,
		InviteLink:            buildInviteLink(baseURL, user.InviteCode),
		PointsBalance:         pointsBalance,
		TotalCommissionEarned: normalizeDecimalString(metrics.TotalCommissionEarned, 8),
		InvitedCount:          tree.TotalCount,
		PaidInvitedCount:      metrics.PaidInvitedCount,
		Level1Count:           tree.Level1Count,
		Level2Count:           tree.Level2Count,
		Level3Count:           tree.Level3Count,
		CommissionPoolRateBP:  InviteCommissionPoolRateBP,
		LevelRates:            DefaultInviteLevelRates(),
	}, nil
}

func (s *InviteCommissionService) GetTreeSummary(ctx context.Context, userID int64) (*InviteTreeSummary, error) {
	if s == nil || s.repo == nil {
		return &InviteTreeSummary{}, nil
	}
	return s.repo.GetTreeSummary(ctx, userID)
}

func (s *InviteCommissionService) ListCommissionRecords(ctx context.Context, userID int64, params pagination.PaginationParams) ([]InviteCommissionRecord, *pagination.PaginationResult, error) {
	if s == nil || s.repo == nil {
		return []InviteCommissionRecord{}, &pagination.PaginationResult{Page: 1, PageSize: params.Limit(), Pages: 1}, nil
	}
	return s.repo.ListByBeneficiary(ctx, userID, params)
}

func (s *InviteCommissionService) ListInvitees(ctx context.Context, userID int64, params pagination.PaginationParams) ([]InviteeSummary, *pagination.PaginationResult, error) {
	if s == nil || s.repo == nil {
		return []InviteeSummary{}, &pagination.PaginationResult{Page: 1, PageSize: params.Limit(), Pages: 1}, nil
	}
	return s.repo.ListInvitees(ctx, userID, params)
}

func (s *InviteCommissionService) GetPointsSummary(ctx context.Context, userID int64) (*InvitePointsSummary, error) {
	pointsBalance := decimal.Zero.StringFixed(8)
	totalPointsEarned := decimal.Zero.StringFixed(8)

	if s != nil && s.assetRepo != nil {
		asset, assetErr := s.assetRepo.GetByLogicalIdentity(ctx, userID, AssetCodePoints, AssetNetworkInternal, AssetChainRefInternal, "")
		if assetErr != nil && !errors.Is(assetErr, ErrUserAssetNotFound) {
			return nil, assetErr
		}
		if assetErr == nil && asset != nil {
			pointsBalance = normalizeDecimalString(asset.AvailableAmount, 8)
			totalPointsEarned = normalizeDecimalString(asset.TotalEarned, 8)
		}
	}

	tree := &InviteTreeSummary{}
	if s != nil && s.repo != nil {
		loadedTree, treeErr := s.repo.GetTreeSummary(ctx, userID)
		if treeErr != nil {
			return nil, treeErr
		}
		if loadedTree != nil {
			tree = loadedTree
		}
	}

	levels, calculatedTotal := buildInvitePointsLevelSummaries(tree)
	return &InvitePointsSummary{
		PointsBalance:         pointsBalance,
		TotalPointsEarned:     totalPointsEarned,
		CalculatedPointsTotal: calculatedTotal,
		InvitedCount:          tree.TotalCount,
		Level1Count:           tree.Level1Count,
		Level2Count:           tree.Level2Count,
		Level3Count:           tree.Level3Count,
		Levels:                levels,
	}, nil
}

func (s *InviteCommissionService) ListPointChanges(ctx context.Context, userID int64, params pagination.PaginationParams) ([]InvitePointsChangeRecord, *pagination.PaginationResult, error) {
	if s == nil || s.assetRepo == nil {
		return []InvitePointsChangeRecord{}, &pagination.PaginationResult{Page: 1, PageSize: params.Limit(), Pages: 1}, nil
	}

	items, result, err := s.assetRepo.ListPointChangesByUser(ctx, userID, params)
	if err != nil {
		return nil, nil, err
	}

	records := make([]InvitePointsChangeRecord, 0, len(items))
	for _, item := range items {
		records = append(records, InvitePointsChangeRecord{
			ID:              item.ID,
			UserAssetID:     item.UserAssetID,
			UserID:          item.UserID,
			AssetCode:       item.AssetCode,
			Direction:       item.Direction,
			AvailableDelta:  normalizeDecimalString(item.AvailableDelta, 8),
			AvailableBefore: normalizeDecimalString(item.AvailableBefore, 8),
			AvailableAfter:  normalizeDecimalString(item.AvailableAfter, 8),
			BizType:         item.BizType,
			BizRefType:      item.BizRefType,
			BizRefID:        item.BizRefID,
			RelatedUserID:   item.RelatedUserID,
			IdempotencyKey:  item.IdempotencyKey,
			Remark:          item.Remark,
			OccurredAt:      item.OccurredAt,
			CreatedAt:       item.CreatedAt,
		})
	}
	return records, result, nil
}

func (s *InviteCommissionService) buildSettlementLines(ctx context.Context, order *InviteCommissionOrder) ([]InviteCommissionLine, error) {
	if s.userRepo == nil || order == nil {
		return nil, nil
	}
	buyer, err := s.userRepo.GetByID(ctx, order.BuyerUserID)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, nil
		}
		return nil, err
	}
	if buyer == nil || buyer.InviterUserID == nil {
		return nil, nil
	}
	lines := make([]InviteCommissionLine, 0, InviteMaxSettlementLevels)
	visited := map[int64]struct{}{
		buyer.ID: {},
	}
	nextInviterUserID := buyer.InviterUserID
	for level := 1; level <= InviteMaxSettlementLevels && nextInviterUserID != nil; level++ {
		inviterUserID := *nextInviterUserID
		if _, exists := visited[inviterUserID]; exists {
			break
		}
		visited[inviterUserID] = struct{}{}

		inviter, inviterErr := s.userRepo.GetByID(ctx, inviterUserID)
		if inviterErr != nil {
			if errors.Is(inviterErr, ErrUserNotFound) {
				break
			}
			return nil, inviterErr
		}
		if inviter == nil {
			break
		}
		nextInviterUserID = inviter.InviterUserID
		if !inviter.IsActive() {
			continue
		}

		levelRateBP := InviteLevelRateBP(level)
		if levelRateBP <= 0 {
			continue
		}
		rewardPoints := CalcInviteCommissionAmount(order.OrderAmountFen, InviteCommissionPoolRateBP, levelRateBP)
		if !isPositiveDecimalString(rewardPoints) {
			continue
		}

		lines = append(lines, InviteCommissionLine{
			BeneficiaryUserID: inviter.ID,
			InviteLevel:       level,
			LevelRateBP:       levelRateBP,
			CommissionAmount:  rewardPoints,
		})
	}
	return lines, nil
}

func (s *InviteCommissionService) acquireSettlementLock(ctx context.Context, orderID int64) (func(), bool, error) {
	if s == nil || s.redisClient == nil || orderID <= 0 {
		return func() {}, true, nil
	}
	lockKey := fmt.Sprintf("%s%d", inviteSettlementLockPrefix, orderID)
	token := NewStringID()
	acquired, err := s.redisClient.SetNX(ctx, lockKey, token, 10*time.Second).Result()
	if err != nil {
		return nil, false, err
	}
	if !acquired {
		return func() {}, false, nil
	}
	return func() {
		_, _ = inviteSettlementUnlockScript.Run(ctx, s.redisClient, []string{lockKey}, token).Result()
	}, true, nil
}

var inviteSettlementUnlockScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
	return redis.call('DEL', KEYS[1])
end
return 0
`)

func buildInviteLink(baseURL, inviteCode string) string {
	trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/")
	if trimmedBase == "" {
		return ""
	}
	if inviteCode == "" {
		return trimmedBase + "/register"
	}
	return fmt.Sprintf("%s/register?invite=%s", trimmedBase, inviteCode)
}

func normalizeInviteCode(code string) string {
	return strings.ToUpper(strings.TrimSpace(code))
}

func randomInviteCode() (string, error) {
	buf := make([]byte, InviteCodeLength)
	if _, err := cryptorand.Read(buf); err != nil {
		return "", err
	}
	charsetLen := byte(len(InviteCodeCharset))
	out := make([]byte, InviteCodeLength)
	for i := range buf {
		out[i] = InviteCodeCharset[int(buf[i]%charsetLen)]
	}
	return string(out), nil
}

func normalizeDecimalString(raw string, scale int32) string {
	value, err := decimal.NewFromString(strings.TrimSpace(raw))
	if err != nil {
		return decimal.Zero.StringFixed(scale)
	}
	return value.StringFixed(scale)
}

func buildInvitePointsLevelSummaries(tree *InviteTreeSummary) ([]InvitePointsLevelSummary, string) {
	if tree == nil {
		tree = &InviteTreeSummary{}
	}

	levelCounts := []int64{tree.Level1Count, tree.Level2Count, tree.Level3Count}
	levels := make([]InvitePointsLevelSummary, 0, len(levelCounts))
	for idx, inviteCount := range levelCounts {
		levels = append(levels, InvitePointsLevelSummary{
			Level:            idx + 1,
			InviteCount:      inviteCount,
			RewardPoints:     decimal.Zero.StringFixed(8),
			CalculatedPoints: decimal.Zero.StringFixed(8),
		})
	}
	return levels, decimal.Zero.StringFixed(8)
}

func truncateInviteFailureReason(reason string) string {
	reason = strings.TrimSpace(reason)
	if len(reason) <= 255 {
		return reason
	}
	return reason[:255]
}

func isPositiveDecimalString(raw string) bool {
	value, err := decimal.NewFromString(strings.TrimSpace(raw))
	if err != nil {
		return false
	}
	return value.GreaterThan(decimal.Zero)
}
