package luckygift

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	"hyapp/pkg/appcode"
	"hyapp/pkg/giftlimits"
	"hyapp/pkg/logx"
	"hyapp/pkg/luckygiftmq"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/xerr"
	domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"

	"google.golang.org/grpc"
	"google.golang.org/protobuf/proto"
	luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
)

// Repository 是幸运礼物唯一持久化边界；RTP、单池余额、抽奖记录和返奖 outbox 必须在同一个 MySQL 事务内修改。
type Repository interface {
	GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error)
	PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
	ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error)
	CreateLuckyGiftExperiment(ctx context.Context, cmd domain.CreateExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error)
	ListLuckyGiftExperiments(ctx context.Context, query domain.ExperimentQuery) ([]domain.Experiment, error)
	UpdateLuckyGiftExperiment(ctx context.Context, cmd domain.UpdateExperimentCommand, nowMS int64) (domain.Experiment, error)
	SetLuckyGiftExperimentOverrides(ctx context.Context, cmd domain.SetExperimentOverridesCommand, nowMS int64) (domain.Experiment, error)
	EndLuckyGiftExperiment(ctx context.Context, cmd domain.EndExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error)
	CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
	ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
	ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error)
	ExecuteExternalGiftDraw(ctx context.Context, cmd domain.ExternalDrawCommand, nowMS int64) (domain.ExternalDrawResult, error)
	ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
	GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
	ListLuckyGiftUserProfiles(ctx context.Context, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error)
	GetLuckyGiftUserProfile(ctx context.Context, query domain.UserProfileQuery, nowMS int64) (domain.UserProfileDetail, bool, error)
	ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error)
	ListLuckyGiftDailyStats(ctx context.Context, query domain.DailyStatsQuery) ([]domain.DailyStat, error)
	AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error)
	GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
	ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error)
	DeleteDeliveredLuckyGiftOutboxBefore(ctx context.Context, cutoffMS int64, limit int) (int64, error)
	MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error
	MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error
	MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error
	RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error)
	RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error)
	MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error
	MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error
	// 批量抽奖只产生一条聚合 outbox；worker 处理后需要用 draw_ids 一次性收敛所有明细状态。
	MarkLuckyGiftDrawsGranted(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string, nowMS int64) error
	MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error
}

// luckyGiftUserProfileRefresher 是 MySQL 读模型的可选历史回灌能力。测试仓储和其他最小实现
// 不需要为了后台异步任务扩宽业务接口；生产仓储实现后由 worker 自动发现并运行。
type luckyGiftUserProfileRefresher interface {
	RefreshLuckyGiftUserProfiles(ctx context.Context, nowMS int64, batchSize int) (int, error)
}

// luckyGiftRuleVersionRepository 把历史版本能力保持为生产 MySQL 仓储的可选扩展，避免为只服务
// 单元测试的最小 Repository 伪实现扩散后台运维方法。
type luckyGiftRuleVersionRepository interface {
	ListLuckyGiftRuleConfigVersions(ctx context.Context, poolID string) ([]domain.RuleConfig, error)
	RollbackLuckyGiftRuleConfig(ctx context.Context, poolID string, targetRuleVersion int64, operatorAdminID int64, nowMS int64) (domain.RuleConfig, error)
}

// WalletClient 是幸运礼物返奖唯一账务依赖；wallet-service 仍负责持久 outbox，activity 只做成功落库后的低延迟余额 IM。
type WalletClient interface {
	CreditLuckyGiftReward(ctx context.Context, req *walletv1.CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error)
}

// UserPublisher 负责把钱包余额变化私有消息发给中奖用户；失败不能回滚已经成功的钱包入账。
type UserPublisher interface {
	PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}

// EventPublisher 把钱包已确认入账的中奖事实发布到 lucky-gift-service 专属 topic。
// 下游 room/activity 各自使用独立 consumer group 和本地幂等 outbox，发布失败只能重试展示事实，不能回滚钱包。
type EventPublisher interface {
	PublishLuckyGiftEvent(ctx context.Context, envelope *luckygifteventsv1.EventEnvelope) error
}

// Service 承载幸运礼物查询、抽奖命令和后台规则配置。
type Service struct {
	repository Repository
	wallet     WalletClient
	userPub    UserPublisher
	eventPub   EventPublisher
	now        func() time.Time
}

type Option func(*Service)

func WithWallet(client WalletClient) Option {
	return func(s *Service) {
		s.wallet = client
	}
}

func WithUserPublisher(publisher UserPublisher) Option {
	return func(s *Service) {
		s.userPub = publisher
	}
}

func WithEventPublisher(publisher EventPublisher) Option {
	return func(s *Service) {
		s.eventPub = publisher
	}
}

func New(repository Repository, options ...Option) *Service {
	service := &Service{repository: repository, now: time.Now}
	for _, option := range options {
		if option != nil {
			option(service)
		}
	}
	return service
}

func (s *Service) SetClock(now func() time.Time) {
	if now != nil {
		s.now = now
	}
}

func (s *Service) Check(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) {
	if err := s.requireRepository(); err != nil {
		return domain.CheckResult{}, err
	}
	// Check 是送礼前拦截，不产生抽奖事实；这里只归一化入参，最终启停仍以仓储中的当前规则为准。
	cmd.PoolID = normalizePoolID(cmd.PoolID)
	cmd.GiftID = strings.TrimSpace(cmd.GiftID)
	cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
	cmd.RoomID = strings.TrimSpace(cmd.RoomID)
	if cmd.GiftID == "" || cmd.UserID <= 0 || cmd.RoomID == "" {
		return domain.CheckResult{}, xerr.New(xerr.InvalidArgument, "lucky gift check command is incomplete")
	}
	if len(cmd.DeviceID) > 128 {
		return domain.CheckResult{}, xerr.New(xerr.InvalidArgument, "device_id is too long")
	}
	return s.repository.CheckLuckyGift(ctx, cmd)
}

func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.DrawResult, error) {
	if err := s.requireRepository(); err != nil {
		return domain.DrawResult{}, err
	}
	normalized, err := s.normalizeDrawCommand(cmd)
	if err != nil {
		return domain.DrawResult{}, err
	}
	result, err := s.repository.ExecuteLuckyGiftDraw(ctx, normalized, s.now().UTC().UnixMilli())
	if err != nil {
		return domain.DrawResult{}, err
	}
	return s.creditLuckyGiftRewardFastPath(ctx, normalized, result), nil
}

func (s *Service) DrawBatch(ctx context.Context, cmds []domain.DrawCommand) ([]domain.DrawResult, error) {
	if err := s.requireRepository(); err != nil {
		return nil, err
	}
	if len(cmds) == 0 {
		return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
	}
	normalized := make([]domain.DrawCommand, 0, len(cmds))
	totalUnits := int64(0)
	for _, cmd := range cmds {
		item, err := s.normalizeDrawCommand(cmd)
		if err != nil {
			return nil, err
		}
		units := int64(item.GiftCount)
		if units > giftlimits.MaxTotalGiftUnits-totalUnits {
			// BatchExecute 的每个 command 对应一个收礼目标；逐项 <=999 仍可能被大量目标放大。
			// 在进入 repository 的共享奖池事务前按所有目标求和，和 Gateway/Room/Wallet 的总工作量上限保持一致。
			return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch exceeds total work limit")
		}
		totalUnits += units
		normalized = append(normalized, item)
	}
	// 批量送礼的返奖不能阻塞 room-service 主链路；repository 只落 draw fact 和 settlement outbox，worker 后续负责 wallet/IM。
	return s.repository.ExecuteLuckyGiftDrawBatch(ctx, normalized, s.now().UTC().UnixMilli())
}

func (s *Service) ExecuteExternalDraw(ctx context.Context, cmd domain.ExternalDrawCommand) (domain.ExternalDrawResult, error) {
	if err := s.requireRepository(); err != nil {
		return domain.ExternalDrawResult{}, err
	}
	normalized, err := s.normalizeExternalDrawCommand(cmd)
	if err != nil {
		return domain.ExternalDrawResult{}, err
	}
	// 外部抽奖没有 HyApp wallet 返奖补偿；repository 在同一个事务里完成 app 级幂等命中、规则版本读取、随机抽奖、记录和 outbox。
	return s.repository.ExecuteExternalGiftDraw(ctx, normalized, s.now().UTC().UnixMilli())
}

func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) {
	cmd.AppCode = appcode.Normalize(cmd.AppCode)
	cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID)
	cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
	cmd.RequestID = strings.TrimSpace(cmd.RequestID)
	cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency))
	cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON)
	cmd.PoolID = normalizePoolID(cmd.PoolID)
	if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" {
		return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is incomplete")
	}
	if len(cmd.AppCode) > 32 || len(cmd.ExternalUserID) > 128 || len(cmd.DeviceID) > 128 || len(cmd.RequestID) > 128 || len(cmd.PoolID) > 96 {
		// 外部请求的 app 级业务键会进入唯一键和审计列；长度先在服务层失败，避免事务中途暴露 MySQL truncation/too long 错误。
		return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long")
	}
	if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 {
		return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive")
	}
	// dynamic_v3 必须为每个礼物保存一条顺序子抽事实；与内部送礼统一限制 999，
	// 避免外部请求用一个超大 count 制造不可控事务和审计行数。
	if cmd.GiftCount > 999 {
		return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count cannot exceed 999")
	}
	if cmd.GiftCount > (1<<63-1)/cmd.UnitAmount || cmd.GiftCount*cmd.UnitAmount != cmd.TotalAmount {
		// 金额校验必须在 owner service 再做一遍；gateway 是外部入口，不能成为唯一可信校验点。
		return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift total_amount mismatch")
	}
	if cmd.Currency == "" {
		cmd.Currency = "COIN"
	}
	if len(cmd.Currency) > 16 {
		return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift currency is too long")
	}
	if cmd.Currency != "COIN" {
		// lucky-gift-service 当前所有 RTP、奖池和返奖审计都以金币等价单位结算；没有 app 汇率规则前不能接收任意币种金额。
		return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift currency must be COIN")
	}
	if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) {
		return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid")
	}
	// paid_at_ms 是否可兼容缺失取决于不可变规则版本；service 层保留原值，repository
	// 读取 fixed_v2 后才可使用 nowMS，dynamic_v3 内外调用都必须提供真实扣费时间。
	return cmd, nil
}

func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawCommand, error) {
	// Draw 只接受扣费成功后的服务端事实；客户端不能提交价格、倍率或奖励字段。
	cmd.CommandID = strings.TrimSpace(cmd.CommandID)
	cmd.PoolID = normalizePoolID(cmd.PoolID)
	cmd.GiftID = strings.TrimSpace(cmd.GiftID)
	cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
	cmd.RoomID = strings.TrimSpace(cmd.RoomID)
	cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
	cmd.SenderName = strings.TrimSpace(cmd.SenderName)
	cmd.SenderAvatar = strings.TrimSpace(cmd.SenderAvatar)
	cmd.SenderDisplayUserID = strings.TrimSpace(cmd.SenderDisplayUserID)
	cmd.SenderPrettyDisplayUserID = strings.TrimSpace(cmd.SenderPrettyDisplayUserID)
	if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
		return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
	}
	if len(cmd.DeviceID) > 128 {
		return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw device_id is too long")
	}
	// device_id 可为空以兼容旧 JWT；dynamic_v3 只在可信值存在时启用设备日额度，
	// 其他风控维度不受影响，且任何路径都不得用 session/command 伪造设备。
	if cmd.TargetUserID < 0 {
		return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
	}
	if _, ok := giftlimits.WorkUnits(int64(cmd.GiftCount), 1); !ok {
		// One lucky draw command is per target; multi-target room sends are split before this owner.
		// The cap prevents a direct internal caller from allocating unbounded per-unit RNG/write work.
		return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit")
	}
	// 内部 wallet paid_at_ms 不能在 service 层用当前时钟补齐：repository 必须先读取策略，
	// fixed_v2 才允许兼容降级，dynamic_v3 则严格拒绝缺失 owner 事实。
	return cmd, nil
}

type WorkerOptions struct {
	WorkerID       string
	PollInterval   time.Duration
	BatchSize      int
	Concurrency    int
	LockTTL        time.Duration
	MaxRetry       int
	PublishTimeout time.Duration
	StatsInterval  time.Duration
	StatsBatchSize int
}

func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
	options = normalizeWorkerOptions(options)
	ticker := time.NewTicker(options.PollInterval)
	defer ticker.Stop()
	nextStatsRefresh := time.Time{}
	nextUserProfileRefresh := time.Time{}
	for {
		if _, err := s.ProcessPendingDrawOutbox(ctx, options); err != nil && ctx.Err() == nil {
			// 批处理失败不删除 outbox；锁过期后下一轮或其他实例会继续补偿。
			logx.Error(ctx, "lucky_gift_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID))
		}
		now := s.now().UTC()
		if options.StatsInterval > 0 && (nextStatsRefresh.IsZero() || !now.Before(nextStatsRefresh)) {
			if _, err := s.repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
				logx.Error(ctx, "lucky_gift_admin_stats_refresh_failed", err, slog.String("worker_id", options.WorkerID))
			}
			if _, err := s.repository.RefreshLuckyGiftDatabiStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
				logx.Error(ctx, "lucky_gift_databi_stats_refresh_failed", err, slog.String("worker_id", options.WorkerID))
			}
			nextStatsRefresh = now.Add(options.StatsInterval)
		}
		if refresher, ok := s.repository.(luckyGiftUserProfileRefresher); ok &&
			options.StatsInterval > 0 && (nextUserProfileRefresh.IsZero() || !now.Before(nextUserProfileRefresh)) {
			// 画像历史回灌独立于 admin/databi 的十分钟快照：每轮只串行处理 5000 条，保持事务和内存有界；
			// 有进度就在下一个 worker poll 继续，直到全部游标返回 0 才进入低频等待。
			const profileBatchSize = 5000
			processed, refreshErr := refresher.RefreshLuckyGiftUserProfiles(ctx, now.UnixMilli(), profileBatchSize)
			switch {
			case refreshErr != nil:
				if ctx.Err() == nil {
					logx.Error(ctx, "lucky_gift_user_profile_refresh_failed", refreshErr, slog.String("worker_id", options.WorkerID))
				}
				nextUserProfileRefresh = now.Add(options.StatsInterval)
			case processed > 0:
				nextUserProfileRefresh = now.Add(options.PollInterval)
			default:
				nextUserProfileRefresh = now.Add(options.StatsInterval)
			}
		}
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
		}
	}
}

func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOptions) (int, error) {
	if err := s.requireRepository(); err != nil {
		return 0, err
	}
	options = normalizeWorkerOptions(options)
	// outbox 只承载钱包返奖结算，避免多实例同时给同一 draw 返奖。
	events, err := s.repository.ClaimPendingLuckyGiftOutbox(ctx, options.WorkerID, s.now().UTC().UnixMilli(), options.LockTTL, options.BatchSize)
	if err != nil {
		return 0, err
	}
	if len(events) == 0 {
		return 0, nil
	}
	workerCount := options.Concurrency
	if workerCount > len(events) {
		workerCount = len(events)
	}
	jobs := make(chan domain.DrawOutbox)
	errs := make(chan error, len(events))
	var wg sync.WaitGroup
	for index := 0; index < workerCount; index++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for event := range jobs {
				if err := s.processDrawOutbox(ctx, event, options); err != nil {
					errs <- err
					continue
				}
				errs <- nil
			}
		}()
	}
	for _, event := range events {
		select {
		case <-ctx.Done():
			close(jobs)
			wg.Wait()
			close(errs)
			return 0, ctx.Err()
		case jobs <- event:
		}
	}
	close(jobs)
	wg.Wait()
	close(errs)
	processed := 0
	var firstErr error
	for err := range errs {
		if err != nil {
			if firstErr == nil {
				firstErr = err
			}
			continue
		}
		processed++
	}
	return processed, firstErr
}

func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error {
	payload, err := decodeDrawOutboxPayload(event.PayloadJSON)
	if err != nil {
		// payload 不可解析说明抽奖事实旁路已经损坏，重试也无法恢复，直接走失败收敛。
		return s.markOutboxFailed(ctx, event, options, err, false)
	}
	if strings.TrimSpace(payload.AppCode) == "" {
		// 早期 settlement payload 可能只依赖 outbox 列保存 app_code；发布跨服务事实时必须把 owner 列补回信封。
		payload.AppCode = event.AppCode
	}
	eventCtx := appcode.WithContext(ctx, event.AppCode)
	switch event.EventType {
	case domain.EventTypeLuckyGiftRewardSettlement, domain.EventTypeLuckyGiftDrawn, "":
		return s.processRewardSettlementOutbox(eventCtx, event, payload, options)
	case domain.EventTypeExternalLuckyGiftDrawn:
		// 外部 App 自己负责余额扣减和返奖入账，HyApp 只保存抽奖审计事实；该 outbox 的职责是给后续 RocketMQ/审计扩展留事实出口。
		return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, s.now().UTC().UnixMilli())
	default:
		return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("unsupported lucky gift outbox event type: %s", event.EventType), false)
	}
}

func (s *Service) processRewardSettlementOutbox(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) error {
	credited, alreadyGranted, receipt, err := s.settleLuckyGiftReward(ctx, event, payload, options)
	if err != nil {
		return err
	}
	if credited && !alreadyGranted {
		// 钱包入账和 activity draw 状态都已经落库；这里的 C2C 只是降低客户端余额刷新延迟，失败时仍由 wallet outbox -> notice-service 补偿。
		if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, options.PublishTimeout); err != nil {
			logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
				slog.String("draw_id", payload.DrawID),
				slog.String("command_id", payload.CommandID),
				slog.String("wallet_transaction_id", receipt.WalletTransactionID),
			)
		}
	}
	if credited && payload.EffectiveRewardCoins > 0 {
		// owner outbox 在钱包入账和 draw=granted 之后才发布中奖事实；Broker 未确认时保持 retryable，
		// 但 alreadyCredited=true 禁止重试耗尽后把已经到账的抽奖覆盖成 failed。
		if err := s.publishLuckyGiftDrawnFact(ctx, payload, receipt); err != nil {
			return s.markOutboxFailed(ctx, event, options, err, true)
		}
	}
	// 只有 Broker 接受中奖事实后才交付 settlement outbox；消费者按 event_id 幂等，因此 publish 成功、ACK 落库失败只会安全重放。
	return s.repository.MarkLuckyGiftOutboxDelivered(ctx, event, s.now().UTC().UnixMilli())
}

func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOutbox, payload luckyGiftDrawnPayload, options WorkerOptions) (bool, bool, luckyGiftRewardReceipt, error) {
	walletTransactionID := ""
	credited := false
	alreadyGranted := false
	receipt := luckyGiftRewardReceipt{}
	if payload.EffectiveRewardCoins > 0 {
		state, err := s.repository.GetLuckyGiftDrawRewardState(ctx, event.AppCode, payload.DrawIDs)
		if err != nil {
			return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
		}
		alreadyGranted = state.AllGranted
		if alreadyGranted {
			walletTransactionID = state.WalletTransactionID
			receipt.WalletTransactionID = state.WalletTransactionID
			receipt.RewardGrantedAtMS = state.RewardGrantedAtMS
			credited = true
		} else {
			var err error
			receipt, err = s.creditLuckyGiftReward(ctx, event.AppCode, payload)
			if err != nil {
				return false, false, receipt, s.markOutboxFailed(ctx, event, options, err, false)
			}
			walletTransactionID = receipt.WalletTransactionID
			credited = true
		}
	}
	if !credited {
		if _, err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
			return false, false, receipt, err
		}
		return false, false, receipt, nil
	}
	if !alreadyGranted {
		grantedAtMS, err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID)
		if err != nil {
			return credited, alreadyGranted, receipt, err
		}
		receipt.RewardGrantedAtMS = grantedAtMS
	}
	return credited, alreadyGranted, receipt, nil
}

type luckyGiftRewardReceipt struct {
	WalletTransactionID string
	CoinBalanceAfter    int64
	CoinFrozenAfter     int64
	BalanceVersion      int64
	// RewardGrantedAtMS 来自 lucky draw 持久状态；同一 event_id 的 Broker 重投必须复用这个时间。
	RewardGrantedAtMS int64
}

func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
	if result.EffectiveRewardCoins <= 0 || result.RewardStatus == domain.StatusGranted {
		return result
	}
	payload := luckyGiftDrawnPayload{
		AppCode:                   appcode.FromContext(ctx),
		DrawID:                    result.DrawID,
		DrawIDs:                   luckyDrawResultIDs(result),
		CommandID:                 cmd.CommandID,
		PoolID:                    result.PoolID,
		RoomID:                    cmd.RoomID,
		GiftID:                    cmd.GiftID,
		GiftCount:                 cmd.GiftCount,
		UserID:                    cmd.UserID,
		SenderUserID:              cmd.UserID,
		TargetUserID:              cmd.TargetUserID,
		SenderName:                cmd.SenderName,
		SenderAvatar:              cmd.SenderAvatar,
		SenderDisplayUserID:       cmd.SenderDisplayUserID,
		SenderPrettyDisplayUserID: cmd.SenderPrettyDisplayUserID,
		VisibleRegionID:           cmd.VisibleRegionID,
		RoomLocked:                cmd.RoomLocked,
		OwnerUserID:               cmd.OwnerUserID,
		CountryID:                 cmd.CountryID,
		CoinSpent:                 cmd.CoinSpent,
		RuleVersion:               result.RuleVersion,
		ExperiencePool:            result.ExperiencePool,
		SelectedTierID:            result.SelectedTierID,
		MultiplierPPM:             result.MultiplierPPM,
		BaseRewardCoins:           result.BaseRewardCoins,
		EffectiveRewardCoins:      result.EffectiveRewardCoins,
		StageFeedback:             result.StageFeedback,
		HighMultiplier:            result.HighMultiplier,
		CreatedAtMS:               result.CreatedAtMS,
	}
	receipt, err := s.creditLuckyGiftReward(ctx, payload.AppCode, payload)
	if err != nil {
		logx.Error(ctx, "lucky_gift_reward_fast_path_failed", err,
			slog.String("draw_id", result.DrawID),
			slog.String("command_id", result.CommandID),
			slog.Int64("reward_coins", result.EffectiveRewardCoins),
		)
		return result
	}
	result.RewardStatus = domain.StatusGranted
	result.WalletTransactionID = receipt.WalletTransactionID
	grantedAtMS, err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID)
	if err != nil {
		logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
			slog.String("draw_id", result.DrawID),
			slog.String("command_id", result.CommandID),
			slog.String("wallet_transaction_id", receipt.WalletTransactionID),
		)
		return result
	}
	receipt.RewardGrantedAtMS = grantedAtMS
	// HTTP/gRPC 抽奖响应不再返回返奖后余额；成功落库后的余额变化通过钱包私有 IM 发送，Flutter 用 balance_after 覆盖本地余额。
	if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, 3*time.Second); err != nil {
		logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
			slog.String("draw_id", result.DrawID),
			slog.String("command_id", result.CommandID),
			slog.String("wallet_transaction_id", receipt.WalletTransactionID),
		)
	}
	return result
}

func (s *Service) publishLuckyGiftDrawnFact(ctx context.Context, payload luckyGiftDrawnPayload, receipt luckyGiftRewardReceipt) error {
	if s.eventPub == nil {
		// 无 MQ 的显式本地隔离模式仍允许验证抽奖/钱包主链路；可提交的 Docker 和线上配置必须启用 owner topic。
		return nil
	}
	drawCreatedAtMS := payload.CreatedAtMS
	if drawCreatedAtMS <= 0 {
		return fmt.Errorf("lucky gift draw created_at_ms is missing")
	}
	rewardGrantedAtMS := receipt.RewardGrantedAtMS
	if rewardGrantedAtMS <= 0 {
		// 拆服前已 granted 的历史行理论上都有 updated_at_ms；若遇到人工旧数据缺时间，只能退化到稳定的抽奖时间，
		// 不能在每次 MQ retry 时重新取 now 造成相同 event_id 对应不同事实正文。
		rewardGrantedAtMS = drawCreatedAtMS
	}
	body, err := proto.Marshal(&luckygifteventsv1.LuckyGiftDrawn{
		DrawId:                    payload.DrawID,
		CommandId:                 payload.CommandID,
		RoomId:                    payload.RoomID,
		PoolId:                    payload.PoolID,
		GiftId:                    payload.GiftID,
		GiftCount:                 payload.GiftCount,
		SenderUserId:              payload.SenderUserID,
		TargetUserId:              payload.TargetUserID,
		SenderName:                payload.SenderName,
		SenderAvatar:              payload.SenderAvatar,
		SenderDisplayUserId:       payload.SenderDisplayUserID,
		SenderPrettyDisplayUserId: payload.SenderPrettyDisplayUserID,
		VisibleRegionId:           payload.VisibleRegionID,
		RoomLocked:                payload.RoomLocked,
		OwnerUserId:               payload.OwnerUserID,
		CountryId:                 payload.CountryID,
		CoinSpent:                 payload.CoinSpent,
		RuleVersion:               payload.RuleVersion,
		ExperiencePool:            payload.ExperiencePool,
		SelectedTierId:            payload.SelectedTierID,
		MultiplierPpm:             payload.MultiplierPPM,
		BaseRewardCoins:           payload.BaseRewardCoins,
		EffectiveRewardCoins:      payload.EffectiveRewardCoins,
		StageFeedback:             payload.StageFeedback,
		HighMultiplier:            payload.HighMultiplier,
		RewardStatus:              domain.StatusGranted,
		WalletTransactionId:       receipt.WalletTransactionID,
		DrawCreatedAtMs:           drawCreatedAtMS,
		RewardGrantedAtMs:         rewardGrantedAtMS,
	})
	if err != nil {
		return err
	}
	eventID := strings.TrimSpace(payload.EventID)
	if eventID == "" {
		eventID = "lucky_gift_drawn:" + payload.DrawID
	}
	return s.eventPub.PublishLuckyGiftEvent(ctx, &luckygifteventsv1.EventEnvelope{
		EventId:      eventID,
		EventType:    luckygiftmq.EventTypeLuckyGiftDrawn,
		AppCode:      payload.AppCode,
		DrawId:       payload.DrawID,
		OccurredAtMs: drawCreatedAtMS,
		Body:         body,
	})
}

func (s *Service) markLuckyGiftDrawsGrantedAfterWallet(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string) (int64, error) {
	baseCtx := context.WithoutCancel(ctx)
	var lastErr error
	for attempt := 0; attempt < 3; attempt++ {
		markCtx, cancel := context.WithTimeout(appcode.WithContext(baseCtx, appCode), 5*time.Second)
		grantedAtMS := s.now().UTC().UnixMilli()
		err := s.repository.MarkLuckyGiftDrawsGranted(markCtx, appCode, drawIDs, walletTransactionID, grantedAtMS)
		cancel()
		if err == nil {
			return grantedAtMS, nil
		}
		lastErr = err
		if !isRetryableLuckyGrantMarkError(err) {
			return 0, err
		}
		time.Sleep(time.Duration(attempt+1) * 50 * time.Millisecond)
	}
	return 0, lastErr
}

func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, payload luckyGiftDrawnPayload) (luckyGiftRewardReceipt, error) {
	if s.wallet == nil {
		return luckyGiftRewardReceipt{}, fmt.Errorf("lucky gift wallet client is not configured")
	}
	resp, err := s.wallet.CreditLuckyGiftReward(appcode.WithContext(ctx, appCode), &walletv1.CreditLuckyGiftRewardRequest{
		CommandId:       "lucky_reward:" + payload.DrawID,
		AppCode:         appCode,
		TargetUserId:    payload.UserID,
		Amount:          payload.EffectiveRewardCoins,
		DrawId:          payload.DrawID,
		RoomId:          payload.RoomID,
		GiftId:          payload.GiftID,
		PoolId:          payload.PoolID,
		Reason:          "lucky_gift_reward",
		VisibleRegionId: payload.VisibleRegionID,
		CountryId:       payload.CountryID,
	})
	if err != nil {
		return luckyGiftRewardReceipt{}, err
	}
	return luckyGiftRewardReceipt{
		WalletTransactionID: resp.GetTransactionId(),
		CoinBalanceAfter:    resp.GetBalance().GetAvailableAmount(),
		CoinFrozenAfter:     resp.GetBalance().GetFrozenAmount(),
		BalanceVersion:      resp.GetBalance().GetVersion(),
	}, nil
}

func (s *Service) publishLuckyGiftWalletBalanceChanged(ctx context.Context, payload luckyGiftDrawnPayload, receipt luckyGiftRewardReceipt, timeout time.Duration) error {
	if s.userPub == nil || payload.EffectiveRewardCoins <= 0 {
		return nil
	}
	receipt.WalletTransactionID = strings.TrimSpace(receipt.WalletTransactionID)
	if receipt.WalletTransactionID == "" {
		return fmt.Errorf("lucky gift wallet transaction id is empty")
	}
	nowMs := s.now().UTC().UnixMilli()
	commandID := "lucky_reward:" + payload.DrawID
	noticePayload, err := json.Marshal(map[string]any{
		"event_type":           "WalletBalanceChanged",
		"event_id":             walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
		"app_code":             payload.AppCode,
		"transaction_id":       receipt.WalletTransactionID,
		"command_id":           commandID,
		"user_id":              fmt.Sprintf("%d", payload.UserID),
		"asset_type":           "COIN",
		"available_delta":      payload.EffectiveRewardCoins,
		"frozen_delta":         0,
		"available_after":      receipt.CoinBalanceAfter,
		"frozen_after":         receipt.CoinFrozenAfter,
		"balance_version":      receipt.BalanceVersion,
		"created_at_ms":        nowMs,
		"source_created_at_ms": nowMs,
		"metadata": map[string]any{
			"transaction_id":    receipt.WalletTransactionID,
			"command_id":        commandID,
			"user_id":           payload.UserID,
			"draw_id":           payload.DrawID,
			"room_id":           payload.RoomID,
			"visible_region_id": payload.VisibleRegionID,
			"region_id":         payload.VisibleRegionID,
			"country_id":        payload.CountryID,
			"gift_id":           payload.GiftID,
			"pool_id":           payload.PoolID,
			"amount":            payload.EffectiveRewardCoins,
			"reason":            "lucky_gift_reward",
			"created_at_ms":     nowMs,
		},
	})
	if err != nil {
		return err
	}
	publishTimeout := timeout
	if publishTimeout <= 0 {
		publishTimeout = 5 * time.Second
	}
	publishCtx, cancel := context.WithTimeout(ctx, publishTimeout)
	defer cancel()
	return s.userPub.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
		ToAccount:   tencentim.FormatUserID(payload.UserID),
		EventID:     walletBalanceChangedEventID(receipt.WalletTransactionID, payload.UserID, "COIN"),
		Desc:        "WalletBalanceChanged",
		Ext:         "wallet_notice",
		PayloadJSON: noticePayload,
	})
}

func walletBalanceChangedEventID(transactionID string, userID int64, assetType string) string {
	hashInput := fmt.Sprintf("%s|%s|%d|%s", strings.TrimSpace(transactionID), "WalletBalanceChanged", userID, strings.TrimSpace(assetType))
	sum := sha256.Sum256([]byte(hashInput))
	return "wev_" + hex.EncodeToString(sum[:])
}

func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox, options WorkerOptions, cause error, alreadyCredited bool) error {
	nowMS := s.now().UTC().UnixMilli()
	nextRetryCount := event.RetryCount + 1
	if nextRetryCount >= options.MaxRetry {
		if !alreadyCredited {
			// 未返奖的 draw 可以整批标记 failed；钱包已返奖时不能覆盖账务事实。
			_ = s.repository.MarkLuckyGiftDrawsFailed(ctx, event.AppCode, drawIDsFromPayload(event.PayloadJSON), cause.Error(), nowMS)
		}
		return s.repository.MarkLuckyGiftOutboxFailed(ctx, event, nextRetryCount, cause.Error(), nowMS)
	}
	// 指数退避降低故障期间对钱包的压力，同时保留 pending 状态给客户端展示“处理中”。
	nextRetryAtMS := nowMS + luckyGiftBackoff(nextRetryCount).Milliseconds()
	return s.repository.MarkLuckyGiftOutboxRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMS)
}

func (s *Service) GetConfig(ctx context.Context, poolID string) (domain.RuleConfig, error) {
	if err := s.requireRepository(); err != nil {
		return domain.RuleConfig{}, err
	}
	poolID = normalizePoolID(poolID)
	config, exists, err := s.repository.GetLuckyGiftRuleConfig(ctx, poolID)
	if err != nil {
		return domain.RuleConfig{}, err
	}
	if !exists {
		// 未配置时返回 disabled v2 草稿，后台可以直接编辑发布；运行抽奖不会读取这个草稿。
		return DefaultRuleConfig(appcode.FromContext(ctx), poolID), nil
	}
	return config, nil
}

func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
	if err := s.requireRepository(); err != nil {
		return domain.RuleConfig{}, err
	}
	config.AppCode = appcode.FromContext(ctx)
	config.PoolID = normalizePoolID(config.PoolID)
	config = normalizeRuleConfig(config)
	if err := validateRuleConfig(config); err != nil {
		return domain.RuleConfig{}, err
	}
	return s.repository.PublishLuckyGiftRuleConfig(ctx, config, s.now().UTC().UnixMilli())
}

func (s *Service) ListConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) {
	if err := s.requireRepository(); err != nil {
		return nil, err
	}
	return s.repository.ListLuckyGiftRuleConfigs(ctx, strings.TrimSpace(appCode))
}

func (s *Service) ListConfigVersions(ctx context.Context, poolID string) ([]domain.RuleConfig, error) {
	if err := s.requireRepository(); err != nil {
		return nil, err
	}
	versionRepository, ok := s.repository.(luckyGiftRuleVersionRepository)
	if !ok {
		return nil, xerr.New(xerr.Unavailable, "lucky gift rule version repository is unavailable")
	}
	poolID = normalizePoolID(poolID)
	if poolID == "" {
		return nil, xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
	}
	return versionRepository.ListLuckyGiftRuleConfigVersions(ctx, poolID)
}

func (s *Service) RollbackConfig(ctx context.Context, poolID string, targetRuleVersion int64, operatorAdminID int64) (domain.RuleConfig, error) {
	if err := s.requireRepository(); err != nil {
		return domain.RuleConfig{}, err
	}
	versionRepository, ok := s.repository.(luckyGiftRuleVersionRepository)
	if !ok {
		return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "lucky gift rule version repository is unavailable")
	}
	poolID = normalizePoolID(poolID)
	if poolID == "" || targetRuleVersion <= 0 || operatorAdminID <= 0 {
		return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift rollback request is incomplete")
	}
	return versionRepository.RollbackLuckyGiftRuleConfig(
		ctx,
		poolID,
		targetRuleVersion,
		operatorAdminID,
		s.now().UTC().UnixMilli(),
	)
}

func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
	if err := s.requireRepository(); err != nil {
		return nil, 0, err
	}
	query = normalizeDrawQuery(query)
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	return s.repository.ListLuckyGiftDraws(ctx, query)
}

func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
	if err := s.requireRepository(); err != nil {
		return domain.DrawSummary{}, err
	}
	return s.repository.GetLuckyGiftDrawSummary(ctx, normalizeDrawQuery(query))
}

// ListDailyStats 是 Databi 的按日聚合读入口；app_code fail-close 必填，避免跨租户读汇总。
func (s *Service) ListDailyStats(ctx context.Context, query domain.DailyStatsQuery) ([]domain.DailyStat, error) {
	if err := s.requireRepository(); err != nil {
		return nil, err
	}
	query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
	if query.AppCode == "" {
		return nil, xerr.New(xerr.InvalidArgument, "lucky gift daily stats app_code is required")
	}
	if strings.TrimSpace(query.PoolID) != "" {
		query.PoolID = normalizePoolID(query.PoolID)
	} else {
		query.PoolID = ""
	}
	query.StrategyVersion = strings.ToLower(strings.TrimSpace(query.StrategyVersion))
	if query.StrategyVersion != "" && query.StrategyVersion != domain.StrategyFixedV2 && query.StrategyVersion != domain.StrategyDynamicV3 {
		return nil, xerr.New(xerr.InvalidArgument, "unsupported lucky gift strategy version")
	}
	if query.StartMS <= 0 || query.EndMS <= query.StartMS {
		return nil, xerr.New(xerr.InvalidArgument, "lucky gift daily stats requires a valid [start_ms, end_ms) range")
	}
	// 读模型按日聚合本身很轻，上限只防御异常调用方传超长窗口拖垮分组。
	const maxDailyStatsRangeMS = 400 * 24 * int64(time.Hour/time.Millisecond)
	if query.EndMS-query.StartMS > maxDailyStatsRangeMS {
		return nil, xerr.New(xerr.InvalidArgument, "lucky gift daily stats range is too large")
	}
	regionIDs := make([]int64, 0, len(query.RegionIDs))
	for _, regionID := range query.RegionIDs {
		if regionID > 0 {
			regionIDs = append(regionIDs, regionID)
		}
	}
	if len(query.RegionIDs) > 0 && len(regionIDs) == 0 {
		// 区域过滤全部非法时不能退化成"不过滤"——那会把受限调用方放大到全量口径。
		return []domain.DailyStat{}, nil
	}
	query.RegionIDs = regionIDs
	return s.repository.ListLuckyGiftDailyStats(ctx, query)
}

func (s *Service) ListUserProfiles(ctx context.Context, query domain.UserProfileQuery) ([]domain.UserProfile, int64, string, bool, int64, error) {
	if err := s.requireRepository(); err != nil {
		return nil, 0, "", false, 0, err
	}
	query, err := normalizeUserProfileQuery(query)
	if err != nil {
		return nil, 0, "", false, 0, err
	}
	snapshotAtMS := s.now().UTC().UnixMilli()
	profiles, total, nextCursor, hasMore, err := s.repository.ListLuckyGiftUserProfiles(ctx, query, snapshotAtMS)
	return profiles, total, nextCursor, hasMore, snapshotAtMS, err
}

func (s *Service) GetUserProfile(ctx context.Context, query domain.UserProfileQuery) (domain.UserProfileDetail, int64, error) {
	if err := s.requireRepository(); err != nil {
		return domain.UserProfileDetail{}, 0, err
	}
	query, err := normalizeUserProfileQuery(query)
	if err != nil {
		return domain.UserProfileDetail{}, 0, err
	}
	// 详情入口必须选择且只选择一种真实身份键；否则空 identity_type 会退化成“取列表第一行”，
	// 错配的另一种 key 还可能让运营打开与请求不一致的用户画像。
	switch query.IdentityType {
	case domain.UserProfileIdentityInternal:
		if query.InternalUserID <= 0 || query.ExternalUserID != "" {
			return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "internal lucky gift profile requires only user_id")
		}
	case domain.UserProfileIdentityExternal:
		if query.ExternalUserID == "" || query.InternalUserID != 0 {
			return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "external lucky gift profile requires only external_user_id")
		}
	default:
		return domain.UserProfileDetail{}, 0, xerr.New(xerr.InvalidArgument, "lucky gift profile identity_type must be internal or external")
	}
	snapshotAtMS := s.now().UTC().UnixMilli()
	detail, found, err := s.repository.GetLuckyGiftUserProfile(ctx, query, snapshotAtMS)
	if err != nil {
		return domain.UserProfileDetail{}, 0, err
	}
	if !found {
		return domain.UserProfileDetail{}, 0, xerr.New(xerr.NotFound, "lucky gift user profile not found")
	}
	return detail, snapshotAtMS, nil
}

func (s *Service) ListPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) {
	if err := s.requireRepository(); err != nil {
		return nil, err
	}
	query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
	if strings.TrimSpace(query.PoolID) != "" {
		query.PoolID = normalizePoolID(query.PoolID)
	}
	query.StrategyVersion = strings.ToLower(strings.TrimSpace(query.StrategyVersion))
	if query.StrategyVersion != "" && query.StrategyVersion != domain.StrategyFixedV2 && query.StrategyVersion != domain.StrategyDynamicV3 {
		return nil, xerr.New(xerr.InvalidArgument, "unsupported lucky gift strategy version")
	}
	return s.repository.ListLuckyGiftPoolBalances(ctx, query)
}

// AdjustPoolBalance 校验管理员资金命令并交给 owner 仓储执行。request_id 只记录链路，
// adjustment_id 才是可跨超时重试的业务幂等键。
func (s *Service) AdjustPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand) (domain.PoolAdjustmentResult, error) {
	if err := s.requireRepository(); err != nil {
		return domain.PoolAdjustmentResult{}, err
	}
	command.AppCode = appcode.FromContext(ctx)
	rawPoolID := strings.TrimSpace(command.PoolID)
	if rawPoolID == "" {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment pool_id is required")
	}
	command.PoolID = normalizePoolID(rawPoolID)
	command.StrategyVersion = strings.ToLower(strings.TrimSpace(command.StrategyVersion))
	command.AdjustmentID = strings.TrimSpace(command.AdjustmentID)
	command.Direction = strings.ToLower(strings.TrimSpace(command.Direction))
	command.Reason = strings.TrimSpace(command.Reason)
	command.RequestID = strings.TrimSpace(command.RequestID)
	if utf8.RuneCountInString(command.AppCode) > 32 || utf8.RuneCountInString(command.PoolID) > 96 {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment app_code or pool_id is too long")
	}
	if command.StrategyVersion != domain.StrategyFixedV2 && command.StrategyVersion != domain.StrategyDynamicV3 {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool strategy_version must be fixed_v2 or dynamic_v3")
	}
	if command.AdjustmentID == "" || utf8.RuneCountInString(command.AdjustmentID) > 128 {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment_id is required and must not exceed 128 characters")
	}
	if command.Direction != domain.PoolAdjustmentIn && command.Direction != domain.PoolAdjustmentOut {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment direction must be in or out")
	}
	if command.AmountCoins <= 0 {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment amount_coins must be positive")
	}
	if command.Reason == "" || utf8.RuneCountInString(command.Reason) > 512 {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment reason is required and must not exceed 512 characters")
	}
	if command.OperatorAdminID <= 0 {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment operator_admin_id must be positive")
	}
	if utf8.RuneCountInString(command.RequestID) > 128 {
		return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment request_id must not exceed 128 characters")
	}
	return s.repository.AdjustLuckyGiftPoolBalance(ctx, command, s.now().UTC().UnixMilli())
}

func (s *Service) requireRepository() error {
	if s == nil || s.repository == nil {
		return xerr.New(xerr.Unavailable, "lucky gift repository is not configured")
	}
	return nil
}

func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
	query.AppCode = strings.TrimSpace(query.AppCode)
	query.PoolID = normalizePoolID(query.PoolID)
	query.GiftID = strings.TrimSpace(query.GiftID)
	query.RoomID = strings.TrimSpace(query.RoomID)
	query.Status = strings.TrimSpace(query.Status)
	query.ExternalUserID = strings.TrimSpace(query.ExternalUserID)
	return query
}

func normalizeUserProfileQuery(query domain.UserProfileQuery) (domain.UserProfileQuery, error) {
	query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
	if query.AppCode == "" {
		return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift user profile app_code is required")
	}
	if strings.TrimSpace(query.PoolID) == "" {
		return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift user profile pool_id is required")
	}
	query.PoolID = normalizePoolID(query.PoolID)
	query.IdentityType = strings.ToLower(strings.TrimSpace(query.IdentityType))
	if query.IdentityType != "" && query.IdentityType != domain.UserProfileIdentityInternal && query.IdentityType != domain.UserProfileIdentityExternal {
		return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "unsupported lucky gift profile identity_type")
	}
	query.ExternalUserID = strings.TrimSpace(query.ExternalUserID)
	if utf8.RuneCountInString(query.ExternalUserID) > 128 {
		return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "external_user_id is too long")
	}
	query.Stage = strings.ToLower(strings.TrimSpace(query.Stage))
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy))
	query.SortDirection = strings.ToLower(strings.TrimSpace(query.SortDirection))
	query.Cursor = strings.TrimSpace(query.Cursor)
	query.JackpotCursor = strings.TrimSpace(query.JackpotCursor)
	if len(query.Cursor) > 2048 || len(query.JackpotCursor) > 2048 {
		return domain.UserProfileQuery{}, xerr.New(xerr.InvalidArgument, "lucky gift profile cursor is too long")
	}
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	if query.JackpotPage <= 0 {
		query.JackpotPage = 1
	}
	if query.JackpotPageSize <= 0 {
		query.JackpotPageSize = 20
	}
	if query.JackpotPageSize > 50 {
		query.JackpotPageSize = 50
	}
	return query, nil
}

type luckyGiftDrawnPayload struct {
	EventType string `json:"event_type"`
	EventID   string `json:"event_id"`
	AppCode   string `json:"app_code"`
	DrawID    string `json:"draw_id"`
	// DrawIDs 是聚合发放的明细集合；老 outbox 没有该字段时 decode 会回填为单个 DrawID。
	DrawIDs                   []string `json:"draw_ids,omitempty"`
	CommandID                 string   `json:"command_id"`
	PoolID                    string   `json:"pool_id"`
	RoomID                    string   `json:"room_id"`
	GiftID                    string   `json:"gift_id"`
	GiftCount                 int32    `json:"gift_count"`
	UserID                    int64    `json:"user_id"`
	SenderUserID              int64    `json:"sender_user_id"`
	TargetUserID              int64    `json:"target_user_id"`
	SenderName                string   `json:"sender_name,omitempty"`
	SenderAvatar              string   `json:"sender_avatar,omitempty"`
	SenderDisplayUserID       string   `json:"sender_display_user_id,omitempty"`
	SenderPrettyDisplayUserID string   `json:"sender_pretty_display_user_id,omitempty"`
	VisibleRegionID           int64    `json:"visible_region_id"`
	RoomLocked                bool     `json:"room_locked"`
	OwnerUserID               int64    `json:"owner_user_id,omitempty"`
	CountryID                 int64    `json:"country_id"`
	CoinSpent                 int64    `json:"coin_spent"`
	RuleVersion               int64    `json:"rule_version"`
	ExperiencePool            string   `json:"experience_pool"`
	SelectedTierID            string   `json:"selected_tier_id"`
	MultiplierPPM             int64    `json:"multiplier_ppm"`
	BaseRewardCoins           int64    `json:"base_reward_coins"`
	EffectiveRewardCoins      int64    `json:"effective_reward_coins"`
	StageFeedback             bool     `json:"stage_feedback"`
	HighMultiplier            bool     `json:"high_multiplier"`
	CreatedAtMS               int64    `json:"created_at_ms"`
}

func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error) {
	var payload luckyGiftDrawnPayload
	if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
		return luckyGiftDrawnPayload{}, fmt.Errorf("invalid lucky gift outbox payload: %w", err)
	}
	// 兼容早期 payload 缺省字段，避免历史 pending outbox 因新增字段永久失败。
	if payload.EventType == "" {
		payload.EventType = "lucky_gift_drawn"
	}
	// 兼容两类 outbox：老单抽 payload 只有 draw_id，新批量 payload 同时有聚合 draw_id 和 draw_ids。
	if payload.DrawID == "" && len(payload.DrawIDs) > 0 {
		payload.DrawID = payload.DrawIDs[0]
	}
	if len(payload.DrawIDs) == 0 && payload.DrawID != "" {
		payload.DrawIDs = []string{payload.DrawID}
	}
	if payload.EventID == "" && payload.DrawID != "" {
		payload.EventID = "lucky_gift_drawn:" + payload.DrawID
	}
	if payload.SenderUserID == 0 {
		payload.SenderUserID = payload.UserID
	}
	// 这些字段是返奖、房间 IM 和幂等去重的最小闭环，缺任何一个都不能安全补偿。
	if payload.DrawID == "" || payload.EventID == "" || payload.RoomID == "" || payload.GiftID == "" || payload.UserID <= 0 {
		return luckyGiftDrawnPayload{}, fmt.Errorf("lucky gift outbox payload is incomplete")
	}
	return payload, nil
}

// drawIDsFromPayload 只用于失败收敛；解析失败时返回 nil，让仓储层保持 no-op，避免误更新未知 draw。
func drawIDsFromPayload(payloadJSON string) []string {
	payload, err := decodeDrawOutboxPayload(payloadJSON)
	if err != nil {
		return nil
	}
	return payload.DrawIDs
}

func luckyDrawResultIDs(result domain.DrawResult) []string {
	if len(result.DrawIDs) > 0 {
		return append([]string(nil), result.DrawIDs...)
	}
	if strings.TrimSpace(result.DrawID) == "" {
		return nil
	}
	return []string{result.DrawID}
}

func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {
	options.WorkerID = strings.TrimSpace(options.WorkerID)
	if options.WorkerID == "" {
		options.WorkerID = "lucky-gift-worker"
	}
	if options.PollInterval <= 0 {
		options.PollInterval = time.Second
	}
	if options.BatchSize <= 0 {
		options.BatchSize = 100
	}
	if options.BatchSize > 500 {
		options.BatchSize = 500
	}
	if options.Concurrency <= 0 {
		options.Concurrency = 4
	}
	if options.Concurrency > options.BatchSize {
		options.Concurrency = options.BatchSize
	}
	if options.LockTTL <= 0 {
		options.LockTTL = 30 * time.Second
	}
	if options.MaxRetry <= 0 {
		options.MaxRetry = 8
	}
	if options.PublishTimeout <= 0 {
		options.PublishTimeout = 5 * time.Second
	}
	if options.StatsInterval <= 0 {
		options.StatsInterval = 10 * time.Minute
	}
	if options.StatsBatchSize <= 0 {
		options.StatsBatchSize = 5000
	}
	if options.StatsBatchSize > 50000 {
		options.StatsBatchSize = 50000
	}
	return options
}

func luckyGiftBackoff(retryCount int) time.Duration {
	if retryCount < 1 {
		retryCount = 1
	}
	if retryCount > 6 {
		retryCount = 6
	}
	return time.Duration(1<<retryCount) * time.Second
}

func isRetryableLuckyGrantMarkError(err error) bool {
	if err == nil {
		return false
	}
	message := err.Error()
	return strings.Contains(message, "Deadlock") ||
		strings.Contains(message, "1213") ||
		strings.Contains(message, "40001") ||
		strings.Contains(message, "Lock wait timeout")
}
