package service

import (
	"context"
	"crypto/sha256"
	"encoding/binary"
	"fmt"
	"slices"
	"strings"
	"time"

	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/roomid"
	"hyapp/pkg/xerr"
	"hyapp/services/room-service/internal/room/command"
	"hyapp/services/room-service/internal/room/outbox"
	"hyapp/services/room-service/internal/room/rank"
	"hyapp/services/room-service/internal/room/state"
)

const (
	roomRocketLevelCount = 5

	roomRocketDefaultFuelSource        = "heat_value"
	roomRocketDefaultLaunchDelayMS     = int64(30_000)
	roomRocketDefaultBroadcastScope    = "region"
	roomRocketDefaultRewardStackPolicy = "allow_stack"

	roomRocketFuelHeatValue = "heat_value"

	roomRocketBroadcastNone   = "none"
	roomRocketBroadcastRegion = "region"
	roomRocketBroadcastGlobal = "global"

	roomRocketRewardStackAllow    = "allow_stack"
	roomRocketRewardStackPriority = "priority_only"

	roomRocketRewardInRoom  = "in_room"
	roomRocketRewardTop1    = "top1"
	roomRocketRewardIgniter = "igniter"
	roomRocketGrantSource   = "room_rocket"
	roomRocketGrantStatus   = "succeeded"
)

var roomRocketDefaultThresholds = []int64{1_000, 3_000, 6_000, 10_000, 15_000}

// GetRoomRocket 返回房间火箭 UI 初始化数据；它只读 Room Cell，不刷新 presence。
func (s *Service) GetRoomRocket(ctx context.Context, req *roomv1.GetRoomRocketRequest) (*roomv1.GetRoomRocketResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	roomID := strings.TrimSpace(req.GetRoomId())
	viewerUserID := req.GetViewerUserId()
	if !roomid.ValidStringID(roomID) {
		return nil, xerr.New(xerr.InvalidArgument, "room_id is invalid")
	}
	if viewerUserID <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
	}

	snapshot, err := s.currentSnapshot(ctx, roomID)
	if err != nil {
		return nil, err
	}
	if snapshot == nil || snapshot.GetRoomId() == "" {
		return nil, xerr.New(xerr.NotFound, "room not found")
	}
	if snapshot.GetStatus() != state.RoomStatusActive {
		return nil, xerr.New(xerr.RoomClosed, "room closed")
	}
	if snapshotUserBanned(snapshot, viewerUserID, s.clock.Now().UnixMilli()) || findProtoUser(snapshot, viewerUserID) == nil {
		// 火箭状态属于房间内活动信息，只允许仍在业务 presence 内的用户读取。
		return nil, xerr.New(xerr.PermissionDenied, "viewer is not in room")
	}

	config, err := s.roomRocketConfig(ctx)
	if err != nil {
		return nil, err
	}
	now := s.clock.Now().UTC()
	info := roomRocketInfoFromConfig(config, snapshot.GetRocket(), now)
	return &roomv1.GetRoomRocketResponse{
		Rocket:       info,
		ServerTimeMs: now.UnixMilli(),
	}, nil
}

func (s *Service) roomRocketConfig(ctx context.Context) (RoomRocketConfig, error) {
	config, exists, err := s.repository.GetRoomRocketConfig(ctx)
	if err != nil {
		return RoomRocketConfig{}, err
	}
	if !exists {
		config = defaultRoomRocketConfig(appcode.FromContext(ctx))
	}
	return normalizeRoomRocketConfig(config)
}

func defaultRoomRocketConfig(appCode string) RoomRocketConfig {
	levels := make([]RoomRocketLevelConfig, 0, roomRocketLevelCount)
	for index, threshold := range roomRocketDefaultThresholds {
		levels = append(levels, RoomRocketLevelConfig{
			Level:         int32(index + 1),
			FuelThreshold: threshold,
		})
	}
	return RoomRocketConfig{
		AppCode:           appcode.Normalize(appCode),
		FuelSource:        roomRocketDefaultFuelSource,
		LaunchDelayMS:     roomRocketDefaultLaunchDelayMS,
		BroadcastEnabled:  true,
		BroadcastScope:    roomRocketDefaultBroadcastScope,
		RewardStackPolicy: roomRocketDefaultRewardStackPolicy,
		Levels:            levels,
	}
}

func normalizeRoomRocketConfig(config RoomRocketConfig) (RoomRocketConfig, error) {
	config.AppCode = appcode.Normalize(config.AppCode)
	if config.AppCode == "" {
		config.AppCode = appcode.Default
	}
	if config.ConfigVersion < 0 {
		return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket config_version is invalid")
	}
	config.FuelSource = defaultRoomRocketString(config.FuelSource, roomRocketDefaultFuelSource)
	if config.FuelSource != roomRocketFuelHeatValue {
		return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket fuel_source is invalid")
	}
	if config.LaunchDelayMS < 0 || config.BroadcastDelayMS < 0 {
		return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket delay is invalid")
	}
	config.BroadcastScope = defaultRoomRocketString(config.BroadcastScope, roomRocketDefaultBroadcastScope)
	if config.BroadcastScope != roomRocketBroadcastNone && config.BroadcastScope != roomRocketBroadcastRegion && config.BroadcastScope != roomRocketBroadcastGlobal {
		return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket broadcast_scope is invalid")
	}
	if !config.BroadcastEnabled {
		config.BroadcastScope = roomRocketBroadcastNone
	}
	config.RewardStackPolicy = defaultRoomRocketString(config.RewardStackPolicy, roomRocketDefaultRewardStackPolicy)
	if config.RewardStackPolicy != roomRocketRewardStackAllow && config.RewardStackPolicy != roomRocketRewardStackPriority {
		return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket reward_stack_policy is invalid")
	}

	levels, err := normalizeRoomRocketLevels(config.Levels)
	if err != nil {
		return RoomRocketConfig{}, err
	}
	config.Levels = levels
	config.GiftFuelRules = normalizeRoomRocketFuelRules(config.GiftFuelRules)
	return config, nil
}

func normalizeRoomRocketLevels(levels []RoomRocketLevelConfig) ([]RoomRocketLevelConfig, error) {
	if len(levels) == 0 {
		return defaultRoomRocketConfig(appcode.Default).Levels, nil
	}
	if len(levels) != roomRocketLevelCount {
		return nil, xerr.New(xerr.InvalidArgument, "room rocket levels must be 5")
	}
	seen := make(map[int32]bool, roomRocketLevelCount)
	normalized := make([]RoomRocketLevelConfig, 0, roomRocketLevelCount)
	for _, level := range levels {
		if level.Level < 1 || level.Level > roomRocketLevelCount || seen[level.Level] {
			return nil, xerr.New(xerr.InvalidArgument, "room rocket level is invalid")
		}
		if level.FuelThreshold <= 0 {
			return nil, xerr.New(xerr.InvalidArgument, "room rocket fuel_threshold is invalid")
		}
		seen[level.Level] = true
		level.CoverURL = strings.TrimSpace(level.CoverURL)
		level.AnimationURL = strings.TrimSpace(level.AnimationURL)
		level.LaunchAnimationURL = strings.TrimSpace(level.LaunchAnimationURL)
		level.LaunchedImageURL = strings.TrimSpace(level.LaunchedImageURL)
		level.InRoomRewards = normalizeRoomRocketRewardPool(level.InRoomRewards)
		level.Top1Rewards = normalizeRoomRocketRewardPool(level.Top1Rewards)
		level.IgniterRewards = normalizeRoomRocketRewardPool(level.IgniterRewards)
		normalized = append(normalized, level)
	}
	slices.SortFunc(normalized, func(left, right RoomRocketLevelConfig) int {
		return int(left.Level - right.Level)
	})
	return normalized, nil
}

func normalizeRoomRocketRewardPool(input []RoomRocketRewardItem) []RoomRocketRewardItem {
	out := make([]RoomRocketRewardItem, 0, len(input))
	for _, reward := range input {
		reward.RewardItemID = strings.TrimSpace(reward.RewardItemID)
		reward.DisplayName = strings.TrimSpace(reward.DisplayName)
		reward.IconURL = strings.TrimSpace(reward.IconURL)
		if reward.ResourceGroupID <= 0 || reward.Weight <= 0 {
			// 后台保存层会拒绝非法奖励；运行时跳过历史脏项，避免一个奖励池配置污染送礼主链路。
			continue
		}
		if reward.Quantity <= 0 {
			// 旧配置没有 quantity 字段；按 1 份兼容，避免上线后历史火箭奖励池被当成 0 份直接停发。
			reward.Quantity = 1
		}
		out = append(out, reward)
	}
	return out
}

func normalizeRoomRocketFuelRules(input []GiftFuelRuleConfig) []GiftFuelRuleConfig {
	out := make([]GiftFuelRuleConfig, 0, len(input))
	for _, rule := range input {
		rule.RuleID = strings.TrimSpace(rule.RuleID)
		rule.GiftID = strings.TrimSpace(rule.GiftID)
		rule.GiftTypeCode = strings.TrimSpace(rule.GiftTypeCode)
		if rule.GiftID == "" && rule.GiftTypeCode == "" {
			continue
		}
		if rule.MultiplierPPM < 0 || rule.FixedFuel < 0 {
			continue
		}
		out = append(out, rule)
	}
	return out
}

func defaultRoomRocketString(value string, fallback string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return fallback
	}
	return value
}

func roomRocketInfoFromConfig(config RoomRocketConfig, current *roomv1.RoomRocketState, now time.Time) *roomv1.RoomRocketInfo {
	stateView := roomRocketProtoStateForView(current, config, now)
	return &roomv1.RoomRocketInfo{
		Enabled:           config.Enabled,
		Levels:            roomRocketLevelsToProto(config.Levels),
		State:             stateView,
		ServerTimeMs:      now.UnixMilli(),
		BroadcastScope:    config.BroadcastScope,
		LaunchDelayMs:     config.LaunchDelayMS,
		BroadcastDelayMs:  config.BroadcastDelayMS,
		RewardStackPolicy: config.RewardStackPolicy,
	}
}

func roomRocketProtoStateForView(current *roomv1.RoomRocketState, config RoomRocketConfig, now time.Time) *roomv1.RoomRocketState {
	internal := state.RocketState{}
	if current != nil {
		internal = state.RocketState{
			CurrentLevel:         current.GetCurrentLevel(),
			CurrentFuel:          current.GetCurrentFuel(),
			FuelThreshold:        current.GetFuelThreshold(),
			Status:               current.GetStatus(),
			IgnitedAtMS:          current.GetIgnitedAtMs(),
			LaunchAtMS:           current.GetLaunchAtMs(),
			LaunchedAtMS:         current.GetLaunchedAtMs(),
			ResetAtMS:            current.GetResetAtMs(),
			Top1UserID:           current.GetTop1UserId(),
			IgniterUserID:        current.GetIgniterUserId(),
			RocketID:             current.GetRocketId(),
			ConfigVersion:        current.GetConfigVersion(),
			PendingLaunches:      statePendingLaunchesFromProto(current.GetPendingLaunches()),
			CurrentContributions: stateContributionsFromProto(current.GetCurrentContributions()),
			LastRewards:          stateRewardsFromProto(current.GetLastRewards()),
		}
	}
	normalized := rocketStateForNow(&internal, config, now, false)
	return rocketStateToRoomProto(normalized)
}

func stateRewardsFromProto(input []*roomv1.RoomRocketRewardGrant) []state.RocketRewardGrant {
	out := make([]state.RocketRewardGrant, 0, len(input))
	for _, reward := range input {
		out = append(out, state.RocketRewardGrant{
			RewardRole:      reward.GetRewardRole(),
			UserID:          reward.GetUserId(),
			RewardItemID:    reward.GetRewardItemId(),
			ResourceGroupID: reward.GetResourceGroupId(),
			DisplayName:     reward.GetDisplayName(),
			IconURL:         reward.GetIconUrl(),
			GrantID:         reward.GetGrantId(),
			Status:          reward.GetStatus(),
		})
	}
	return out
}

func statePendingLaunchesFromProto(input []*roomv1.RoomRocketPendingLaunch) []state.RocketPendingLaunch {
	out := make([]state.RocketPendingLaunch, 0, len(input))
	for _, launch := range input {
		out = append(out, state.RocketPendingLaunch{
			RocketID:      launch.GetRocketId(),
			Level:         launch.GetLevel(),
			FuelThreshold: launch.GetFuelThreshold(),
			IgnitedAtMS:   launch.GetIgnitedAtMs(),
			LaunchAtMS:    launch.GetLaunchAtMs(),
			ResetAtMS:     launch.GetResetAtMs(),
			Top1UserID:    launch.GetTop1UserId(),
			IgniterUserID: launch.GetIgniterUserId(),
			ConfigVersion: launch.GetConfigVersion(),
			CoverURL:      launch.GetCoverUrl(),
		})
	}
	return out
}

func stateContributionsFromProto(input []*roomv1.RoomRocketContribution) []state.RocketContribution {
	out := make([]state.RocketContribution, 0, len(input))
	for _, contribution := range input {
		if contribution.GetUserId() <= 0 || contribution.GetFuel() <= 0 {
			continue
		}
		out = append(out, state.RocketContribution{
			UserID:      contribution.GetUserId(),
			Fuel:        contribution.GetFuel(),
			UpdatedAtMS: contribution.GetUpdatedAtMs(),
		})
	}
	return out
}

func roomRocketLevelsToProto(levels []RoomRocketLevelConfig) []*roomv1.RoomRocketLevel {
	out := make([]*roomv1.RoomRocketLevel, 0, len(levels))
	for _, level := range levels {
		out = append(out, &roomv1.RoomRocketLevel{
			Level:              level.Level,
			FuelThreshold:      level.FuelThreshold,
			CoverUrl:           level.CoverURL,
			AnimationUrl:       level.AnimationURL,
			LaunchAnimationUrl: level.LaunchAnimationURL,
			LaunchedImageUrl:   level.LaunchedImageURL,
			InRoomRewards:      roomRocketRewardsToProto(level.InRoomRewards),
			Top1Rewards:        roomRocketRewardsToProto(level.Top1Rewards),
			IgniterRewards:     roomRocketRewardsToProto(level.IgniterRewards),
		})
	}
	return out
}

func roomRocketRewardsToProto(input []RoomRocketRewardItem) []*roomv1.RoomRocketRewardItem {
	out := make([]*roomv1.RoomRocketRewardItem, 0, len(input))
	for _, reward := range input {
		out = append(out, &roomv1.RoomRocketRewardItem{
			RewardItemId:    reward.RewardItemID,
			ResourceGroupId: reward.ResourceGroupID,
			Weight:          reward.Weight,
			Quantity:        reward.Quantity,
			DisplayName:     reward.DisplayName,
			IconUrl:         reward.IconURL,
		})
	}
	return out
}

type rocketGiftApplyResult struct {
	touched       bool
	progressEvent *roomeventsv1.RoomRocketFuelChanged
	ignited       *roomeventsv1.RoomRocketIgnited
}

func (s *Service) applyRoomRocketGift(now time.Time, current *state.RoomState, cfg RoomRocketConfig, cmd command.SendGift, settled *command.SendGift, billing *walletv1.DebitGiftResponse, roomMeta RoomMeta) (rocketGiftApplyResult, error) {
	if !cfg.Enabled {
		return rocketGiftApplyResult{}, nil
	}
	now = now.UTC()
	nowMS := now.UnixMilli()
	rocket := rocketStateForNow(current.Rocket, cfg, now, true)
	result := rocketGiftApplyResult{touched: current.Rocket == nil || rocket.ResetAtMS != current.Rocket.ResetAtMS}

	addedFuel := roomRocketFuel(cfg, cmd.GiftID, billing.GetGiftTypeCode(), billing.GetHeatValue())
	effectiveFuel := int64(0)
	overflowFuel := int64(0)
	progressRocketID := rocket.RocketID
	progressLevel := rocket.CurrentLevel
	progressFuel := rocket.CurrentFuel
	progressThreshold := rocket.FuelThreshold
	progressStatus := rocket.Status

	if rocket.Status == state.RocketStatusCompleted {
		// 当天最高 5 级已满后，继续送礼不再产生新火箭；整笔燃料按产品规则直接作废。
		overflowFuel = addedFuel
	} else if addedFuel > 0 {
		remaining := rocket.FuelThreshold - rocket.CurrentFuel
		if remaining < 0 {
			remaining = 0
		}
		effectiveFuel = minInt64(addedFuel, remaining)
		overflowFuel = addedFuel - effectiveFuel
		if effectiveFuel > 0 {
			rocket.CurrentFuel += effectiveFuel
			rocket.CurrentContributions = addRocketContribution(rocket.CurrentContributions, cmd.ActorUserID(), effectiveFuel, nowMS)
			progressFuel = rocket.CurrentFuel
			result.touched = true
		}
		if rocket.CurrentFuel >= rocket.FuelThreshold {
			levelCfg := roomRocketLevelByNumber(cfg, rocket.CurrentLevel)
			top1UserID := firstRocketContributionUserID(rocket.CurrentContributions)
			if top1UserID <= 0 {
				// 理论上 effectiveFuel > 0 后一定有当前等级贡献；兜底用点火人，避免历史脏状态把 top1 奖励发给 0。
				top1UserID = cmd.ActorUserID()
			}
			pending := state.RocketPendingLaunch{
				RocketID:      rocket.RocketID,
				Level:         rocket.CurrentLevel,
				FuelThreshold: rocket.FuelThreshold,
				IgnitedAtMS:   nowMS,
				LaunchAtMS:    nowMS + cfg.LaunchDelayMS,
				ResetAtMS:     rocket.ResetAtMS,
				Top1UserID:    top1UserID,
				IgniterUserID: cmd.ActorUserID(),
				ConfigVersion: cfg.ConfigVersion,
				CoverURL:      levelCfg.CoverURL,
			}
			rocket.CurrentFuel = rocket.FuelThreshold
			rocket.IgnitedAtMS = pending.IgnitedAtMS
			rocket.LaunchAtMS = pending.LaunchAtMS
			rocket.Top1UserID = pending.Top1UserID
			rocket.IgniterUserID = pending.IgniterUserID
			rocket.ConfigVersion = cfg.ConfigVersion
			rocket.PendingLaunches = append(rocket.PendingLaunches, pending)
			progressRocketID = pending.RocketID
			progressLevel = pending.Level
			progressFuel = pending.FuelThreshold
			progressThreshold = pending.FuelThreshold
			progressStatus = state.RocketStatusIgnited
			result.ignited = &roomeventsv1.RoomRocketIgnited{
				RocketId:        pending.RocketID,
				Level:           pending.Level,
				CurrentFuel:     pending.FuelThreshold,
				FuelThreshold:   pending.FuelThreshold,
				IgnitedAtMs:     pending.IgnitedAtMS,
				LaunchAtMs:      pending.LaunchAtMS,
				BroadcastScope:  cfg.BroadcastScope,
				VisibleRegionId: roomMeta.VisibleRegionID,
				Top1UserId:      pending.Top1UserID,
				IgniterUserId:   pending.IgniterUserID,
				CommandId:       cmd.ID(),
				RoomShortId:     roomMeta.RoomShortID,
				RocketCoverUrl:  pending.CoverURL,
				ResetAtMs:       pending.ResetAtMS,
				// 构造函数同时服务普通送礼和 RPS 礼物；在这里读取 Cell 房主才能保证两条点火链路不漏字段。
				OwnerUserId: current.OwnerUserID,
			}
			if rocket.CurrentLevel < roomRocketLevelCount {
				nextLevel := rocket.CurrentLevel + 1
				nextLevelCfg := roomRocketLevelByNumber(cfg, nextLevel)
				rocket.CurrentLevel = nextLevel
				rocket.CurrentFuel = 0
				rocket.FuelThreshold = nextLevelCfg.FuelThreshold
				rocket.Status = state.RocketStatusCharging
				rocket.RocketID = idgen.New("room_rocket")
				rocket.CurrentContributions = nil
			} else {
				// 5 级是当天最高等级；点火后不再生成第 6 级，UTC 日界统一重置回 1 级。
				rocket.Status = state.RocketStatusCompleted
				rocket.RocketID = ""
				rocket.CurrentContributions = nil
			}
		}
	}

	if result.touched {
		current.Rocket = rocket
	}
	if effectiveFuel > 0 {
		result.progressEvent = &roomeventsv1.RoomRocketFuelChanged{
			RocketId:           progressRocketID,
			Level:              progressLevel,
			AddedFuel:          addedFuel,
			EffectiveAddedFuel: effectiveFuel,
			OverflowFuel:       overflowFuel,
			CurrentFuel:        progressFuel,
			FuelThreshold:      progressThreshold,
			Status:             progressStatus,
			ResetAtMs:          rocket.ResetAtMS,
			SenderUserId:       cmd.ActorUserID(),
			GiftId:             cmd.GiftID,
			GiftCount:          cmd.GiftCount,
			CommandId:          cmd.ID(),
			VisibleRegionId:    roomMeta.VisibleRegionID,
		}
	}

	if result.touched {
		settled.GiftTypeCode = billing.GetGiftTypeCode()
		settled.RocketTouched = true
		settled.RocketAddedFuel = addedFuel
		settled.RocketEffectiveFuel = effectiveFuel
		settled.RocketOverflowFuel = overflowFuel
		settled.RocketLevel = rocket.CurrentLevel
		settled.RocketFuel = rocket.CurrentFuel
		settled.RocketStatus = rocket.Status
		settled.RocketFuelThreshold = rocket.FuelThreshold
		settled.RocketIgnitedAtMS = rocket.IgnitedAtMS
		settled.RocketLaunchAtMS = rocket.LaunchAtMS
		settled.RocketResetAtMS = rocket.ResetAtMS
		settled.RocketTop1UserID = rocket.Top1UserID
		settled.RocketIgniterUserID = rocket.IgniterUserID
		settled.RocketID = rocket.RocketID
		settled.RocketConfigVersion = rocket.ConfigVersion
		settled.RocketPendingLaunches = rocketPendingLaunchesToCommand(rocket.PendingLaunches)
		settled.RocketCurrentContributions = rocketContributionsToCommand(rocket.CurrentContributions)
	}
	return result, nil
}

func rocketStateForNow(input *state.RocketState, cfg RoomRocketConfig, now time.Time, allocateRocketID bool) *state.RocketState {
	now = now.UTC()
	nowMS := now.UnixMilli()
	resetAtMS := nextUTCResetMS(now)
	level := int32(1)
	if input != nil && input.CurrentLevel >= 1 && input.CurrentLevel <= roomRocketLevelCount {
		level = input.CurrentLevel
	}
	if input == nil || input.ResetAtMS <= 0 || nowMS >= input.ResetAtMS {
		return newRocketState(levelForUTCReset(), roomRocketLevelByNumber(cfg, levelForUTCReset()), resetAtMS, cfg.ConfigVersion, allocateRocketID)
	}

	rocket := input.Clone()
	levelConfig := roomRocketLevelByNumber(cfg, level)
	rocket.CurrentLevel = level
	rocket.FuelThreshold = levelConfig.FuelThreshold
	rocket.ResetAtMS = input.ResetAtMS
	if rocket.CurrentFuel < 0 {
		rocket.CurrentFuel = 0
	}
	if rocket.CurrentFuel > rocket.FuelThreshold {
		rocket.CurrentFuel = rocket.FuelThreshold
	}
	rocket.PendingLaunches = validRocketPendingLaunches(rocket.PendingLaunches, rocket.ResetAtMS)
	syncRocketLaunchSnapshotFromPending(rocket)
	if rocket.Status == state.RocketStatusCompleted {
		rocket.CurrentLevel = roomRocketLevelCount
		rocket.FuelThreshold = roomRocketLevelByNumber(cfg, roomRocketLevelCount).FuelThreshold
		rocket.CurrentFuel = rocket.FuelThreshold
		rocket.RocketID = ""
	} else {
		rocket.Status = state.RocketStatusCharging
	}
	if rocket.RocketID == "" && allocateRocketID && rocket.Status != state.RocketStatusCompleted {
		rocket.RocketID = idgen.New("room_rocket")
	}
	return rocket
}

func syncRocketLaunchSnapshotFromPending(rocket *state.RocketState) {
	if rocket == nil {
		return
	}
	if len(rocket.PendingLaunches) == 0 {
		// 当前充能档没有待发射火箭时，不能继续暴露上一枚火箭的发射时间，否则客户端会把过期 launch_at_ms 渲染成 00:00:00。
		rocket.IgnitedAtMS = 0
		rocket.LaunchAtMS = 0
		rocket.Top1UserID = 0
		rocket.IgniterUserID = 0
		return
	}
	next := rocket.PendingLaunches[0]
	for _, launch := range rocket.PendingLaunches[1:] {
		if launch.LaunchAtMS > 0 && (next.LaunchAtMS <= 0 || launch.LaunchAtMS < next.LaunchAtMS) {
			next = launch
		}
	}
	// RoomRocketState 上的倒计时字段兼容旧客户端；真实多枚队列仍以 PendingLaunches 为准。
	rocket.IgnitedAtMS = next.IgnitedAtMS
	rocket.LaunchAtMS = next.LaunchAtMS
	rocket.Top1UserID = next.Top1UserID
	rocket.IgniterUserID = next.IgniterUserID
}

func validRocketPendingLaunches(input []state.RocketPendingLaunch, resetAtMS int64) []state.RocketPendingLaunch {
	out := make([]state.RocketPendingLaunch, 0, len(input))
	seen := make(map[string]bool, len(input))
	for _, launch := range input {
		if launch.RocketID == "" || launch.Level <= 0 || launch.LaunchAtMS <= 0 {
			continue
		}
		if resetAtMS > 0 && launch.ResetAtMS > 0 && launch.ResetAtMS != resetAtMS {
			continue
		}
		key := fmt.Sprintf("%s:%d", launch.RocketID, launch.Level)
		if seen[key] {
			continue
		}
		seen[key] = true
		out = append(out, launch)
	}
	slices.SortFunc(out, func(left, right state.RocketPendingLaunch) int {
		if left.LaunchAtMS == right.LaunchAtMS {
			return int(left.Level - right.Level)
		}
		if left.LaunchAtMS < right.LaunchAtMS {
			return -1
		}
		return 1
	})
	return out
}

func newRocketState(level int32, levelConfig RoomRocketLevelConfig, resetAtMS int64, configVersion int64, allocateRocketID bool) *state.RocketState {
	rocketID := ""
	if allocateRocketID {
		rocketID = idgen.New("room_rocket")
	}
	return &state.RocketState{
		CurrentLevel:  level,
		FuelThreshold: levelConfig.FuelThreshold,
		Status:        state.RocketStatusCharging,
		ResetAtMS:     resetAtMS,
		RocketID:      rocketID,
		ConfigVersion: configVersion,
	}
}

func levelForUTCReset() int32 {
	return 1
}

func nextUTCResetMS(now time.Time) int64 {
	utc := now.UTC()
	next := time.Date(utc.Year(), utc.Month(), utc.Day()+1, 0, 0, 0, 0, time.UTC)
	return next.UnixMilli()
}

func roomRocketLevelByNumber(cfg RoomRocketConfig, level int32) RoomRocketLevelConfig {
	for _, item := range cfg.Levels {
		if item.Level == level {
			return item
		}
	}
	return defaultRoomRocketConfig(cfg.AppCode).Levels[0]
}

func roomRocketFuel(cfg RoomRocketConfig, giftID string, giftTypeCode string, heatValue int64) int64 {
	// 火箭燃料只读取 wallet-service 按真实扣费和贡献比例计算出的 heat_value，避免历史 GIFT_POINT 字段污染活动进度。
	base := heatValue
	if base < 0 {
		base = 0
	}

	for _, rule := range cfg.GiftFuelRules {
		if rule.GiftID != "" && rule.GiftID == giftID {
			return roomRocketFuelByRule(base, rule)
		}
	}
	for _, rule := range cfg.GiftFuelRules {
		if rule.GiftID == "" && rule.GiftTypeCode != "" && rule.GiftTypeCode == giftTypeCode {
			return roomRocketFuelByRule(base, rule)
		}
	}
	return base
}

func roomRocketFuelByRule(base int64, rule GiftFuelRuleConfig) int64 {
	if rule.Excluded {
		return 0
	}
	fuel := int64(0)
	if rule.MultiplierPPM > 0 {
		fuel += base * rule.MultiplierPPM / 1_000_000
	}
	fuel += rule.FixedFuel
	if fuel < 0 {
		return 0
	}
	return fuel
}

func addRocketContribution(input []state.RocketContribution, userID int64, fuel int64, updatedAtMS int64) []state.RocketContribution {
	if userID <= 0 || fuel <= 0 {
		return normalizeRocketContributions(input)
	}
	out := append([]state.RocketContribution(nil), input...)
	for index := range out {
		if out[index].UserID == userID {
			// top1 只看当前火箭等级真实写入燃料的累计；溢出部分不会带到下一等级，也不会影响当前等级排名。
			out[index].Fuel += fuel
			out[index].UpdatedAtMS = updatedAtMS
			return normalizeRocketContributions(out)
		}
	}
	out = append(out, state.RocketContribution{UserID: userID, Fuel: fuel, UpdatedAtMS: updatedAtMS})
	return normalizeRocketContributions(out)
}

func normalizeRocketContributions(input []state.RocketContribution) []state.RocketContribution {
	out := make([]state.RocketContribution, 0, len(input))
	for _, item := range input {
		if item.UserID <= 0 || item.Fuel <= 0 {
			continue
		}
		out = append(out, item)
	}
	slices.SortFunc(out, func(left, right state.RocketContribution) int {
		if left.Fuel != right.Fuel {
			if left.Fuel > right.Fuel {
				return -1
			}
			return 1
		}
		if left.UpdatedAtMS != right.UpdatedAtMS {
			if left.UpdatedAtMS > right.UpdatedAtMS {
				return -1
			}
			return 1
		}
		if left.UserID < right.UserID {
			return -1
		}
		if left.UserID > right.UserID {
			return 1
		}
		return 0
	})
	return out
}

func firstRocketContributionUserID(items []state.RocketContribution) int64 {
	normalized := normalizeRocketContributions(items)
	if len(normalized) == 0 {
		return 0
	}
	return normalized[0].UserID
}

func minInt64(left int64, right int64) int64 {
	if left < right {
		return left
	}
	return right
}

func rocketStateToRoomProto(input *state.RocketState) *roomv1.RoomRocketState {
	if input == nil {
		return nil
	}
	rewards := make([]*roomv1.RoomRocketRewardGrant, 0, len(input.LastRewards))
	for _, reward := range input.LastRewards {
		rewards = append(rewards, &roomv1.RoomRocketRewardGrant{
			RewardRole:      reward.RewardRole,
			UserId:          reward.UserID,
			RewardItemId:    reward.RewardItemID,
			ResourceGroupId: reward.ResourceGroupID,
			DisplayName:     reward.DisplayName,
			IconUrl:         reward.IconURL,
			GrantId:         reward.GrantID,
			Status:          reward.Status,
		})
	}
	pending := make([]*roomv1.RoomRocketPendingLaunch, 0, len(input.PendingLaunches))
	for _, launch := range input.PendingLaunches {
		pending = append(pending, &roomv1.RoomRocketPendingLaunch{
			RocketId:      launch.RocketID,
			Level:         launch.Level,
			FuelThreshold: launch.FuelThreshold,
			IgnitedAtMs:   launch.IgnitedAtMS,
			LaunchAtMs:    launch.LaunchAtMS,
			ResetAtMs:     launch.ResetAtMS,
			Top1UserId:    launch.Top1UserID,
			IgniterUserId: launch.IgniterUserID,
			ConfigVersion: launch.ConfigVersion,
			CoverUrl:      launch.CoverURL,
		})
	}
	contributions := make([]*roomv1.RoomRocketContribution, 0, len(input.CurrentContributions))
	for _, contribution := range normalizeRocketContributions(input.CurrentContributions) {
		contributions = append(contributions, &roomv1.RoomRocketContribution{
			UserId:      contribution.UserID,
			Fuel:        contribution.Fuel,
			UpdatedAtMs: contribution.UpdatedAtMS,
		})
	}
	return &roomv1.RoomRocketState{
		CurrentLevel:         input.CurrentLevel,
		CurrentFuel:          input.CurrentFuel,
		FuelThreshold:        input.FuelThreshold,
		Status:               input.Status,
		IgnitedAtMs:          input.IgnitedAtMS,
		LaunchAtMs:           input.LaunchAtMS,
		LaunchedAtMs:         input.LaunchedAtMS,
		ResetAtMs:            input.ResetAtMS,
		Top1UserId:           input.Top1UserID,
		IgniterUserId:        input.IgniterUserID,
		RocketId:             input.RocketID,
		ConfigVersion:        input.ConfigVersion,
		LastRewards:          rewards,
		PendingLaunches:      pending,
		CurrentContributions: contributions,
	}
}

func rocketStateToEventRewards(input []state.RocketRewardGrant) []*roomeventsv1.RoomRocketRewardGrant {
	out := make([]*roomeventsv1.RoomRocketRewardGrant, 0, len(input))
	for _, reward := range input {
		out = append(out, &roomeventsv1.RoomRocketRewardGrant{
			RewardRole:      reward.RewardRole,
			UserId:          reward.UserID,
			RewardItemId:    reward.RewardItemID,
			ResourceGroupId: reward.ResourceGroupID,
			DisplayName:     reward.DisplayName,
			IconUrl:         reward.IconURL,
			GrantId:         reward.GrantID,
			Status:          reward.Status,
		})
	}
	return out
}

// RunRoomRocketLaunchWorker 周期性扫描已装载房间，把到点的待发射火箭走命令链路发射。
func (s *Service) RunRoomRocketLaunchWorker(ctx context.Context, interval time.Duration) {
	if interval <= 0 {
		return
	}
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			_ = s.SweepRoomRocketLaunchings(ctx)
		}
	}
}

// SweepRoomRocketLaunchings 只处理本节点仍持有 lease 的已装载 Room Cell。
func (s *Service) SweepRoomRocketLaunchings(ctx context.Context) error {
	now := s.clock.Now().UTC()
	nowMS := now.UnixMilli()
	for _, roomRef := range s.loadedRoomRefs() {
		roomCtx := appcode.WithContext(ctx, roomRef.AppCode)
		lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now)
		if err != nil {
			return err
		}
		if !owned {
			continue
		}
		roomCell := s.loadCellForLease(roomCtx, roomRef.RoomID, lease)
		if roomCell == nil {
			continue
		}
		currentState, _, err := roomCell.Snapshot(roomCtx)
		if err != nil {
			return err
		}
		owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
		if err != nil {
			return err
		}
		if !owned || currentState == nil || currentState.Rocket == nil {
			continue
		}
		rocket := currentState.Rocket
		if rocket.ResetAtMS > 0 && nowMS >= rocket.ResetAtMS {
			// UTC 切日优先级高于延迟发射；跨日倒计时火箭不再结算，下一次查询或送礼会按新 UTC 日投影/持久化一级新进度。
			continue
		}
		for _, pending := range append([]state.RocketPendingLaunch(nil), rocket.PendingLaunches...) {
			if pending.LaunchAtMS <= 0 || pending.LaunchAtMS > nowMS {
				continue
			}
			actorID := pending.IgniterUserID
			if actorID <= 0 {
				actorID = roomRocketSystemActor(currentState)
			}
			cmd := command.LaunchRoomRocket{
				Base: command.Base{
					AppCode:       roomRef.AppCode,
					RequestID:     idgen.New("req_room_rocket_launch"),
					CommandID:     roomRocketLaunchCommandID(pending.RocketID),
					ActorID:       actorID,
					Room:          roomRef.RoomID,
					GatewayNodeID: s.nodeID,
					SessionID:     "room-rocket-launch",
					SentAtMS:      nowMS,
				},
				RocketID: pending.RocketID,
				Level:    pending.Level,
			}
			if _, err := s.launchRoomRocket(roomCtx, cmd); err != nil {
				return err
			}
		}
	}
	return nil
}

func roomRocketLaunchCommandID(rocketID string) string {
	if strings.TrimSpace(rocketID) == "" {
		return idgen.New("cmd_room_rocket_launch")
	}
	return "cmd_room_rocket_launch_" + rocketID
}

func roomRocketSystemActor(current *state.RoomState) int64 {
	if current == nil {
		return 0
	}
	if current.OwnerUserID > 0 {
		return current.OwnerUserID
	}
	if current.Rocket != nil && current.Rocket.IgniterUserID > 0 {
		return current.Rocket.IgniterUserID
	}
	return 0
}

func (s *Service) launchRoomRocket(ctx context.Context, cmd command.LaunchRoomRocket) (*roomv1.RoomRocketState, error) {
	result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
		now = now.UTC()
		nowMS := now.UnixMilli()
		if current.Rocket == nil {
			return mutationResult{snapshot: current.ToProto()}, nil, nil
		}
		pending, pendingIndex, ok := findRocketPendingLaunch(current.Rocket.PendingLaunches, cmd.RocketID, cmd.Level)
		if !ok {
			return mutationResult{snapshot: current.ToProto()}, nil, nil
		}
		if pending.ResetAtMS > 0 && nowMS >= pending.ResetAtMS {
			// 手动或延迟到达的发射命令也不能越过 UTC 日界结算昨天的火箭。
			return mutationResult{snapshot: current.ToProto()}, nil, nil
		}
		if pending.LaunchAtMS <= 0 || pending.LaunchAtMS > nowMS {
			return mutationResult{snapshot: current.ToProto()}, nil, nil
		}

		cfg, err := s.roomRocketConfig(ctx)
		if err != nil {
			return mutationResult{}, nil, err
		}
		levelCfg := roomRocketLevelByNumber(cfg, pending.Level)
		inRoomUserIDs := sortedRocketOnlineUserIDs(current.OnlineUsers)
		rewards, err := s.settleRoomRocketRewards(ctx, now, current, pending, cfg, levelCfg, inRoomUserIDs)
		if err != nil {
			return mutationResult{}, nil, err
		}

		pendingAfter := removeRocketPendingLaunch(current.Rocket.PendingLaunches, pendingIndex)
		current.Rocket.PendingLaunches = pendingAfter
		syncRocketLaunchSnapshotFromPending(current.Rocket)
		current.Rocket.LaunchedAtMS = nowMS
		current.Rocket.LastRewards = rewards

		settledCommand := cmd
		settledCommand.CurrentLevel = current.Rocket.CurrentLevel
		settledCommand.CurrentFuel = current.Rocket.CurrentFuel
		settledCommand.CurrentFuelThreshold = current.Rocket.FuelThreshold
		settledCommand.CurrentStatus = current.Rocket.Status
		settledCommand.CurrentRocketID = current.Rocket.RocketID
		settledCommand.ConfigVersion = current.Rocket.ConfigVersion
		settledCommand.ResetAtMS = current.Rocket.ResetAtMS
		settledCommand.Top1UserID = pending.Top1UserID
		settledCommand.IgniterUserID = pending.IgniterUserID
		settledCommand.LaunchedAtMS = nowMS
		settledCommand.InRoomUserIDs = inRoomUserIDs
		settledCommand.CurrentContributions = rocketContributionsToCommand(current.Rocket.CurrentContributions)
		settledCommand.Rewards = rocketRewardsToCommand(rewards)
		settledCommand.PendingLaunches = rocketPendingLaunchesToCommand(pendingAfter)
		commandPayload, err := command.Serialize(settledCommand)
		if err != nil {
			return mutationResult{}, nil, err
		}

		current.Version++

		launchedEvent, err := outbox.Build(current.RoomID, "RoomRocketLaunched", current.Version, now, &roomeventsv1.RoomRocketLaunched{
			RocketId:      pending.RocketID,
			Level:         pending.Level,
			NextLevel:     current.Rocket.CurrentLevel,
			LaunchedAtMs:  nowMS,
			ResetAtMs:     pending.ResetAtMS,
			Top1UserId:    pending.Top1UserID,
			IgniterUserId: pending.IgniterUserID,
			InRoomUserIds: inRoomUserIDs,
			Rewards:       rocketStateToEventRewards(rewards),
		})
		if err != nil {
			return mutationResult{}, nil, err
		}
		records := []outbox.Record{launchedEvent}
		if len(rewards) > 0 {
			rewardEvent, err := outbox.Build(current.RoomID, "RoomRocketRewardGranted", current.Version, now, &roomeventsv1.RoomRocketRewardGranted{
				RocketId: pending.RocketID,
				Level:    pending.Level,
				Rewards:  rocketStateToEventRewards(rewards),
			})
			if err != nil {
				return mutationResult{}, nil, err
			}
			records = append(records, rewardEvent)
		}

		return mutationResult{
			snapshot:       current.ToProto(),
			commandPayload: commandPayload,
		}, records, nil
	})
	if err != nil {
		return nil, err
	}
	return result.snapshot.GetRocket(), nil
}

func findRocketPendingLaunch(input []state.RocketPendingLaunch, rocketID string, level int32) (state.RocketPendingLaunch, int, bool) {
	for index, launch := range input {
		if launch.RocketID == rocketID && launch.Level == level {
			return launch, index, true
		}
	}
	return state.RocketPendingLaunch{}, -1, false
}

func removeRocketPendingLaunch(input []state.RocketPendingLaunch, index int) []state.RocketPendingLaunch {
	if index < 0 || index >= len(input) {
		return append([]state.RocketPendingLaunch(nil), input...)
	}
	out := make([]state.RocketPendingLaunch, 0, len(input)-1)
	out = append(out, input[:index]...)
	out = append(out, input[index+1:]...)
	return out
}

func sortedRocketOnlineUserIDs(users map[int64]*state.RoomUserState) []int64 {
	userIDs := make([]int64, 0, len(users))
	for userID := range users {
		if userID > 0 {
			userIDs = append(userIDs, userID)
		}
	}
	slices.Sort(userIDs)
	return userIDs
}

func (s *Service) settleRoomRocketRewards(ctx context.Context, now time.Time, current *state.RoomState, launch state.RocketPendingLaunch, _ RoomRocketConfig, levelCfg RoomRocketLevelConfig, inRoomUserIDs []int64) ([]state.RocketRewardGrant, error) {
	if current == nil {
		return nil, nil
	}
	rewards := make([]state.RocketRewardGrant, 0, len(inRoomUserIDs)+len(levelCfg.Top1Rewards)+len(levelCfg.IgniterRewards))
	addRewardItem := func(role string, userID int64, item RoomRocketRewardItem) error {
		if userID <= 0 {
			return nil
		}
		if item.ResourceGroupID <= 0 || item.Weight <= 0 {
			return nil
		}
		grantID, err := s.grantRoomRocketReward(ctx, now, current, launch.RocketID, role, userID, item)
		if err != nil {
			return err
		}
		rewards = append(rewards, state.RocketRewardGrant{
			RewardRole:      role,
			UserID:          userID,
			RewardItemID:    item.RewardItemID,
			ResourceGroupID: item.ResourceGroupID,
			DisplayName:     item.DisplayName,
			IconURL:         item.IconURL,
			GrantID:         grantID,
			Status:          roomRocketGrantStatus,
		})
		return nil
	}
	addAllRewards := func(role string, userID int64, pool []RoomRocketRewardItem) error {
		for _, item := range pool {
			if err := addRewardItem(role, userID, item); err != nil {
				return err
			}
		}
		return nil
	}

	inRoomRewards := selectRoomRocketRewardsForUsers(launch.RocketID, roomRocketRewardInRoom, inRoomUserIDs, levelCfg.InRoomRewards)
	for _, userID := range inRoomUserIDs {
		// 在房奖励是大面积发放池，每个在线用户只拿一个奖项；同一枚火箭内按无放回周期分配，避免配置了金币和麦位声波时整房都落到同一个奖项。
		item, ok := inRoomRewards[userID]
		if !ok {
			continue
		}
		if err := addRewardItem(roomRocketRewardInRoom, userID, item); err != nil {
			return nil, err
		}
	}
	// top1 和点火人是确定身份权益：命中后发完整配置池；同一用户同时命中两个身份时继续叠加。
	if err := addAllRewards(roomRocketRewardTop1, launch.Top1UserID, levelCfg.Top1Rewards); err != nil {
		return nil, err
	}
	if err := addAllRewards(roomRocketRewardIgniter, launch.IgniterUserID, levelCfg.IgniterRewards); err != nil {
		return nil, err
	}
	return rewards, nil
}

func (s *Service) grantRoomRocketReward(ctx context.Context, _ time.Time, current *state.RoomState, rocketID string, role string, userID int64, item RoomRocketRewardItem) (string, error) {
	if s.wallet == nil {
		return "", xerr.New(xerr.Unavailable, "wallet client is not configured")
	}
	operatorUserID := current.OwnerUserID
	if operatorUserID <= 0 {
		operatorUserID = userID
	}
	resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
		CommandId:      roomRocketGrantCommandID(rocketID, role, userID, item.RewardItemID),
		AppCode:        appcode.FromContext(ctx),
		TargetUserId:   userID,
		GroupId:        item.ResourceGroupID,
		Reason:         fmt.Sprintf("room rocket %s reward", role),
		OperatorUserId: operatorUserID,
		GrantSource:    roomRocketGrantSource,
	})
	if err != nil {
		return "", err
	}
	if resp == nil || resp.GetGrant() == nil {
		return "", xerr.New(xerr.Unavailable, "wallet grant response is empty")
	}
	return resp.GetGrant().GetGrantId(), nil
}

func validRoomRocketRewardPool(pool []RoomRocketRewardItem) []RoomRocketRewardItem {
	valid := make([]RoomRocketRewardItem, 0, len(pool))
	for _, item := range pool {
		if item.Weight <= 0 || item.ResourceGroupID <= 0 {
			continue
		}
		if item.Quantity <= 0 {
			item.Quantity = 1
		}
		valid = append(valid, item)
	}
	return valid
}

func selectRoomRocketRewardsForUsers(rocketID string, role string, userIDs []int64, pool []RoomRocketRewardItem) map[int64]RoomRocketRewardItem {
	assignments := make(map[int64]RoomRocketRewardItem, len(userIDs))
	if len(userIDs) == 0 {
		return assignments
	}
	basePool := validRoomRocketRewardPool(pool)
	if len(basePool) == 0 {
		return assignments
	}
	available := expandRoomRocketRewardSlots(basePool)
	for index, userID := range userIDs {
		if userID <= 0 {
			continue
		}
		if len(available) == 0 {
			// 在房奖励数量是单次发射的硬上限；配置总数发完后，其余在线用户本次拿空，不生成 grant 和 IM 弹窗。
			break
		}
		selectedIndex, ok := selectRoomRocketRewardIndex(rocketID, role, userID, available, fmt.Sprintf("limited:index:%d", index))
		if !ok {
			continue
		}
		assignments[userID] = available[selectedIndex]
		available = append(available[:selectedIndex], available[selectedIndex+1:]...)
	}
	return assignments
}

func expandRoomRocketRewardSlots(pool []RoomRocketRewardItem) []RoomRocketRewardItem {
	total := 0
	for _, item := range pool {
		if item.Quantity > 0 {
			total += int(item.Quantity)
		}
	}
	slots := make([]RoomRocketRewardItem, 0, total)
	for _, item := range pool {
		for count := int64(0); count < item.Quantity; count++ {
			slots = append(slots, item)
		}
	}
	return slots
}

func selectRoomRocketRewardIndex(rocketID string, role string, userID int64, pool []RoomRocketRewardItem, salt string) (int, bool) {
	total := int64(0)
	for _, item := range pool {
		if item.Weight > 0 && item.ResourceGroupID > 0 {
			total += item.Weight
		}
	}
	if total <= 0 {
		return -1, false
	}
	hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%s", rocketID, role, userID, salt)))
	pick := int64(binary.BigEndian.Uint64(hash[:8]) % uint64(total))
	cursor := int64(0)
	fallback := -1
	for index, item := range pool {
		if item.Weight <= 0 || item.ResourceGroupID <= 0 {
			continue
		}
		fallback = index
		cursor += item.Weight
		if pick < cursor {
			return index, true
		}
	}
	if fallback >= 0 {
		return fallback, true
	}
	return -1, false
}

func roomRocketGrantCommandID(rocketID string, role string, userID int64, rewardItemID string) string {
	hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%s", rocketID, role, userID, rewardItemID)))
	return fmt.Sprintf("cmd_room_rocket_grant_%x", hash[:8])
}

func rocketRewardsToCommand(input []state.RocketRewardGrant) []command.RocketRewardGrant {
	out := make([]command.RocketRewardGrant, 0, len(input))
	for _, reward := range input {
		out = append(out, command.RocketRewardGrant{
			RewardRole:      reward.RewardRole,
			UserID:          reward.UserID,
			RewardItemID:    reward.RewardItemID,
			ResourceGroupID: reward.ResourceGroupID,
			DisplayName:     reward.DisplayName,
			IconURL:         reward.IconURL,
			GrantID:         reward.GrantID,
			Status:          reward.Status,
		})
	}
	return out
}

func rocketContributionsToCommand(input []state.RocketContribution) []command.RocketContribution {
	normalized := normalizeRocketContributions(input)
	out := make([]command.RocketContribution, 0, len(normalized))
	for _, contribution := range normalized {
		out = append(out, command.RocketContribution{
			UserID:      contribution.UserID,
			Fuel:        contribution.Fuel,
			UpdatedAtMS: contribution.UpdatedAtMS,
		})
	}
	return out
}

func rocketPendingLaunchesToCommand(input []state.RocketPendingLaunch) []command.RocketPendingLaunch {
	out := make([]command.RocketPendingLaunch, 0, len(input))
	for _, launch := range input {
		out = append(out, command.RocketPendingLaunch{
			RocketID:      launch.RocketID,
			Level:         launch.Level,
			FuelThreshold: launch.FuelThreshold,
			IgnitedAtMS:   launch.IgnitedAtMS,
			LaunchAtMS:    launch.LaunchAtMS,
			ResetAtMS:     launch.ResetAtMS,
			Top1UserID:    launch.Top1UserID,
			IgniterUserID: launch.IgniterUserID,
			ConfigVersion: launch.ConfigVersion,
			CoverURL:      launch.CoverURL,
		})
	}
	return out
}
