// Package broadcast 实现 activity-service 拥有的腾讯云 IM 播报能力。
// 该包只处理群生命周期、播报入队、outbox 投递和 room 事件消费策略，不拥有房间状态和资金事实。
package broadcast

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"sort"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/imgroup"
	"hyapp/pkg/logx"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/xerr"
	broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
	"hyapp/services/activity-service/internal/service/rewardnotice"
)

const (
	defaultBroadcastBatchSize = 100
	defaultBroadcastLockTTL   = 30 * time.Second
	defaultBroadcastMaxRetry  = 8
	defaultGameWinMinCoins    = int64(100000)
	defaultGameWinCacheTTL    = 5 * time.Second
)

// Repository 是播报消息的持久化 outbox 边界。
// service 只通过这个接口写入/抢占/标记记录，确保腾讯 REST 失败后仍可重试和人工补偿。
type Repository interface {
	SaveBroadcastOutbox(ctx context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error)
	ClaimPendingBroadcastOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, maxRetry int) ([]broadcastdomain.OutboxRecord, error)
	MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error
	MarkBroadcastOutboxFailed(ctx context.Context, eventID string, workerID string, nowMS int64, nextRetryAtMS int64, maxRetry int, lastError string) error
}

// Publisher 隐藏腾讯云 IM REST 细节，只暴露 activity-service 真正拥有的建群和发群自定义消息能力。
type Publisher interface {
	EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
	PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
	DeleteGroupMember(ctx context.Context, groupID string, userID int64) error
}

// RegionSource 从 user-service 读取 active 区域。
// 区域归属是用户主数据，activity-service 不复制区域配置，只在建群 reconciler 中读取。
type RegionSource interface {
	ListActiveRegionIDs(ctx context.Context) ([]int64, error)
}

// SenderProfileSource 只读取播报 IM 展示所需的公开资料快照。
// 资金和红包状态仍来自 wallet outbox，用户资料 owner 仍是 user-service。
type SenderProfileSource interface {
	GetSenderProfile(ctx context.Context, userID int64) (SenderProfile, error)
}

// RoomSnapshotSource 只读取 game_win 点击入口需要的房间展示快照。
// 锁房状态仍由 Room Cell 拥有，activity-service 不复制密码哈希或房间核心状态。
type RoomSnapshotSource interface {
	GetRoomBroadcastSnapshot(ctx context.Context, roomID string) (RoomBroadcastSnapshot, error)
}

type RoomBroadcastSnapshot struct {
	RoomID          string
	Title           string
	Locked          bool
	OwnerUserID     int64
	VisibleRegionID int64
	Status          string
}

// GameWinConfigRepository 是游戏中奖飘屏策略的独立持久化边界。
// 它不并入通用 outbox Repository，避免测试或离线投递器被迫实现无关配置接口。
type GameWinConfigRepository interface {
	GetGameWinBroadcastConfig(ctx context.Context) (broadcastdomain.GameWinConfig, bool, error)
	UpsertGameWinBroadcastConfig(ctx context.Context, config broadcastdomain.GameWinConfig) (broadcastdomain.GameWinConfig, error)
}

// SenderProfile 是播报 IM payload 的发送者展示字段。
// activity-service 不持久化该资料，只在消费事实时组装一次播报消息。
type SenderProfile struct {
	UserID              int64
	Account             string
	PrettyDisplayUserID string
	Nickname            string
	Avatar              string
	AvatarFrameURL      string
	RegionCode          string
}

// Config 保存播报策略和 worker 参数。
// SuperGiftMinValue 是产品策略阈值；Worker* 是 outbox 投递吞吐和故障恢复参数。
type Config struct {
	NodeID string
	// AppCodes 是共享腾讯 IM 应用内需要维护的业务租户；群名和 outbox 仍按 app_code 隔离。
	AppCodes          []string
	SuperGiftMinValue int64
	// GameWinMinCoins 仅作为未创建 App 配置行时的启用默认值；后台保存后以 MySQL 配置为准。
	GameWinMinCoins       int64
	GameWinConfigCacheTTL time.Duration
	WorkerBatchSize       int
	WorkerLockTTL         time.Duration
	WorkerMaxRetry        int
	WorkerPollInterval    time.Duration
	EnsureGroupsOnStartup bool
	// EnsureGroupsInterval 是常驻 worker 内的定时建群 reconcile 周期；<=0 表示只在启动时补偿一次。
	// 新区域上线、启动时腾讯 REST 抖动都靠这个周期兜底，不再依赖人工调用 gRPC 补群。
	EnsureGroupsInterval time.Duration
	GroupIDPrefix        string
}

// PublishInput 是服务端入队播报请求。
// 调用方必须提供稳定 EventID；重复请求会命中 outbox 幂等，而不是重复发 IM。
type PublishInput struct {
	EventID       string
	BroadcastType string
	RegionID      int64
	PayloadJSON   string
	DeliverAtMS   int64
}

// Service 拥有播报群建群补偿、播报入队和 outbox 投递决策。
// 它不直接向客户端返回 UI 文案，只生产带 action 的结构化 JSON，让客户端自行决定展示形态。
type Service struct {
	cfg                 Config
	repository          Repository
	publisher           Publisher
	regionSource        RegionSource
	senderProfileSource SenderProfileSource
	roomSnapshotSource  RoomSnapshotSource
	gameWinConfigRepo   GameWinConfigRepository
	gameWinConfigMu     sync.RWMutex
	gameWinConfigCache  map[string]cachedGameWinConfig
	rewardNotices       rewardNoticeNotifier
	now                 func() time.Time
	// outboxClaimCursor 轮转跨 App claim 起点，避免默认 lalu 的持续积压饿死其他租户播报。
	outboxClaimCursor atomic.Uint64
}

// rewardNoticeNotifier keeps room broadcast independent from the concrete inbox implementation.
type rewardNoticeNotifier interface {
	NotifyReward(ctx context.Context, command rewardnotice.Command) error
}

// New 创建播报服务。
// publisher 可以为 nil，此时仍允许消费事件并写 outbox，但真正投递会 fail-closed；本地环境默认不误发腾讯云消息。
func New(cfg Config, repository Repository, publisher Publisher, regionSource RegionSource) *Service {
	cfg = normalizeConfig(cfg)
	service := &Service{
		cfg:                cfg,
		repository:         repository,
		publisher:          publisher,
		regionSource:       regionSource,
		gameWinConfigCache: make(map[string]cachedGameWinConfig),
		now:                time.Now,
	}
	if configRepo, ok := repository.(GameWinConfigRepository); ok {
		service.gameWinConfigRepo = configRepo
	}
	return service
}

// SetClock 注入确定性时间，测试中用它验证 payload 和 outbox 的 Unix ms 字段。
func (s *Service) SetClock(now func() time.Time) {
	if now != nil {
		s.now = now
	}
}

// SetSenderProfileSource 注入 user-service 资料读取边界。
// 测试或离线环境可以不注入，此时红包 IM payload 仍会带 user_id/account fallback。
func (s *Service) SetSenderProfileSource(source SenderProfileSource) {
	if s != nil {
		s.senderProfileSource = source
	}
}

// SetRoomSnapshotSource 注入 Room Cell 的只读展示快照边界；有 room_id 的 game_win 在缺少该依赖时拒绝生成错误锁房字段。
func (s *Service) SetRoomSnapshotSource(source RoomSnapshotSource) {
	if s != nil {
		s.roomSnapshotSource = source
	}
}

// SetRewardNoticeNotifier lets RoomRocketRewardGranted create one private activity message per winner.
func (s *Service) SetRewardNoticeNotifier(notices rewardNoticeNotifier) {
	if s != nil {
		s.rewardNotices = notices
	}
}

// EnsureBroadcastGroups 幂等创建全局播报群和所有 active 区域播报群。
// 这个动作放在启动/定时 reconciler，不放在 /im/usersig 路径，避免用户登录被外部腾讯 REST 拖慢。
// 单个群创建失败不中断其余群的补偿：全局群配额问题不应让所有区域群一起缺失，错误聚合后返回。
func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
	if s == nil || s.publisher == nil {
		return 0, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
	}
	app := appcode.FromContext(ctx)
	count := 0
	var errs []error
	globalID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app)
	if err != nil {
		return 0, xerr.New(xerr.InvalidArgument, err.Error())
	}
	if err := s.publisher.EnsureGroup(ctx, s.broadcastGroupSpec(globalID)); err != nil {
		errs = append(errs, fmt.Errorf("ensure global group %s: %w", globalID, err))
	} else {
		count++
	}

	if s.regionSource == nil {
		// 没有区域源时至少保证全局群存在；区域群等待下次有 user-service 依赖的 reconciler 补齐。
		return count, errors.Join(errs...)
	}
	regionIDs, err := s.regionSource.ListActiveRegionIDs(ctx)
	if err != nil {
		errs = append(errs, fmt.Errorf("list active regions: %w", err))
		return count, errors.Join(errs...)
	}
	for _, regionID := range regionIDs {
		if regionID <= 0 {
			// GLOBAL/未知区域不对应 IM 区域群，避免客户端加入一个语义不清的“0 区域群”。
			continue
		}
		groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app, regionID)
		if err != nil {
			errs = append(errs, fmt.Errorf("region %d group_id: %w", regionID, err))
			continue
		}
		if err := s.publisher.EnsureGroup(ctx, s.broadcastGroupSpec(groupID)); err != nil {
			errs = append(errs, fmt.Errorf("ensure region group %s: %w", groupID, err))
			continue
		}
		count++
	}
	return count, errors.Join(errs...)
}

// PublishRegionBroadcast 把区域播报写入持久化 outbox。
// 这里不直接调用腾讯 REST，保证调用方提交成功后即使进程崩溃也能由 worker 继续投递。
func (s *Service) PublishRegionBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) {
	if input.RegionID <= 0 {
		return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "region_id is required")
	}
	groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), input.RegionID)
	if err != nil {
		return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
	}
	return s.enqueue(ctx, broadcastdomain.ScopeRegion, groupID, input)
}

func (s *Service) PublishRoomBroadcast(ctx context.Context, roomID string, input PublishInput) (broadcastdomain.PublishResult, error) {
	roomID = strings.TrimSpace(roomID)
	if roomID == "" {
		return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "room_id is required")
	}
	return s.enqueue(ctx, broadcastdomain.ScopeRoom, roomID, input)
}

// PublishGlobalBroadcast 把全局播报写入持久化 outbox。
// 全局播报和区域播报共用同一张表，scope/group_id 决定最终投递目标。
func (s *Service) PublishGlobalBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) {
	groupID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx))
	if err != nil {
		return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
	}
	return s.enqueue(ctx, broadcastdomain.ScopeGlobal, groupID, input)
}

// RemoveRegionBroadcastMember 把一个用户从旧区域播报群移除。
// 这是成员关系更新，不是区域群生命周期操作；区域群仍由 reconciler 长期维护，其他用户不受影响。
func (s *Service) RemoveRegionBroadcastMember(ctx context.Context, userID int64, regionID int64) (string, bool, error) {
	if userID <= 0 {
		return "", false, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	if regionID <= 0 {
		// GLOBAL/无区域没有对应区域播报群，调用方跨区域比较时可以安全把 0 传进来。
		return "", false, nil
	}
	if s == nil || s.publisher == nil {
		return "", false, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
	}
	groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), regionID)
	if err != nil {
		return "", false, xerr.New(xerr.InvalidArgument, err.Error())
	}
	if err := s.publisher.DeleteGroupMember(ctx, groupID, userID); err != nil {
		var restErr tencentim.RESTError
		if errors.As(err, &restErr) && restErr.Code == 10007 && strings.Contains(strings.ToLower(restErr.Info), "disable kick member") {
			// 区域播报群是 AVChatRoom，腾讯 IM 明确不允许服务端踢成员。用户换区依靠客户端
			// 离开旧群并加入新群；这个永久能力拒绝不能重试，否则每条 UserRegionChanged
			// 都会重投到 DLQ。removed=false 保留真实结果，nil 让 MQ 确认该读模型操作已降级完成。
			return groupID, false, nil
		}
		return groupID, false, err
	}
	// 过渡期老客户端可能仍在 v1 旧区域群里，切区时一并尽力移除；
	// 用户不在旧群或旧群不存在都会报错，不能因此阻断已成功的 v2 移除主流程。
	if legacyID, legacyErr := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), regionID); legacyErr == nil && legacyID != groupID {
		_ = s.publisher.DeleteGroupMember(ctx, legacyID, userID)
	}
	return groupID, true, nil
}

func (s *Service) enqueue(ctx context.Context, scope string, groupID string, input PublishInput) (broadcastdomain.PublishResult, error) {
	if s == nil || s.repository == nil {
		return broadcastdomain.PublishResult{}, xerr.New(xerr.Unavailable, "broadcast repository is not configured")
	}
	input.EventID = strings.TrimSpace(input.EventID)
	input.BroadcastType = strings.TrimSpace(input.BroadcastType)
	if input.EventID == "" || input.BroadcastType == "" {
		return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "event_id and broadcast_type are required")
	}
	nowMS := s.now().UTC().UnixMilli()
	payloadJSON, err := normalizedPayloadJSON(input.PayloadJSON, map[string]any{
		"event_id":       input.EventID,
		"broadcast_type": input.BroadcastType,
		"scope":          scope,
		"app_code":       appcode.FromContext(ctx),
		"region_id":      input.RegionID,
		"sent_at_ms":     nowMS,
	})
	if err != nil {
		return broadcastdomain.PublishResult{}, err
	}
	// outbox 记录保存的是完整 JSON，而不是只保存引用 ID；客户端收到 IM 后无需再同步请求才能完成入口跳转。
	record := broadcastdomain.OutboxRecord{
		AppCode:       appcode.FromContext(ctx),
		EventID:       input.EventID,
		Scope:         scope,
		GroupID:       groupID,
		BroadcastType: input.BroadcastType,
		PayloadJSON:   payloadJSON,
		Status:        broadcastdomain.StatusPending,
		NextRetryAtMS: nowMS,
		CreatedAtMS:   nowMS,
		UpdatedAtMS:   nowMS,
	}
	if input.DeliverAtMS > nowMS {
		record.NextRetryAtMS = input.DeliverAtMS
	}
	created, saved, err := s.repository.SaveBroadcastOutbox(ctx, record)
	if err != nil {
		return broadcastdomain.PublishResult{}, err
	}
	return broadcastdomain.PublishResult{EventID: saved.EventID, GroupID: saved.GroupID, Status: saved.Status, Created: created}, nil
}

// ProcessPendingBroadcasts 抢占一批待投递 outbox，发送到腾讯 IM，并更新成功/重试状态。
// claim 和 mark 分开做：抢占阶段保护多 worker 并发，发送阶段允许单条失败不影响同批其他消息。
func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOptions) (broadcastdomain.ProcessResult, error) {
	if s == nil || s.repository == nil {
		return broadcastdomain.ProcessResult{}, xerr.New(xerr.Unavailable, "broadcast repository is not configured")
	}
	if s.publisher == nil {
		return broadcastdomain.ProcessResult{}, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
	}
	options = normalizeWorkerOptions(options, s.cfg)
	nowMS := s.now().UTC().UnixMilli()
	records, err := s.claimPendingBroadcasts(ctx, options, nowMS)
	if err != nil {
		return broadcastdomain.ProcessResult{}, err
	}
	result := broadcastdomain.ProcessResult{ClaimedCount: len(records), HasMore: len(records) >= options.BatchSize}
	for _, record := range records {
		recordCtx := appcode.WithContext(ctx, record.AppCode)
		// 腾讯 REST 只接受已序列化 JSON；event_id/desc/ext 让客户端和日志都能快速识别消息类型。
		err := s.publisher.PublishGroupCustomMessage(recordCtx, tencentim.CustomGroupMessage{
			GroupID:     record.GroupID,
			EventID:     record.EventID,
			Desc:        record.BroadcastType,
			Ext:         "im_broadcast",
			PayloadJSON: json.RawMessage(record.PayloadJSON),
		})
		if err != nil {
			result.FailureCount++
			// 单条投递失败必须落日志：区域群缺失/满员这类腾讯侧错误此前只写 last_error 列，
			// 重试烧完后记录无声沉底为 failed，线上排障只能翻表。attempt 达到上限时标记 final 便于告警。
			logx.Error(recordCtx, "broadcast_outbox_deliver_failed", err,
				slog.String("event_id", record.EventID),
				slog.String("group_id", record.GroupID),
				slog.String("scope", record.Scope),
				slog.String("broadcast_type", record.BroadcastType),
				slog.Int("attempt_count", record.AttemptCount+1),
				slog.Int("max_retry", options.MaxRetry),
				slog.Bool("final", record.AttemptCount+1 >= options.MaxRetry))
			// Mark 失败只记录为 best-effort：当前记录锁会过期，下一轮 worker 仍可重新接管。
			_ = s.repository.MarkBroadcastOutboxFailed(recordCtx, record.EventID, options.WorkerID, s.now().UTC().UnixMilli(), s.now().UTC().Add(options.PollInterval).UnixMilli(), options.MaxRetry, trimError(err.Error()))
			continue
		}
		if err := s.repository.MarkBroadcastOutboxDelivered(recordCtx, record.EventID, s.now().UTC().UnixMilli()); err != nil {
			return result, err
		}
		result.SuccessCount++
		s.dualSendLegacyBroadcast(recordCtx, record)
	}
	return result, nil
}

type broadcastOutboxAppCodeLister interface {
	ListBroadcastOutboxAppCodes(ctx context.Context) ([]string, error)
}

func (s *Service) claimPendingBroadcasts(ctx context.Context, options WorkerOptions, nowMS int64) ([]broadcastdomain.OutboxRecord, error) {
	appCodes, err := s.broadcastOutboxAppCodes(ctx)
	if err != nil || len(appCodes) == 0 {
		return nil, err
	}
	start := int(s.outboxClaimCursor.Add(1)-1) % len(appCodes)
	ordered := make([]string, 0, len(appCodes))
	for offset := 0; offset < len(appCodes); offset++ {
		ordered = append(ordered, appCodes[(start+offset)%len(appCodes)])
	}

	// 第一轮给每个 App 公平配额；若有 App 无积压，第二轮再把剩余额度让给有积压的 App，兼顾隔离和吞吐。
	records := make([]broadcastdomain.OutboxRecord, 0, options.BatchSize)
	quota := (options.BatchSize + len(ordered) - 1) / len(ordered)
	for pass := 0; pass < 2 && len(records) < options.BatchSize; pass++ {
		for _, appCode := range ordered {
			remaining := options.BatchSize - len(records)
			if remaining <= 0 {
				break
			}
			limit := remaining
			if pass == 0 && limit > quota {
				limit = quota
			}
			claimed, err := s.repository.ClaimPendingBroadcastOutbox(appcode.WithContext(ctx, appCode), options.WorkerID, nowMS, nowMS+options.LockTTL.Milliseconds(), limit, options.MaxRetry)
			if err != nil {
				return nil, err
			}
			records = append(records, claimed...)
		}
	}
	return records, nil
}

func (s *Service) broadcastOutboxAppCodes(ctx context.Context) ([]string, error) {
	// 配置清单保证零 outbox 的新 App 也会建群；数据库发现继续兜底历史或灰度 App，避免漏投已有记录。
	appCodes := append([]string(nil), s.cfg.AppCodes...)
	appCodes = append(appCodes, appcode.FromContext(ctx))
	if lister, ok := s.repository.(broadcastOutboxAppCodeLister); ok {
		discovered, err := lister.ListBroadcastOutboxAppCodes(ctx)
		if err != nil {
			return nil, err
		}
		appCodes = append(appCodes, discovered...)
	}
	seen := make(map[string]struct{}, len(appCodes))
	normalized := make([]string, 0, len(appCodes))
	for _, appCode := range appCodes {
		if strings.TrimSpace(appCode) == "" {
			continue
		}
		appCode = appcode.Normalize(appCode)
		if _, exists := seen[appCode]; exists {
			continue
		}
		seen[appCode] = struct{}{}
		normalized = append(normalized, appCode)
	}
	sort.Strings(normalized)
	return normalized, nil
}

// dualSendLegacyBroadcast 在播报群从 v1(ChatRoom) 切到 v2(AVChatRoom) 的过渡期内，
// 把已成功投递到 v2 群的全局/区域播报补发一份到 v1 旧群：老客户端在重新拉取 join 列表前
// 仍停留在旧群里。补发尽力而为，失败不影响主投递的成功状态。
// 单个用户不会重复收到：新登录只加入 v2 群，存量会话只在 v1 群。
func (s *Service) dualSendLegacyBroadcast(ctx context.Context, record broadcastdomain.OutboxRecord) {
	parsed := imgroup.ParseWithPrefix(record.GroupID, s.cfg.GroupIDPrefix)
	var legacyID string
	var err error
	switch parsed.Kind {
	case imgroup.KindGlobalBroadcast:
		legacyID, err = imgroup.LegacyGlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode)
	case imgroup.KindRegionBroadcast:
		legacyID, err = imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID)
	default:
		return
	}
	// legacyID == record.GroupID 说明这条记录本身就是迁移前入队的 v1 群消息，无需再补发。
	if err != nil || legacyID == record.GroupID {
		return
	}
	_ = s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
		GroupID:     legacyID,
		EventID:     record.EventID + "_legacy",
		Desc:        record.BroadcastType,
		Ext:         "im_broadcast",
		PayloadJSON: json.RawMessage(record.PayloadJSON),
	})
}

// HandleRoomEvent 把已提交的 room outbox 事实转换为服务端播报。
// 礼物、火箭和锁房只使用 room outbox 中的稳定事实，避免把展示策略侵入 Room Cell 主链路。
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
	if envelope == nil {
		return broadcastdomain.ConsumeRoomEventResult{}, xerr.New(xerr.InvalidArgument, "room event envelope is required")
	}
	eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
	result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
	if envelope.GetEventType() == "RoomRocketIgnited" {
		return s.handleRoomRocketIgnited(eventCtx, envelope)
	}
	if envelope.GetEventType() == "RoomRocketRewardGranted" {
		return s.handleRoomRocketRewardGranted(eventCtx, envelope)
	}
	if envelope.GetEventType() == "RoomPasswordChanged" {
		return s.handleRoomPasswordChanged(eventCtx, envelope)
	}
	if envelope.GetEventType() != "RoomGiftSent" {
		// 当前只消费礼物、房间火箭和锁房事实；未来运营事件应显式增加事件类型分支和测试。
		return result, nil
	}
	var gift roomeventsv1.RoomGiftSent
	if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	if gift.GetIsRobotGift() {
		return result, nil
	}
	if gift.GetVisibleRegionId() <= 0 || !s.shouldBroadcastGift(&gift) {
		// visible_region_id 来自房间/主播可见区域，不能用客户端当前 IP 或本地时区推断。
		return result, nil
	}
	broadcastEventID := superGiftBroadcastEventID(envelope, &gift)
	// 区域播报需要携带双方展示资料；资料服务不可用时返回错误，避免发出缺头像昵称的误导性大礼消息。
	senderProfile, err := s.broadcastUserProfile(ctx, gift.GetSenderUserId())
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	receiverProfile, err := s.broadcastUserProfile(ctx, gift.GetTargetUserId())
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	payloadJSON, err := superGiftPayload(envelope, &gift, broadcastEventID, s.now().UTC().UnixMilli(), senderProfile, receiverProfile)
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	published, err := s.PublishRegionBroadcast(eventCtx, PublishInput{
		EventID:       broadcastEventID,
		BroadcastType: broadcastdomain.TypeSuperGift,
		RegionID:      gift.GetVisibleRegionId(),
		PayloadJSON:   payloadJSON,
	})
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	// 返回上游 event_id 和下游 broadcast_event_id，方便排查“房间事件已消费但播报是否生成”。
	return broadcastdomain.ConsumeRoomEventResult{
		EventID:          envelope.GetEventId(),
		Status:           published.Status,
		BroadcastEventID: published.EventID,
		BroadcastCreated: published.Created,
	}, nil
}

func (s *Service) shouldBroadcastGift(gift *roomeventsv1.RoomGiftSent) bool {
	if gift == nil {
		return false
	}
	// 老规则仍按礼物总价值触发；新规则额外允许后台显式标记 global_broadcast 的低价礼物触发区域播报。
	if gift.GetGiftValue() >= s.cfg.SuperGiftMinValue {
		return true
	}
	for _, effectType := range gift.GetGiftEffectTypes() {
		if strings.TrimSpace(strings.ToLower(effectType)) == "global_broadcast" {
			return true
		}
	}
	return false
}

func (s *Service) broadcastUserProfile(ctx context.Context, userID int64) (SenderProfile, error) {
	profile := SenderProfile{UserID: userID}
	// 没有 user_id 或未注入资料源时保留 user_id 兜底，保证测试和无资料源部署不会阻断原有播报链路。
	if userID <= 0 || s.senderProfileSource == nil {
		return profile, nil
	}
	resolved, err := s.senderProfileSource.GetSenderProfile(ctx, userID)
	if err != nil {
		return SenderProfile{}, err
	}
	if resolved.UserID <= 0 {
		resolved.UserID = userID
	}
	return resolved, nil
}

func (s *Service) handleRoomPasswordChanged(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
	result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
	var event roomeventsv1.RoomPasswordChanged
	if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	if event.GetVisibleRegionId() <= 0 {
		// 区域通知必须有明确区域，不能把无区域房间退化成全局播报扩大影响面。
		return result, nil
	}
	broadcastEventID := roomPasswordChangedBroadcastEventID(envelope)
	payloadJSON, err := roomPasswordChangedPayload(envelope, &event, broadcastEventID, s.now().UTC().UnixMilli())
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	published, err := s.PublishRegionBroadcast(ctx, PublishInput{
		EventID:       broadcastEventID,
		BroadcastType: broadcastdomain.TypeRoomPasswordChanged,
		RegionID:      event.GetVisibleRegionId(),
		PayloadJSON:   payloadJSON,
	})
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	return broadcastdomain.ConsumeRoomEventResult{
		EventID:          envelope.GetEventId(),
		Status:           published.Status,
		BroadcastEventID: published.EventID,
		BroadcastCreated: published.Created,
	}, nil
}

func (s *Service) handleRoomRocketIgnited(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
	result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
	var rocket roomeventsv1.RoomRocketIgnited
	if err := proto.Unmarshal(envelope.GetBody(), &rocket); err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	switch rocket.GetBroadcastScope() {
	case broadcastdomain.ScopeRegion:
		if rocket.GetVisibleRegionId() <= 0 {
			return result, nil
		}
	case broadcastdomain.ScopeGlobal:
	default:
		return result, nil
	}

	broadcastEventID := roomRocketBroadcastEventID(envelope, &rocket)
	igniterProfile := SenderProfile{UserID: rocket.GetIgniterUserId()}
	if rocket.GetIgniterUserId() > 0 && s.senderProfileSource != nil {
		profile, err := s.senderProfileSource.GetSenderProfile(ctx, rocket.GetIgniterUserId())
		if err != nil {
			return broadcastdomain.ConsumeRoomEventResult{}, err
		}
		igniterProfile = profile
	}
	payloadJSON, err := roomRocketPayload(envelope, &rocket, broadcastEventID, s.now().UTC().UnixMilli(), igniterProfile)
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	var published broadcastdomain.PublishResult
	if rocket.GetBroadcastScope() == broadcastdomain.ScopeGlobal {
		published, err = s.PublishGlobalBroadcast(ctx, PublishInput{
			EventID:       broadcastEventID,
			BroadcastType: broadcastdomain.TypeRoomRocket,
			PayloadJSON:   payloadJSON,
		})
	} else {
		published, err = s.PublishRegionBroadcast(ctx, PublishInput{
			EventID:       broadcastEventID,
			BroadcastType: broadcastdomain.TypeRoomRocket,
			RegionID:      rocket.GetVisibleRegionId(),
			PayloadJSON:   payloadJSON,
		})
	}
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	return broadcastdomain.ConsumeRoomEventResult{
		EventID:          envelope.GetEventId(),
		Status:           published.Status,
		BroadcastEventID: published.EventID,
		BroadcastCreated: published.Created,
	}, nil
}

func (s *Service) handleRoomRocketRewardGranted(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
	result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
	var event roomeventsv1.RoomRocketRewardGranted
	if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	if envelope.GetRoomId() == "" || event.GetRocketId() == "" || len(event.GetRewards()) == 0 {
		return result, nil
	}

	profiles := make(map[int64]SenderProfile, len(event.GetRewards()))
	if s.senderProfileSource != nil {
		for _, reward := range event.GetRewards() {
			userID := reward.GetUserId()
			if userID <= 0 {
				continue
			}
			if _, exists := profiles[userID]; exists {
				continue
			}
			profile, err := s.senderProfileSource.GetSenderProfile(ctx, userID)
			if err != nil {
				return broadcastdomain.ConsumeRoomEventResult{}, err
			}
			profiles[userID] = profile
		}
	}

	broadcastEventID := roomRocketRewardBroadcastEventID(envelope, &event)
	payloadJSON, err := roomRocketRewardPayload(envelope, &event, broadcastEventID, s.now().UTC().UnixMilli(), profiles)
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	published, err := s.PublishRoomBroadcast(ctx, envelope.GetRoomId(), PublishInput{
		EventID:       broadcastEventID,
		BroadcastType: broadcastdomain.TypeRoomRocketReward,
		PayloadJSON:   payloadJSON,
	})
	if err != nil {
		return broadcastdomain.ConsumeRoomEventResult{}, err
	}
	if s.rewardNotices != nil {
		grantIDsByUser := make(map[int64][]string)
		for _, reward := range event.GetRewards() {
			if reward.GetUserId() <= 0 || reward.GetStatus() != "succeeded" || strings.TrimSpace(reward.GetGrantId()) == "" {
				continue
			}
			grantIDsByUser[reward.GetUserId()] = append(grantIDsByUser[reward.GetUserId()], reward.GetGrantId())
		}
		for userID, grantIDs := range grantIDsByUser {
			// 同一用户可能同时是 top1 和点火人；合并全部 grant 后只发一条横向奖励列表消息。
			if noticeErr := s.rewardNotices.NotifyReward(ctx, rewardnotice.Command{
				Activity:        rewardnotice.RoomRocket,
				TargetUserID:    userID,
				ProducerEventID: envelope.GetEventId() + ":" + fmt.Sprintf("%d", userID),
				AggregateType:   "room_rocket",
				AggregateID:     event.GetRocketId(),
				GrantIDs:        grantIDs,
				SentAtMS:        envelope.GetOccurredAtMs(),
				Metadata: map[string]any{
					"rocket_id": event.GetRocketId(),
					"level":     event.GetLevel(),
					"room_id":   envelope.GetRoomId(),
				},
			}); noticeErr != nil {
				return broadcastdomain.ConsumeRoomEventResult{}, noticeErr
			}
		}
	}
	return broadcastdomain.ConsumeRoomEventResult{
		EventID:          envelope.GetEventId(),
		Status:           published.Status,
		BroadcastEventID: published.EventID,
		BroadcastCreated: published.Created,
	}, nil
}

// WorkerOptions 描述一次 worker 批处理请求；cron 和常驻 worker 都走同一套参数归一化。
type WorkerOptions struct {
	WorkerID     string
	BatchSize    int
	LockTTL      time.Duration
	MaxRetry     int
	PollInterval time.Duration
}

func normalizeConfig(cfg Config) Config {
	cfg.NodeID = strings.TrimSpace(cfg.NodeID)
	if cfg.NodeID == "" {
		cfg.NodeID = "activity-broadcast"
	}
	if cfg.SuperGiftMinValue <= 0 {
		cfg.SuperGiftMinValue = 100000
	}
	if cfg.GameWinMinCoins <= 0 {
		cfg.GameWinMinCoins = defaultGameWinMinCoins
	}
	if cfg.GameWinConfigCacheTTL <= 0 {
		cfg.GameWinConfigCacheTTL = defaultGameWinCacheTTL
	}
	if cfg.WorkerBatchSize <= 0 {
		cfg.WorkerBatchSize = defaultBroadcastBatchSize
	}
	if cfg.WorkerLockTTL <= 0 {
		cfg.WorkerLockTTL = defaultBroadcastLockTTL
	}
	if cfg.WorkerMaxRetry <= 0 {
		cfg.WorkerMaxRetry = defaultBroadcastMaxRetry
	}
	if cfg.WorkerPollInterval <= 0 {
		cfg.WorkerPollInterval = time.Second
	}
	seenApps := make(map[string]struct{}, len(cfg.AppCodes))
	normalizedApps := make([]string, 0, len(cfg.AppCodes))
	for _, value := range cfg.AppCodes {
		if strings.TrimSpace(value) == "" {
			continue
		}
		value = appcode.Normalize(value)
		if _, exists := seenApps[value]; exists {
			continue
		}
		seenApps[value] = struct{}{}
		normalizedApps = append(normalizedApps, value)
	}
	cfg.AppCodes = normalizedApps
	return cfg
}

func normalizeWorkerOptions(options WorkerOptions, cfg Config) WorkerOptions {
	if strings.TrimSpace(options.WorkerID) == "" {
		options.WorkerID = cfg.NodeID + "-broadcast"
	}
	if options.BatchSize <= 0 {
		options.BatchSize = cfg.WorkerBatchSize
	}
	if options.LockTTL <= 0 {
		options.LockTTL = cfg.WorkerLockTTL
	}
	if options.MaxRetry <= 0 {
		options.MaxRetry = cfg.WorkerMaxRetry
	}
	if options.PollInterval <= 0 {
		options.PollInterval = cfg.WorkerPollInterval
	}
	return options
}

func (s *Service) broadcastGroupSpec(groupID string) tencentim.GroupSpec {
	switch imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix).Kind {
	case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
		// 播报群统一使用 AVChatRoom：ChatRoom 的套餐成员上限（标准版 2000 人）曾把新设备
		// 挡在群外（10038），全局群先踩坑迁移，区域群成员同样只进不出，是同一失败模式。
		// AVChatRoom 成员无上限、免审批，也不再需要定时清理成员。
		// reconciler 只生成 v2 群 ID，v1 旧群不再 ensure，过渡期由双发覆盖存量会话。
		return tencentim.GroupSpec{
			GroupID: groupID,
			Name:    groupID,
			Type:    tencentim.BroadcastGroupType,
		}
	}
	return tencentim.GroupSpec{
		GroupID: groupID,
		Name:    groupID,
		Type:    tencentim.DefaultGroupType,
		// 入群表面允许 FreeAccess，真正准入由腾讯回调回查 user-service；这样客户端 SDK 体验简单，权限仍在服务端。
		ApplyJoinOption: "FreeAccess",
	}
}

func normalizedPayloadJSON(input string, forced map[string]any) (string, error) {
	input = strings.TrimSpace(input)
	if input == "" {
		input = "{}"
	}
	var payload map[string]any
	decoder := json.NewDecoder(strings.NewReader(input))
	decoder.UseNumber()
	if err := decoder.Decode(&payload); err != nil {
		return "", xerr.New(xerr.InvalidArgument, "payload_json is invalid")
	}
	if err := decoder.Decode(&struct{}{}); err != io.EOF {
		return "", xerr.New(xerr.InvalidArgument, "payload_json is invalid")
	}
	if payload == nil {
		payload = map[string]any{}
	}
	for key, value := range forced {
		if key == "region_id" && value.(int64) <= 0 {
			// 全局播报必须删除调用方可能夹带的 region_id，避免客户端把伪造区域或 0
			// 当成真实路由维度；只跳过 forced 写入会保留原 payload 中的脏字段。
			delete(payload, "region_id")
			continue
		}
		// forced 字段覆盖调用方 payload，保证 event_id/scope/app_code/sent_at_ms 不被外部请求伪造。
		payload[key] = value
	}
	// 播报 payload 可能来自多个 owner service；在唯一出站归一化边界递归收敛所有 user_id/user_ids，
	// 并用 UseNumber 保留原始十进制文本，避免先落为 float64 后再转字符串已经无法恢复。
	normalizeBroadcastUserIDs(payload)
	encoded, err := json.Marshal(payload)
	if err != nil {
		return "", err
	}
	return string(encoded), nil
}

func normalizeBroadcastUserIDs(value any) any {
	switch typed := value.(type) {
	case map[string]any:
		for key, item := range typed {
			if broadcastUserIDKey(key) {
				typed[key] = broadcastUserIDValue(item)
				continue
			}
			typed[key] = normalizeBroadcastUserIDs(item)
		}
		return typed
	case []any:
		for index, item := range typed {
			typed[index] = normalizeBroadcastUserIDs(item)
		}
		return typed
	default:
		return value
	}
}

func broadcastUserIDKey(key string) bool {
	key = strings.ToLower(strings.TrimSpace(key))
	return key == "user_id" || key == "user_ids" || strings.HasSuffix(key, "_user_id") || strings.HasSuffix(key, "_user_ids")
}

func broadcastUserIDValue(value any) any {
	switch typed := value.(type) {
	case json.Number:
		return typed.String()
	case int64:
		return strconv.FormatInt(typed, 10)
	case int:
		return strconv.Itoa(typed)
	case []any:
		for index, item := range typed {
			typed[index] = broadcastUserIDValue(item)
		}
		return typed
	default:
		return value
	}
}

func superGiftBroadcastEventID(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) string {
	if strings.TrimSpace(gift.GetCommandId()) != "" {
		// command_id 是 SendGift 幂等键，用它生成播报 event_id 可避免同一房间事件重放时重复播报。
		return fmt.Sprintf("gift_broadcast:%s:%s", envelope.GetRoomId(), gift.GetCommandId())
	}
	// 兼容没有 command_id 的旧/测试事件；当前开发阶段仍以 envelope event_id 保持幂等。
	return "gift_broadcast:" + envelope.GetEventId()
}

func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64, sender SenderProfile, receiver SenderProfile) (string, error) {
	// payload 同时保留扁平字段和 sender/receiver 对象，旧客户端继续读原字段，新客户端可直接取完整用户资料。
	action := map[string]any{
		"type":        "enter_room",
		"room_id":     envelope.GetRoomId(),
		"room_locked": gift.GetRoomLocked(),
	}
	payload := map[string]any{
		"event_id":                eventID,
		"broadcast_type":          broadcastdomain.TypeSuperGift,
		"scope":                   broadcastdomain.ScopeRegion,
		"app_code":                envelope.GetAppCode(),
		"region_id":               gift.GetVisibleRegionId(),
		"room_id":                 envelope.GetRoomId(),
		"room_locked":             gift.GetRoomLocked(),
		"sender_user_id":          gift.GetSenderUserId(),
		"sender_display_user_id":  sender.Account,
		"sender_nickname":         sender.Nickname,
		"sender_avatar":           sender.Avatar,
		"sender_avatar_frame_url": sender.AvatarFrameURL,
		// 托盘 URL 是 gateway 在扣费前从有效 VIP 权益固化、room durable fact 透传的历史快照；
		// activity 不能在异步消费时按当前会员状态回查，否则同一 event_id 重试会改变客户端展示。
		"sender_vip_level":                    gift.GetSenderVipLevel(),
		"sender_gift_broadcast_animation_url": gift.GetSenderGiftBroadcastAnimationUrl(),
		"target_user_id":                      gift.GetTargetUserId(),
		"receiver_user_id":                    gift.GetTargetUserId(),
		"receiver_display_user_id":            receiver.Account,
		"receiver_nickname":                   receiver.Nickname,
		"receiver_avatar":                     receiver.Avatar,
		"receiver_avatar_frame_url":           receiver.AvatarFrameURL,
		"gift_id":                             gift.GetGiftId(),
		"gift_name":                           gift.GetGiftName(),
		"gift_icon_url":                       gift.GetGiftIconUrl(),
		"gift_animation_url":                  gift.GetGiftAnimationUrl(),
		"gift_effect_types":                   gift.GetGiftEffectTypes(),
		"gift_count":                          gift.GetGiftCount(),
		"gift_value":                          gift.GetGiftValue(),
		"sent_at_ms":                          sentAtMS,
		"room_version":                        envelope.GetRoomVersion(),
		"source_event_id":                     envelope.GetEventId(),
		"sender":                              broadcastProfilePayload(sender),
		"receiver":                            broadcastProfilePayload(receiver),
		"action":                              action,
	}
	setRoomOwnerSnapshot(payload, gift.GetOwnerUserId())
	setRoomOwnerSnapshot(action, gift.GetOwnerUserId())
	encoded, err := json.Marshal(payload)
	return string(encoded), err
}

func broadcastProfilePayload(profile SenderProfile) map[string]any {
	// 嵌套资料对象只放展示字段，不放钱包或权限数据，避免 IM 广播泄露服务端内部状态。
	return map[string]any{
		"user_id":          profile.UserID,
		"display_user_id":  profile.Account,
		"nickname":         profile.Nickname,
		"avatar":           profile.Avatar,
		"avatar_frame_url": profile.AvatarFrameURL,
	}
}

func roomRocketBroadcastEventID(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited) string {
	if strings.TrimSpace(rocket.GetRocketId()) != "" {
		return fmt.Sprintf("room_rocket_broadcast:%s:%s", envelope.GetRoomId(), rocket.GetRocketId())
	}
	return "room_rocket_broadcast:" + envelope.GetEventId()
}

func roomRocketRewardBroadcastEventID(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomRocketRewardGranted) string {
	if strings.TrimSpace(event.GetRocketId()) != "" {
		return fmt.Sprintf("room_rocket_reward:%s:%s", envelope.GetRoomId(), event.GetRocketId())
	}
	return "room_rocket_reward:" + envelope.GetEventId()
}

func roomPasswordChangedBroadcastEventID(envelope *roomeventsv1.EventEnvelope) string {
	return "room_password_changed:" + envelope.GetEventId()
}

func roomPasswordChangedPayload(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomPasswordChanged, eventID string, sentAtMS int64) (string, error) {
	payload := map[string]any{
		"event_id":        eventID,
		"broadcast_type":  broadcastdomain.TypeRoomPasswordChanged,
		"scope":           broadcastdomain.ScopeRegion,
		"app_code":        envelope.GetAppCode(),
		"region_id":       event.GetVisibleRegionId(),
		"room_id":         envelope.GetRoomId(),
		"actor_user_id":   event.GetActorUserId(),
		"locked":          event.GetLocked(),
		"sent_at_ms":      sentAtMS,
		"room_version":    envelope.GetRoomVersion(),
		"source_event_id": envelope.GetEventId(),
		"action": map[string]any{
			"type":    "refresh_room",
			"room_id": envelope.GetRoomId(),
		},
	}
	encoded, err := json.Marshal(payload)
	return string(encoded), err
}

func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited, eventID string, sentAtMS int64, igniter SenderProfile) (string, error) {
	action := map[string]any{
		"type":        "enter_room",
		"room_id":     envelope.GetRoomId(),
		"room_locked": rocket.GetRoomLocked(),
	}
	payload := map[string]any{
		"event_id":         eventID,
		"broadcast_type":   broadcastdomain.TypeRoomRocket,
		"scope":            rocket.GetBroadcastScope(),
		"app_code":         envelope.GetAppCode(),
		"region_id":        rocket.GetVisibleRegionId(),
		"room_id":          envelope.GetRoomId(),
		"room_short_id":    rocket.GetRoomShortId(),
		"rocket_id":        rocket.GetRocketId(),
		"rocket_level":     rocket.GetLevel(),
		"rocket_cover_url": rocket.GetRocketCoverUrl(),
		"launch_at_ms":     rocket.GetLaunchAtMs(),
		"top1_user_id":     rocket.GetTop1UserId(),
		"igniter_user_id":  rocket.GetIgniterUserId(),
		"room_locked":      rocket.GetRoomLocked(),
		"igniter": map[string]any{
			"user_id":  fmt.Sprintf("%d", rocket.GetIgniterUserId()),
			"short_id": strings.TrimSpace(igniter.Account),
			"nickname": strings.TrimSpace(igniter.Nickname),
			"avatar":   strings.TrimSpace(igniter.Avatar),
		},
		"sent_at_ms":      sentAtMS,
		"room_version":    envelope.GetRoomVersion(),
		"source_event_id": envelope.GetEventId(),
		"current_fuel":    rocket.GetCurrentFuel(),
		"fuel_threshold":  rocket.GetFuelThreshold(),
		"ignited_at_ms":   rocket.GetIgnitedAtMs(),
		"action":          action,
	}
	setRoomOwnerSnapshot(payload, rocket.GetOwnerUserId())
	setRoomOwnerSnapshot(action, rocket.GetOwnerUserId())
	encoded, err := json.Marshal(payload)
	return string(encoded), err
}

func setRoomOwnerSnapshot(payload map[string]any, ownerUserID int64) {
	if payload == nil || ownerUserID <= 0 {
		// 旧事件没有 owner_user_id 时必须保持字段缺失，让客户端继续弹密码框，不能发送可被误判为真实身份的 0。
		return
	}
	// 所有出站用户 ID 都使用十进制字符串，避免 JSON/JavaScript Number 截断 Snowflake int64。
	payload["owner_user_id"] = strconv.FormatInt(ownerUserID, 10)
}

func roomRocketRewardPayload(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomRocketRewardGranted, eventID string, sentAtMS int64, profiles map[int64]SenderProfile) (string, error) {
	rewards := make([]map[string]any, 0, len(event.GetRewards()))
	for _, reward := range event.GetRewards() {
		userID := reward.GetUserId()
		profile := profiles[userID]
		rewards = append(rewards, map[string]any{
			"reward_role": reward.GetRewardRole(),
			"user_id":     fmt.Sprintf("%d", userID),
			"user": map[string]any{
				"user_id":  fmt.Sprintf("%d", userID),
				"short_id": strings.TrimSpace(profile.Account),
				"nickname": strings.TrimSpace(profile.Nickname),
				"avatar":   strings.TrimSpace(profile.Avatar),
			},
			"reward_item_id":    reward.GetRewardItemId(),
			"resource_group_id": reward.GetResourceGroupId(),
			"display_name":      reward.GetDisplayName(),
			"icon_url":          reward.GetIconUrl(),
			"grant_id":          reward.GetGrantId(),
			"status":            reward.GetStatus(),
		})
	}
	payload := map[string]any{
		"event_id":        eventID,
		"broadcast_type":  broadcastdomain.TypeRoomRocketReward,
		"scope":           broadcastdomain.ScopeRoom,
		"app_code":        envelope.GetAppCode(),
		"room_id":         envelope.GetRoomId(),
		"rocket_id":       event.GetRocketId(),
		"rocket_level":    event.GetLevel(),
		"rewards":         rewards,
		"sent_at_ms":      sentAtMS,
		"room_version":    envelope.GetRoomVersion(),
		"source_event_id": envelope.GetEventId(),
	}
	encoded, err := json.Marshal(payload)
	return string(encoded), err
}

func trimError(value string) string {
	value = strings.TrimSpace(value)
	if len(value) > 512 {
		return value[:512]
	}
	return value
}
