package service_test

import (
	"context"
	"fmt"
	"slices"
	"sync"
	"testing"
	"time"

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/roommq"
	"hyapp/pkg/xerr"
	"hyapp/services/room-service/internal/integration"
	"hyapp/services/room-service/internal/room/command"
	roomservice "hyapp/services/room-service/internal/room/service"
	"hyapp/services/room-service/internal/room/state"
	"hyapp/services/room-service/internal/router"
	"hyapp/services/room-service/internal/testutil/mysqltest"
)

type fixedRoomRocketClock struct {
	now time.Time
}

func (c *fixedRoomRocketClock) Now() time.Time {
	return c.now.UTC()
}

type rocketTestWallet struct {
	debits               []*walletv1.DebitGiftResponse
	grants               []*walletv1.GrantResourceGroupRequest
	lastDebit            *walletv1.DebitGiftRequest
	debitRequests        []*walletv1.DebitGiftRequest
	lastBatch            *walletv1.BatchDebitGiftRequest
	robotDebitRequests   []*walletv1.DebitRobotGiftRequest
	robotGiftConfigs     []*walletv1.GiftConfig
	robotCatalogRequests []*walletv1.ListGiftConfigsRequest
	balanceResponses     []*walletv1.GetBalancesResponse
	balanceRequests      []*walletv1.GetBalancesRequest
	vipResponses         map[int64]*walletv1.GetMyVipResponse
	vipRequests          []*walletv1.GetMyVipRequest
	vipError             error
}

type recordingRoomDirectIMPublisher struct {
	mu      sync.Mutex
	wake    chan struct{}
	records []*roomeventsv1.EventEnvelope
}

func newRecordingRoomDirectIMPublisher() *recordingRoomDirectIMPublisher {
	return &recordingRoomDirectIMPublisher{wake: make(chan struct{}, 128)}
}

func (p *recordingRoomDirectIMPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
	p.mu.Lock()
	p.records = append(p.records, proto.Clone(envelope).(*roomeventsv1.EventEnvelope))
	p.mu.Unlock()
	select {
	case p.wake <- struct{}{}:
	default:
	}
	return nil
}

func (p *recordingRoomDirectIMPublisher) counts() map[string]int {
	p.mu.Lock()
	defer p.mu.Unlock()
	counts := make(map[string]int, len(p.records))
	for _, record := range p.records {
		counts[record.GetEventType()]++
	}
	return counts
}

func (p *recordingRoomDirectIMPublisher) recordsByType(eventType string) []*roomeventsv1.EventEnvelope {
	p.mu.Lock()
	defer p.mu.Unlock()
	records := make([]*roomeventsv1.EventEnvelope, 0)
	for _, record := range p.records {
		if record.GetEventType() == eventType {
			records = append(records, proto.Clone(record).(*roomeventsv1.EventEnvelope))
		}
	}
	return records
}

func TestJoinRoomDirectIMCarriesDisplayProfileSnapshot(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)

	roomID := "room-join-display-profile"
	userID := int64(1002)
	createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, userID),
		Role: "audience",
		ActorDisplayProfile: &roomv1.RoomUserDisplayProfile{
			UserId:              userID,
			Nickname:            "Join Snapshot",
			Avatar:              "https://cdn.example/join.png",
			DisplayUserId:       "200002",
			PrettyDisplayUserId: "888002",
		},
	}); err != nil {
		t.Fatalf("join room with display profile failed: %v", err)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})

	records := directIM.recordsByType("RoomUserJoined")
	if len(records) != 1 {
		t.Fatalf("direct IM joined records mismatch: %+v", records)
	}
	var joined roomeventsv1.RoomUserJoined
	if err := proto.Unmarshal(records[0].GetBody(), &joined); err != nil {
		t.Fatalf("decode direct IM joined body failed: %v", err)
	}
	if joined.GetNickname() != "Join Snapshot" || joined.GetAvatar() != "https://cdn.example/join.png" || joined.GetDisplayUserId() != "200002" || joined.GetPrettyDisplayUserId() != "888002" {
		t.Fatalf("direct IM joined profile mismatch: %+v", &joined)
	}
}

func TestJoinRoomLiteCarriesEntryVehicleToDirectIMAndOutbox(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC)}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)

	roomID := "room-lite-entry-vehicle"
	userID := int64(1002)
	createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
	response, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta:         roomservice.NewRequestMeta(roomID, userID),
		Role:         "audience",
		ResponseMode: "lite",
		EntryVehicle: &roomv1.RoomEntryVehicleSnapshot{
			ResourceId:    8201,
			ResourceCode:  "vehicle-lite",
			Name:          "Lite Vehicle",
			AssetUrl:      "https://cdn.example/vehicle.png",
			PreviewUrl:    "https://cdn.example/vehicle-preview.png",
			AnimationUrl:  "https://cdn.example/vehicle.svga",
			MetadataJson:  `{"format":"svga"}`,
			EntitlementId: "ent-lite-vehicle",
			ExpiresAtMs:   1_999_999_999_999,
		},
	})
	if err != nil {
		t.Fatalf("lite join with entry vehicle failed: %v", err)
	}
	if len(response.GetRoom().GetOnlineUsers()) != 0 {
		t.Fatalf("lite response must remain trimmed while its IM event carries the vehicle: %+v", response.GetRoom())
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})

	directRecords := directIM.recordsByType("RoomUserJoined")
	if len(directRecords) != 1 {
		t.Fatalf("lite join direct IM count mismatch: %+v", directRecords)
	}
	assertJoinedEntryVehicle := func(source string, body []byte) {
		t.Helper()
		var joined roomeventsv1.RoomUserJoined
		if err := proto.Unmarshal(body, &joined); err != nil {
			t.Fatalf("decode %s RoomUserJoined failed: %v", source, err)
		}
		vehicle := joined.GetEntryVehicle()
		if vehicle.GetResourceId() != 8201 || vehicle.GetAnimationUrl() != "https://cdn.example/vehicle.svga" || vehicle.GetEntitlementId() != "ent-lite-vehicle" {
			t.Fatalf("%s must preserve the entry vehicle snapshot: %+v", source, vehicle)
		}
	}
	assertJoinedEntryVehicle("direct IM", directRecords[0].GetBody())

	pendingRecords, err := repository.ListPendingOutbox(ctx, 20)
	if err != nil {
		t.Fatalf("list lite join outbox failed: %v", err)
	}
	for _, record := range pendingRecords {
		if record.EventType == "RoomUserJoined" && record.RoomID == roomID {
			assertJoinedEntryVehicle("durable outbox", record.Envelope.GetBody())
			return
		}
	}
	t.Fatalf("lite join RoomUserJoined outbox record not found: %+v", pendingRecords)
}

func TestRepeatJoinRoomPublishesEntryBroadcastDirectIMOnly(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)

	roomID := "room-repeat-join-broadcast"
	userID := int64(1002)
	createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
	joinRocketRoom(t, ctx, svc, roomID, userID)
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})

	if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != 1 {
		t.Fatalf("first join must write one durable RoomUserJoined outbox, got %d", got)
	}

	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, userID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("repeat join failed: %v", err)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 2})

	if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != 1 {
		t.Fatalf("repeat join must not write durable RoomUserJoined outbox, got %d", got)
	}
}

func TestPresenceRecoveryRestoresOnlineStateWithoutJoinEvents(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 12, 6, 0, 0, 0, time.UTC)}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)

	roomID := "room-presence-recovery-no-entry"
	userID := int64(1002)
	createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
	joinRocketRoom(t, ctx, svc, roomID, userID)
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, userID),
	}); err != nil {
		t.Fatalf("remove stale presence fixture failed: %v", err)
	}

	joinedBefore := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]
	directJoinedBefore := directIM.counts()["RoomUserJoined"]
	resp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta:             roomservice.NewRequestMeta(roomID, userID),
		Role:             "audience",
		PresenceRecovery: true,
	})
	if err != nil {
		t.Fatalf("presence recovery failed: %v", err)
	}
	if resp.GetUser().GetUserId() != userID {
		t.Fatalf("presence recovery must restore current user: %+v", resp.GetUser())
	}
	if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != joinedBefore {
		t.Fatalf("presence recovery must not publish durable RoomUserJoined: before=%d after=%d", joinedBefore, got)
	}
	if got := directIM.counts()["RoomUserJoined"]; got != directJoinedBefore {
		t.Fatalf("presence recovery must not publish entry direct IM: before=%d after=%d", directJoinedBefore, got)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta:             roomservice.NewRequestMeta(roomID, userID),
		Role:             "audience",
		PresenceRecovery: true,
	}); err != nil {
		t.Fatalf("idempotent presence recovery failed: %v", err)
	}
	if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != joinedBefore {
		t.Fatalf("recovery racing with restored presence must not publish durable join: before=%d after=%d", joinedBefore, got)
	}
	if got := directIM.counts()["RoomUserJoined"]; got != directJoinedBefore {
		t.Fatalf("recovery racing with restored presence must not publish entry direct IM: before=%d after=%d", directJoinedBefore, got)
	}
}

func TestJoinRoomLiteTrimsResponseSnapshotOnly(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)}
	svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, newRecordingRoomDirectIMPublisher())

	roomID := "room-lite-response-snapshot"
	createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
	joinRocketRoom(t, ctx, svc, roomID, 1002)
	joinRocketRoom(t, ctx, svc, roomID, 1003)

	resp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta:         roomservice.NewRequestMeta(roomID, 1004),
		Role:         "audience",
		ResponseMode: "lite",
	})
	if err != nil {
		t.Fatalf("lite join room failed: %v", err)
	}
	if len(resp.GetRoom().GetOnlineUsers()) != 0 || len(resp.GetRoom().GetGiftRank()) != 0 || len(resp.GetRoom().GetAdminUserIds()) != 0 {
		t.Fatalf("lite response must trim heavy room arrays: %+v", resp.GetRoom())
	}

	snapshot, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{
		Meta:         &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
		RoomId:       roomID,
		ViewerUserId: 1004,
	})
	if err != nil {
		t.Fatalf("snapshot after lite join failed: %v", err)
	}
	if got, want := resp.GetRoom().GetOnlineCount(), int32(len(snapshot.GetRoom().GetOnlineUsers())); got != want || want == 0 {
		t.Fatalf("lite response must keep accurate online_count without mutating stored snapshot: got=%d want=%d snapshot=%+v", got, want, snapshot.GetRoom())
	}
}

func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
	w.lastDebit = req
	w.debitRequests = append(w.debitRequests, req)
	if len(w.debits) == 0 {
		return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-default", GiftPointAdded: 1, HeatValue: 1}, nil
	}
	next := w.debits[0]
	w.debits = w.debits[1:]
	return next, nil
}

func (w *rocketTestWallet) BatchDebitGift(_ context.Context, req *walletv1.BatchDebitGiftRequest) (*walletv1.BatchDebitGiftResponse, error) {
	w.lastBatch = req
	receipts := make([]*walletv1.BatchDebitGiftReceipt, 0, len(req.GetTargets()))
	aggregate := &walletv1.DebitGiftResponse{}
	for _, target := range req.GetTargets() {
		var next *walletv1.DebitGiftResponse
		if len(w.debits) == 0 {
			next = &walletv1.DebitGiftResponse{BillingReceiptId: fmt.Sprintf("receipt-%d", target.GetTargetUserId()), GiftPointAdded: 1, HeatValue: 1, CoinSpent: 1, ChargeAmount: 1}
		} else {
			next = w.debits[0]
			w.debits = w.debits[1:]
		}
		aggregate.BillingReceiptId = joinReceiptIDs(aggregate.GetBillingReceiptId(), next.GetBillingReceiptId())
		aggregate.CoinSpent += next.GetCoinSpent()
		aggregate.ChargeAmount += next.GetChargeAmount()
		aggregate.GiftPointAdded += next.GetGiftPointAdded()
		aggregate.HeatValue += next.GetHeatValue()
		aggregate.HostPeriodDiamondAdded += next.GetHostPeriodDiamondAdded()
		if aggregate.PriceVersion == "" {
			aggregate.PriceVersion = next.GetPriceVersion()
		}
		if aggregate.GiftTypeCode == "" {
			aggregate.GiftTypeCode = next.GetGiftTypeCode()
		}
		aggregate.BalanceAfter = next.GetBalanceAfter()
		receipts = append(receipts, &walletv1.BatchDebitGiftReceipt{
			TargetUserId: target.GetTargetUserId(),
			CommandId:    target.GetCommandId(),
			Billing:      next,
		})
	}
	return &walletv1.BatchDebitGiftResponse{Aggregate: aggregate, Receipts: receipts}, nil
}

func (w *rocketTestWallet) DebitRobotGift(_ context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) {
	w.robotDebitRequests = append(w.robotDebitRequests, proto.Clone(req).(*walletv1.DebitRobotGiftRequest))
	if len(w.debits) == 0 {
		return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-robot-default", HeatValue: 1, CoinSpent: 1, ChargeAmount: 1, ChargeAssetType: "ROBOT_COIN"}, nil
	}
	next := w.debits[0]
	w.debits = w.debits[1:]
	return next, nil
}

func (w *rocketTestWallet) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
	w.robotCatalogRequests = append(w.robotCatalogRequests, proto.Clone(req).(*walletv1.ListGiftConfigsRequest))
	items := make([]*walletv1.GiftConfig, 0, len(w.robotGiftConfigs))
	for _, gift := range w.robotGiftConfigs {
		items = append(items, proto.Clone(gift).(*walletv1.GiftConfig))
	}
	return &walletv1.ListGiftConfigsResponse{Gifts: items, Total: int64(len(items))}, nil
}

func joinReceiptIDs(left string, right string) string {
	if left == "" {
		return right
	}
	if right == "" {
		return left
	}
	return left + "," + right
}

func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-202", CoinSpent: 10, ChargeAmount: 10, GiftPointAdded: 5, HeatValue: 11, GiftTypeCode: "normal", PriceVersion: "v1", BalanceAfter: 90},
		{BillingReceiptId: "receipt-303", CoinSpent: 20, ChargeAmount: 20, GiftPointAdded: 7, HeatValue: 19, GiftTypeCode: "normal", PriceVersion: "v1", BalanceAfter: 70},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)

	roomID := "room-gift-multi-target"
	ownerID := int64(101)
	firstTargetID := int64(202)
	secondTargetID := int64(303)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
	joinRocketRoom(t, ctx, svc, roomID, secondTargetID)

	resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta: &roomv1.RequestMeta{
			RequestId:   "req-gift-multi",
			CommandId:   "cmd-gift-multi",
			ActorUserId: ownerID,
			RoomId:      roomID,
			AppCode:     appcode.Default,
			SentAtMs:    now.Now().UnixMilli(),
		},
		TargetType:    "user",
		TargetUserIds: []int64{firstTargetID, secondTargetID},
		GiftId:        "rose",
		GiftCount:     2,
		TargetHostScopes: []*roomv1.SendGiftTargetHostScope{
			{TargetUserId: firstTargetID, TargetIsHost: true, TargetHostRegionId: 8801, TargetAgencyOwnerUserId: 30001},
			{TargetUserId: secondTargetID, TargetIsHost: true, TargetHostRegionId: 8802, TargetAgencyOwnerUserId: 30002},
		},
	})
	if err != nil {
		t.Fatalf("send multi-target gift failed: %v", err)
	}
	if resp.GetRoomHeat() != 30 || resp.GetBillingReceiptId() != "receipt-202,receipt-303" {
		t.Fatalf("multi-target gift response must aggregate wallet results: %+v", resp)
	}
	if resp.GetCoinBalanceAfter() != 70 {
		t.Fatalf("multi-target gift must return latest sender balance: %d", resp.GetCoinBalanceAfter())
	}
	if firstUser := onlineUserByID(resp.GetRoom(), firstTargetID); firstUser == nil || firstUser.GetGiftValue() != 11 {
		t.Fatalf("first target must receive only its own heat value: %+v", firstUser)
	}
	if secondUser := onlineUserByID(resp.GetRoom(), secondTargetID); secondUser == nil || secondUser.GetGiftValue() != 19 {
		t.Fatalf("second target must receive only its own heat value: %+v", secondUser)
	}
	if len(wallet.debitRequests) != 0 {
		t.Fatalf("multi-target gift must use wallet BatchDebitGift, single debits=%+v", wallet.debitRequests)
	}
	if wallet.lastBatch == nil || len(wallet.lastBatch.GetTargets()) != 2 {
		t.Fatalf("wallet batch request missing targets: %+v", wallet.lastBatch)
	}
	if wallet.lastBatch.GetTargets()[0].GetCommandId() != "cmd-gift-multi:target:202" || wallet.lastBatch.GetTargets()[1].GetCommandId() != "cmd-gift-multi:target:303" {
		t.Fatalf("wallet batch target command ids mismatch: %+v", wallet.lastBatch.GetTargets())
	}
	if !wallet.lastBatch.GetTargets()[0].GetTargetIsHost() || wallet.lastBatch.GetTargets()[0].GetTargetHostRegionId() != 8801 || wallet.lastBatch.GetTargets()[0].GetTargetAgencyOwnerUserId() != 30001 ||
		!wallet.lastBatch.GetTargets()[1].GetTargetIsHost() || wallet.lastBatch.GetTargets()[1].GetTargetHostRegionId() != 8802 || wallet.lastBatch.GetTargets()[1].GetTargetAgencyOwnerUserId() != 30002 {
		t.Fatalf("wallet batch target host scopes mismatch: %+v", wallet.lastBatch.GetTargets())
	}

	events := roomGiftSentEvents(t, ctx, repository)
	if len(events) != 2 {
		t.Fatalf("multi-target gift must emit one GiftSent per target, got %d", len(events))
	}
	got := map[int64]*roomeventsv1.RoomGiftSent{}
	for _, event := range events {
		got[event.GetTargetUserId()] = event
	}
	if got[firstTargetID].GetBillingReceiptId() != "receipt-202" || got[firstTargetID].GetGiftValue() != 11 || got[firstTargetID].GetCommandId() != "cmd-gift-multi:target:202" {
		t.Fatalf("first target GiftSent mismatch: %+v", got[firstTargetID])
	}
	if got[firstTargetID].GetTargetGiftValue() != 11 {
		t.Fatalf("first target GiftSent must carry its cumulative value: %+v", got[firstTargetID])
	}
	if got[secondTargetID].GetBillingReceiptId() != "receipt-303" || got[secondTargetID].GetGiftValue() != 19 || got[secondTargetID].GetCommandId() != "cmd-gift-multi:target:303" {
		t.Fatalf("second target GiftSent mismatch: %+v", got[secondTargetID])
	}
	if got[secondTargetID].GetTargetGiftValue() != 19 {
		t.Fatalf("second target GiftSent must carry its cumulative value: %+v", got[secondTargetID])
	}
}

func TestSendGiftMirrorsGiftDisplayToDirectIMAndKeepsDurableFact(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 9, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
		BillingReceiptId:       "receipt-direct-im-gift",
		CoinSpent:              25,
		ChargeAmount:           25,
		HeatValue:              25,
		GiftTypeCode:           "normal",
		BalanceAfter:           975,
		GiftIncomeBalanceAfter: 120,
	}}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)

	roomID := "room-gift-direct-im"
	ownerID := int64(701)
	targetID := int64(702)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	before := outboxEventCounts(t, ctx, repository)

	resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, ownerID, "gift-direct-im"),
		TargetType:   "user",
		TargetUserId: targetID,
		GiftId:       "rose",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("send direct IM gift failed: %v", err)
	}
	if resp.GetCoinBalanceAfter() != 975 || resp.GetGiftIncomeBalanceAfter() != 120 {
		t.Fatalf("gift response must carry balance snapshots: %+v", resp)
	}

	after := outboxEventCounts(t, ctx, repository)
	if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 1 {
		t.Fatalf("RoomGiftSent durable fact must stay in room_outbox, delta=%d counts=%+v", delta, after)
	}
	for _, eventType := range []string{"RoomHeatChanged", "RoomRankChanged"} {
		if delta := after[eventType] - before[eventType]; delta != 0 {
			t.Fatalf("%s must not write room_outbox after directIM split, delta=%d counts=%+v", eventType, delta, after)
		}
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{
		"RoomGiftSent":    1,
		"RoomHeatChanged": 1,
		"RoomRankChanged": 1,
	})
}

func TestBatchSendGiftPublishesSingleBatchDisplayAndKeepsDurableFacts(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-batch-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 20, GiftTypeCode: "normal", BalanceAfter: 980, GiftName: "Clover", GiftAnimationUrl: "https://cdn.example/clover.svga"},
		{BillingReceiptId: "receipt-batch-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 30, GiftTypeCode: "normal", BalanceAfter: 950, GiftName: "Clover", GiftAnimationUrl: "https://cdn.example/clover.svga"},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)

	roomID := "room-gift-batch-direct-im"
	ownerID := int64(801)
	firstTargetID := int64(802)
	secondTargetID := int64(803)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
	joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
	before := outboxEventCounts(t, ctx, repository)

	resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "gift-batch-direct-im"),
		TargetType:    "user",
		TargetUserIds: []int64{firstTargetID, secondTargetID},
		GiftId:        "clover",
		GiftCount:     1,
	})
	if err != nil {
		t.Fatalf("batch send gift failed: %v", err)
	}
	if resp.GetCoinBalanceAfter() != 950 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 {
		t.Fatalf("batch response must include final balance and batch display: %+v", resp)
	}
	if resp.GetBatchDisplay().GetEventId() == "" || resp.GetBatchDisplay().GetTotalGiftValue() != 50 || resp.GetBatchDisplay().GetTargetUserIds()[0] != firstTargetID {
		t.Fatalf("batch display snapshot mismatch: %+v", resp.GetBatchDisplay())
	}

	after := outboxEventCounts(t, ctx, repository)
	if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 2 {
		t.Fatalf("batch send must keep one durable RoomGiftSent per target, delta=%d counts=%+v", delta, after)
	}
	if delta := after["RoomGiftBatchSent"] - before["RoomGiftBatchSent"]; delta != 0 {
		t.Fatalf("RoomGiftBatchSent is direct display only and must not enter durable outbox, delta=%d counts=%+v", delta, after)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{
		"RoomGiftBatchSent": 1,
		"RoomHeatChanged":   1,
		"RoomRankChanged":   1,
	})
	counts := directIM.counts()
	if counts["RoomGiftSent"] != 0 {
		t.Fatalf("batch display must suppress per-target GiftSent direct IM: %+v", counts)
	}
}

func TestBatchSendGiftSkipsTargetsNoLongerInRoom(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-online-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980},
		{BillingReceiptId: "receipt-online-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 31, GiftTypeCode: "normal", BalanceAfter: 950},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)

	roomID := "room-batch-skip-offline"
	ownerID := int64(8101)
	firstTargetID := int64(8102)
	offlineTargetID := int64(8103)
	secondTargetID := int64(8104)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
	joinRocketRoom(t, ctx, svc, roomID, offlineTargetID)
	joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlineTargetID, "batch-skip-offline-leave")}); err != nil {
		t.Fatalf("offline target leave failed: %v", err)
	}
	before := outboxEventCounts(t, ctx, repository)

	resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "batch-skip-offline"),
		TargetType:    "user",
		TargetUserIds: []int64{firstTargetID, offlineTargetID, secondTargetID},
		GiftId:        "clover",
		GiftCount:     1,
		TargetHostScopes: []*roomv1.SendGiftTargetHostScope{
			{TargetUserId: firstTargetID, TargetIsHost: true, TargetHostRegionId: 8801, TargetAgencyOwnerUserId: 30001},
			{TargetUserId: offlineTargetID, TargetIsHost: true, TargetHostRegionId: 8802, TargetAgencyOwnerUserId: 30002},
			{TargetUserId: secondTargetID, TargetIsHost: true, TargetHostRegionId: 8803, TargetAgencyOwnerUserId: 30003},
		},
		TargetDisplayProfiles: []*roomv1.SendGiftDisplayProfile{
			{UserId: firstTargetID, Username: "First Online"},
			{UserId: offlineTargetID, Username: "Offline"},
			{UserId: secondTargetID, Username: "Second Online"},
		},
	})
	if err != nil {
		t.Fatalf("batch gift with skipped target failed: %v", err)
	}
	if wallet.lastBatch == nil || len(wallet.lastBatch.GetTargets()) != 2 {
		t.Fatalf("batch wallet must only include online targets: %+v", wallet.lastBatch)
	}
	if wallet.lastBatch.GetTargets()[0].GetTargetUserId() != firstTargetID || wallet.lastBatch.GetTargets()[1].GetTargetUserId() != secondTargetID {
		t.Fatalf("batch wallet targets mismatch after filtering: %+v", wallet.lastBatch.GetTargets())
	}
	if wallet.lastBatch.GetTargets()[0].GetTargetHostRegionId() != 8801 || wallet.lastBatch.GetTargets()[1].GetTargetHostRegionId() != 8803 {
		t.Fatalf("batch wallet host scopes must be filtered with targets: %+v", wallet.lastBatch.GetTargets())
	}
	if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) || len(display.GetTargets()) != 2 {
		t.Fatalf("batch display must expose actual delivered targets: %+v", display)
	}
	after := outboxEventCounts(t, ctx, repository)
	if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 2 {
		t.Fatalf("filtered batch send must keep one durable RoomGiftSent per delivered target, delta=%d counts=%+v", delta, after)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
	batchEvents := roomDirectIMGiftBatchSentEvents(t, directIM)
	if len(batchEvents) != 1 || !slices.Equal(batchEvents[0].GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) || len(batchEvents[0].GetTargets()) != 2 {
		t.Fatalf("direct IM batch event must only include delivered targets: %+v", batchEvents)
	}

	record, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-skip-offline").GetCommandId())
	if err != nil {
		t.Fatalf("get batch gift command log failed: %v", err)
	}
	if !exists {
		t.Fatalf("batch gift command log must be persisted")
	}
	decoded, err := command.Deserialize(command.SendGift{}.Type(), record.Payload)
	if err != nil {
		t.Fatalf("decode batch gift command log failed: %v", err)
	}
	giftCmd := decoded.(*command.SendGift)
	if !slices.Equal(giftCmd.TargetUserIDs, []int64{firstTargetID, secondTargetID}) || !slices.Equal(giftCmd.RequestedTargetUserIDs, []int64{firstTargetID, offlineTargetID, secondTargetID}) {
		t.Fatalf("command log must keep delivered and requested targets separately: %+v", giftCmd)
	}
	if _, exists := giftCmd.TargetGiftValues[offlineTargetID]; exists {
		t.Fatalf("command log must not write gift value for skipped target: %+v", giftCmd.TargetGiftValues)
	}
}

func TestBatchSendGiftSkipsOfflineTargetAndFallsBackToSingleDebit(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 30, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-single-online", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)

	roomID := "room-batch-skip-to-single"
	ownerID := int64(8201)
	onlineTargetID := int64(8202)
	offlineTargetID := int64(8203)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, onlineTargetID)

	resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "batch-skip-to-single"),
		TargetType:    "user",
		TargetUserIds: []int64{onlineTargetID, offlineTargetID},
		GiftId:        "clover",
		GiftCount:     1,
	})
	if err != nil {
		t.Fatalf("batch gift filtered to one target failed: %v", err)
	}
	if wallet.lastBatch != nil || wallet.lastDebit == nil || wallet.lastDebit.GetTargetUserId() != onlineTargetID {
		t.Fatalf("filtered single target must use single wallet debit: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch)
	}
	if wallet.lastDebit.GetCommandId() != rocketMeta(roomID, ownerID, "batch-skip-to-single").GetCommandId() {
		t.Fatalf("filtered single target must preserve root command id for wallet idempotency: %+v", wallet.lastDebit)
	}
	if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{onlineTargetID}) || len(display.GetTargets()) != 1 {
		t.Fatalf("batch response must report the single delivered target: %+v", display)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
}

func TestBatchSendGiftIdempotencyUsesRequestedTargetsAfterPrimarySkipped(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 35, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-idempotent-online", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)

	roomID := "room-batch-skip-primary-idempotency"
	ownerID := int64(8231)
	offlinePrimaryID := int64(8232)
	onlineTargetID := int64(8233)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, offlinePrimaryID)
	joinRocketRoom(t, ctx, svc, roomID, onlineTargetID)
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlinePrimaryID, "batch-skip-primary-leave")}); err != nil {
		t.Fatalf("primary target leave failed: %v", err)
	}

	req := &roomv1.SendGiftRequest{
		Meta:                    rocketMeta(roomID, ownerID, "batch-skip-primary"),
		TargetType:              "user",
		TargetUserIds:           []int64{offlinePrimaryID, onlineTargetID},
		TargetUserId:            offlinePrimaryID,
		TargetIsHost:            true,
		TargetHostRegionId:      8802,
		TargetAgencyOwnerUserId: 30002,
		GiftId:                  "clover",
		GiftCount:               1,
	}
	resp, err := svc.SendGiftBatch(ctx, req)
	if err != nil {
		t.Fatalf("first batch gift with skipped primary failed: %v", err)
	}
	if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{onlineTargetID}) {
		t.Fatalf("first response must report delivered online target: %+v", display)
	}
	if wallet.lastDebit == nil || wallet.lastDebit.GetTargetUserId() != onlineTargetID || len(wallet.debitRequests) != 1 {
		t.Fatalf("first send must debit only delivered target once: single=%+v requests=%+v", wallet.lastDebit, wallet.debitRequests)
	}

	if _, err := svc.SendGiftBatch(ctx, req); err != nil {
		t.Fatalf("idempotent retry must compare requested targets, not filtered payload: %v", err)
	}
	if len(wallet.debitRequests) != 1 {
		t.Fatalf("idempotent retry must not call wallet again: requests=%+v", wallet.debitRequests)
	}
}

func TestBatchSendGiftRejectsOriginalSingleTarget(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 45, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-single-batch", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)

	roomID := "room-batch-single-online"
	ownerID := int64(8251)
	targetID := int64(8252)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	before := outboxEventCounts(t, ctx, repository)

	_, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "batch-single-online"),
		TargetType:    "user",
		TargetUserIds: []int64{targetID},
		GiftId:        "clover",
		GiftCount:     1,
	})
	if err == nil || xerr.CodeOf(err) != xerr.InvalidArgument {
		t.Fatalf("single-target batch gift must be rejected before wallet debit, err=%v", err)
	}
	if wallet.lastBatch != nil || wallet.lastDebit != nil {
		t.Fatalf("single-target batch must not call wallet: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch)
	}
	after := outboxEventCounts(t, ctx, repository)
	if after["RoomGiftSent"] != before["RoomGiftSent"] || after["RoomGiftBatchSent"] != before["RoomGiftBatchSent"] {
		t.Fatalf("single-target batch must not write gift outbox: before=%+v after=%+v", before, after)
	}
	if _, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-single-online").GetCommandId()); err != nil || exists {
		t.Fatalf("single-target batch must not write command log: exists=%t err=%v", exists, err)
	}
}

func TestBatchSendGiftFailsWhenAllTargetsLeftRoom(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 2, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{}
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher())

	roomID := "room-batch-all-targets-left"
	ownerID := int64(8301)
	firstTargetID := int64(8302)
	secondTargetID := int64(8303)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
	joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, firstTargetID, "batch-all-left-first")}); err != nil {
		t.Fatalf("first target leave failed: %v", err)
	}
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, secondTargetID, "batch-all-left-second")}); err != nil {
		t.Fatalf("second target leave failed: %v", err)
	}
	before := outboxEventCounts(t, ctx, repository)

	_, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "batch-all-left"),
		TargetType:    "user",
		TargetUserIds: []int64{firstTargetID, secondTargetID},
		GiftId:        "clover",
		GiftCount:     1,
	})
	if err == nil || xerr.CodeOf(err) != xerr.NotFound {
		t.Fatalf("batch gift with no online targets must fail with NOT_FOUND, err=%v", err)
	}
	if wallet.lastDebit != nil || wallet.lastBatch != nil {
		t.Fatalf("failed batch gift must not call wallet: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch)
	}
	after := outboxEventCounts(t, ctx, repository)
	if after["RoomGiftSent"] != before["RoomGiftSent"] || after["RoomGiftBatchSent"] != before["RoomGiftBatchSent"] {
		t.Fatalf("failed batch gift must not write gift outbox: before=%+v after=%+v", before, after)
	}
	if _, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-all-left").GetCommandId()); err != nil || exists {
		t.Fatalf("failed batch gift must not write command log: exists=%t err=%v", exists, err)
	}
}

func TestBatchLuckyGiftSkipsOfflineTargetsBeforeDraw(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 2, 30, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-lucky-online-1", CoinSpent: 60, ChargeAmount: 60, HeatValue: 60, GiftTypeCode: "super_lucky", BalanceAfter: 940},
		{BillingReceiptId: "receipt-lucky-online-2", CoinSpent: 40, ChargeAmount: 40, HeatValue: 40, GiftTypeCode: "super_lucky", BalanceAfter: 900},
	}}
	luckyGift := &batchLuckyGiftTestClient{createdAtBase: now.Now().UnixMilli()}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := roomservice.New(roomservice.Config{
		NodeID:               "node-batch-lucky-skip-offline-test",
		LeaseTTL:             10 * time.Second,
		RankLimit:            20,
		SnapshotEveryN:       1,
		Clock:                now,
		RoomDisplayPublisher: directIM,
	}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)

	roomID := "room-batch-lucky-skip-offline"
	ownerID := int64(8401)
	firstTargetID := int64(8402)
	offlineTargetID := int64(8403)
	secondTargetID := int64(8404)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
	joinRocketRoom(t, ctx, svc, roomID, offlineTargetID)
	joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlineTargetID, "batch-lucky-skip-offline-leave")}); err != nil {
		t.Fatalf("offline target leave failed: %v", err)
	}

	resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "batch-lucky-skip-offline"),
		TargetType:    "user",
		TargetUserIds: []int64{firstTargetID, offlineTargetID, secondTargetID},
		GiftId:        "super-lucky-clover",
		GiftCount:     1,
		PoolId:        "super_lucky",
	})
	if err != nil {
		t.Fatalf("batch lucky gift with skipped target failed: %v", err)
	}
	if checks, singleDraws, batchDraws, batchCalls := luckyGift.stats(); checks != 1 || singleDraws != 0 || batchDraws != 2 || batchCalls != 1 {
		t.Fatalf("batch lucky must draw only online targets: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls)
	}
	if len(resp.GetLuckyGifts()) != 2 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 {
		t.Fatalf("batch lucky response must only include delivered targets: %+v", resp)
	}
	if !slices.Equal(resp.GetBatchDisplay().GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) {
		t.Fatalf("batch lucky display target ids mismatch: %+v", resp.GetBatchDisplay())
	}
}

func TestSendGiftDisplayModeDoesNotTriggerBatchDisplay(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 10, 30, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-normal-multi-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 20, GiftTypeCode: "normal", BalanceAfter: 980},
		{BillingReceiptId: "receipt-normal-multi-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 30, GiftTypeCode: "normal", BalanceAfter: 950},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)

	roomID := "room-gift-normal-multi-display-mode"
	ownerID := int64(811)
	firstTargetID := int64(812)
	secondTargetID := int64(813)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
	joinRocketRoom(t, ctx, svc, roomID, secondTargetID)

	resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "gift-normal-multi-display-mode"),
		TargetType:    "user",
		TargetUserIds: []int64{firstTargetID, secondTargetID},
		GiftId:        "clover",
		GiftCount:     1,
		DisplayMode:   "batch",
	})
	if err != nil {
		t.Fatalf("normal multi-target send gift failed: %v", err)
	}
	if resp.GetBatchDisplay() != nil {
		t.Fatalf("plain SendGift must not create batch display: %+v", resp.GetBatchDisplay())
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftSent": 2})
}

func TestSendGiftKeepsStrictTargetPresenceValidation(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 3, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{}
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher())

	roomID := "room-normal-gift-strict-presence"
	ownerID := int64(8501)
	onlineTargetID := int64(8502)
	offlineTargetID := int64(8503)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, onlineTargetID)

	_, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "normal-gift-strict-presence"),
		TargetType:    "user",
		TargetUserIds: []int64{onlineTargetID, offlineTargetID},
		GiftId:        "clover",
		GiftCount:     1,
	})
	if xerr.CodeOf(err) != xerr.NotFound {
		t.Fatalf("plain SendGift must keep strict target presence validation, err=%v", err)
	}
	if wallet.lastDebit != nil || wallet.lastBatch != nil || len(wallet.debitRequests) != 0 {
		t.Fatalf("plain SendGift strict validation must run before wallet debit: single=%+v batch=%+v requests=%+v", wallet.lastDebit, wallet.lastBatch, wallet.debitRequests)
	}
}

func TestBatchLuckyGiftUsesSingleActivityBatchDrawAndDebitCoinBalance(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 11, 0, 0, 0, time.UTC)}
	debits := make([]*walletv1.DebitGiftResponse, 0, 10)
	for index := 0; index < 10; index++ {
		debits = append(debits, &walletv1.DebitGiftResponse{
			BillingReceiptId: fmt.Sprintf("receipt-lucky-batch-%d", index),
			CoinSpent:        10,
			ChargeAmount:     10,
			HeatValue:        10,
			GiftTypeCode:     "super_lucky",
			BalanceAfter:     int64(990 - index*10),
		})
	}
	wallet := &rocketTestWallet{debits: debits}
	luckyGift := &batchLuckyGiftTestClient{drawDelay: 20 * time.Millisecond, createdAtBase: now.Now().UnixMilli()}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := roomservice.New(roomservice.Config{
		NodeID:               "node-batch-lucky-test",
		LeaseTTL:             10 * time.Second,
		RankLimit:            20,
		SnapshotEveryN:       1,
		RoomDisplayPublisher: directIM,
	}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)

	roomID := "room-batch-lucky"
	ownerID := int64(901)
	targetUserIDs := make([]int64, 0, 10)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	for offset := int64(0); offset < 10; offset++ {
		userID := int64(902) + offset
		targetUserIDs = append(targetUserIDs, userID)
		joinRocketRoom(t, ctx, svc, roomID, userID)
	}

	resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "batch-lucky"),
		TargetType:    "user",
		TargetUserIds: targetUserIDs,
		GiftId:        "super-lucky-clover",
		GiftCount:     1,
		PoolId:        "super_lucky",
	})
	if err != nil {
		t.Fatalf("batch lucky gift failed: %v", err)
	}
	checks, singleDraws, batchDraws, batchCalls := luckyGift.stats()
	if checks != 1 || singleDraws != 0 || batchDraws != 10 || batchCalls != 1 {
		t.Fatalf("batch lucky must check once and call one activity batch draw: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls)
	}
	if len(wallet.balanceRequests) != 0 {
		t.Fatalf("batch lucky must not query final sender coin balance: %+v", wallet.balanceRequests)
	}
	if resp.GetCoinBalanceAfter() != 900 || resp.GetLuckyGift().GetCoinBalanceAfter() != 0 {
		t.Fatalf("batch lucky HTTP balance must stay at debit result and lucky balance must stay empty: %+v", resp)
	}
	if len(resp.GetLuckyGifts()) != 10 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 10 {
		t.Fatalf("batch lucky response must keep per-target lucky details and batch display: %+v", resp)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
}

func TestBatchLuckyGiftUpdatesEachMicGiftValueAndBatchDisplay(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 12, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-batch-lucky-seat-1", CoinSpent: 60, ChargeAmount: 60, HeatValue: 60, GiftTypeCode: "super_lucky", BalanceAfter: 940},
		{BillingReceiptId: "receipt-batch-lucky-seat-2", CoinSpent: 40, ChargeAmount: 40, HeatValue: 40, GiftTypeCode: "super_lucky", BalanceAfter: 900},
	}}
	luckyGift := &batchLuckyGiftTestClient{createdAtBase: now.Now().UnixMilli()}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := roomservice.New(roomservice.Config{
		NodeID:               "node-batch-lucky-mic-value-test",
		LeaseTTL:             10 * time.Second,
		RankLimit:            20,
		SnapshotEveryN:       1,
		Clock:                now,
		RoomDisplayPublisher: directIM,
	}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)

	roomID := "room-batch-lucky-mic-value"
	ownerID := int64(931)
	firstTargetID := int64(932)
	secondTargetID := int64(933)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
	joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
	if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
		Meta:   rocketMeta(roomID, firstTargetID, "batch-lucky-first-mic-up"),
		SeatNo: 2,
	}); err != nil {
		t.Fatalf("first target mic up failed: %v", err)
	}
	if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
		Meta:   rocketMeta(roomID, secondTargetID, "batch-lucky-second-mic-up"),
		SeatNo: 3,
	}); err != nil {
		t.Fatalf("second target mic up failed: %v", err)
	}

	resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
		Meta:          rocketMeta(roomID, ownerID, "batch-lucky-mic-value"),
		TargetType:    "user",
		TargetUserIds: []int64{firstTargetID, secondTargetID},
		GiftId:        "super-lucky-seat-gift",
		GiftCount:     1,
		PoolId:        "super_lucky",
	})
	if err != nil {
		t.Fatalf("batch lucky gift with mic targets failed: %v", err)
	}
	if checks, singleDraws, batchDraws, batchCalls := luckyGift.stats(); checks != 1 || singleDraws != 0 || batchDraws != 2 || batchCalls != 1 {
		t.Fatalf("batch lucky mic gift must check once and call one activity batch draw: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls)
	}
	if user := onlineUserByID(resp.GetRoom(), firstTargetID); user == nil || user.GetGiftValue() != 60 {
		t.Fatalf("first target online gift_value mismatch: %+v", user)
	}
	if user := onlineUserByID(resp.GetRoom(), secondTargetID); user == nil || user.GetGiftValue() != 40 {
		t.Fatalf("second target online gift_value mismatch: %+v", user)
	}
	if seat := seatByNo(resp.GetRoom(), 2); seat == nil || seat.GetUserId() != firstTargetID || seat.GetGiftValue() != 60 {
		t.Fatalf("first target mic seat gift_value mismatch: %+v", seat)
	}
	if seat := seatByNo(resp.GetRoom(), 3); seat == nil || seat.GetUserId() != secondTargetID || seat.GetGiftValue() != 40 {
		t.Fatalf("second target mic seat gift_value mismatch: %+v", seat)
	}
	if resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 || len(resp.GetLuckyGifts()) != 2 {
		t.Fatalf("batch lucky mic response must keep batch display and per-target lucky results: %+v", resp)
	}
	if first := resp.GetBatchDisplay().GetTargets()[0]; first.GetTargetUserId() != firstTargetID || first.GetTargetGiftValue() != 60 || first.GetLuckyGift() == nil {
		t.Fatalf("first batch display target mismatch: %+v", first)
	}
	if second := resp.GetBatchDisplay().GetTargets()[1]; second.GetTargetUserId() != secondTargetID || second.GetTargetGiftValue() != 40 || second.GetLuckyGift() == nil {
		t.Fatalf("second batch display target mismatch: %+v", second)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
	batchEvents := roomDirectIMGiftBatchSentEvents(t, directIM)
	if len(batchEvents) != 1 || len(batchEvents[0].GetTargets()) != 2 {
		t.Fatalf("batch lucky mic gift must publish one batch IM with two targets: %+v", batchEvents)
	}
	if first := batchEvents[0].GetTargets()[0]; first.GetTargetUserId() != firstTargetID || first.GetTargetGiftValue() != 60 || first.GetLuckyGift() == nil {
		t.Fatalf("first batch IM target mismatch: %+v", first)
	}
	if second := batchEvents[0].GetTargets()[1]; second.GetTargetUserId() != secondTargetID || second.GetTargetGiftValue() != 40 || second.GetLuckyGift() == nil {
		t.Fatalf("second batch IM target mismatch: %+v", second)
	}
}

func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
	w.grants = append(w.grants, req)
	return &walletv1.ResourceGrantResponse{
		Grant: &walletv1.ResourceGrant{
			GrantId:      fmt.Sprintf("grant-%d-%d", req.GetTargetUserId(), req.GetGroupId()),
			CommandId:    req.GetCommandId(),
			TargetUserId: req.GetTargetUserId(),
		},
	}, nil
}

func (w *rocketTestWallet) GetMyVip(_ context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error) {
	w.vipRequests = append(w.vipRequests, proto.Clone(req).(*walletv1.GetMyVipRequest))
	if w.vipError != nil {
		return nil, w.vipError
	}
	if response := w.vipResponses[req.GetUserId()]; response != nil {
		return proto.Clone(response).(*walletv1.GetMyVipResponse), nil
	}
	return legacyVIPResponse(req.GetUserId()), nil
}

func (w *rocketTestWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
	w.balanceRequests = append(w.balanceRequests, req)
	if len(w.balanceResponses) == 0 {
		return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{
			{AssetType: "COIN"},
		}}, nil
	}
	next := w.balanceResponses[0]
	w.balanceResponses = w.balanceResponses[1:]
	return proto.Clone(next).(*walletv1.GetBalancesResponse), nil
}

type batchLuckyGiftTestClient struct {
	mu            sync.Mutex
	checks        []*luckygiftv1.CheckLuckyGiftRequest
	draws         []*luckygiftv1.ExecuteLuckyGiftDrawRequest
	batchDraws    []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest
	inFlight      int
	maxInFlight   int
	drawDelay     time.Duration
	createdAtBase int64
}

func (c *batchLuckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
	c.mu.Lock()
	c.checks = append(c.checks, req)
	c.mu.Unlock()
	return &luckygiftv1.CheckLuckyGiftResponse{
		Enabled:        true,
		Reason:         "enabled",
		GiftId:         req.GetGiftId(),
		PoolId:         req.GetPoolId(),
		RuleVersion:    12,
		ExperiencePool: "novice",
	}, nil
}

func (c *batchLuckyGiftTestClient) ExecuteLuckyGiftDraw(ctx context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) {
	c.mu.Lock()
	c.draws = append(c.draws, req)
	drawIndex := len(c.draws)
	c.inFlight++
	if c.inFlight > c.maxInFlight {
		c.maxInFlight = c.inFlight
	}
	c.mu.Unlock()
	defer func() {
		c.mu.Lock()
		c.inFlight--
		c.mu.Unlock()
	}()

	if c.drawDelay > 0 {
		timer := time.NewTimer(c.drawDelay)
		select {
		case <-ctx.Done():
			timer.Stop()
			return nil, ctx.Err()
		case <-timer.C:
		}
	}
	meta := req.GetLuckyGift()
	return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{
		DrawId:               fmt.Sprintf("draw-%d", meta.GetTargetUserId()),
		CommandId:            meta.GetCommandId(),
		PoolId:               meta.GetPoolId(),
		GiftId:               meta.GetGiftId(),
		RuleVersion:          12,
		ExperiencePool:       "novice",
		SelectedTierId:       "tier",
		MultiplierPpm:        2_000_000,
		BaseRewardCoins:      100,
		EffectiveRewardCoins: 100,
		RewardStatus:         "granted",
		WalletTransactionId:  fmt.Sprintf("wallet-tx-%d", drawIndex),
		CoinBalanceAfter:     int64(9000 + drawIndex),
		CreatedAtMs:          c.createdAtBase + int64(drawIndex),
	}}, nil
}

func (c *batchLuckyGiftTestClient) BatchExecuteLuckyGiftDraw(ctx context.Context, req *luckygiftv1.BatchExecuteLuckyGiftDrawRequest) (*luckygiftv1.BatchExecuteLuckyGiftDrawResponse, error) {
	c.mu.Lock()
	c.batchDraws = append(c.batchDraws, req)
	drawOffset := len(c.draws)
	c.inFlight++
	if c.inFlight > c.maxInFlight {
		c.maxInFlight = c.inFlight
	}
	c.mu.Unlock()
	defer func() {
		c.mu.Lock()
		c.inFlight--
		c.mu.Unlock()
	}()

	if c.drawDelay > 0 {
		timer := time.NewTimer(c.drawDelay)
		select {
		case <-ctx.Done():
			timer.Stop()
			return nil, ctx.Err()
		case <-timer.C:
		}
	}
	resp := &luckygiftv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*luckygiftv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts()))}
	for index, meta := range req.GetLuckyGifts() {
		drawIndex := drawOffset + index + 1
		resp.Results = append(resp.Results, &luckygiftv1.LuckyGiftDrawResult{
			DrawId:               fmt.Sprintf("draw-%d", meta.GetTargetUserId()),
			CommandId:            meta.GetCommandId(),
			PoolId:               meta.GetPoolId(),
			GiftId:               meta.GetGiftId(),
			RuleVersion:          12,
			ExperiencePool:       "novice",
			SelectedTierId:       "tier",
			MultiplierPpm:        2_000_000,
			BaseRewardCoins:      100,
			EffectiveRewardCoins: 100,
			RewardStatus:         "pending",
			CreatedAtMs:          c.createdAtBase + int64(drawIndex),
		})
	}
	return resp, nil
}

func (c *batchLuckyGiftTestClient) stats() (checks int, singleDraws int, batchDraws int, batchCalls int) {
	c.mu.Lock()
	defer c.mu.Unlock()
	totalBatchDraws := 0
	for _, req := range c.batchDraws {
		totalBatchDraws += len(req.GetLuckyGifts())
	}
	return len(c.checks), len(c.draws), totalBatchDraws, len(c.batchDraws)
}

type rocketLaunchScheduleRecorder struct {
	schedules []integration.RoomRocketLaunchSchedule
}

func (r *rocketLaunchScheduleRecorder) ScheduleRoomRocketLaunch(_ context.Context, schedule integration.RoomRocketLaunchSchedule) error {
	r.schedules = append(r.schedules, schedule)
	return nil
}

func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-fill", GiftPointAdded: 250, HeatValue: 250, GiftTypeCode: "normal"},
		{BillingReceiptId: "receipt-next", GiftPointAdded: 50, HeatValue: 50, GiftTypeCode: "normal"},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))

	roomID := "room-rocket-flow"
	ownerID := int64(101)
	viewerID := int64(202)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, viewerID)
	if _, err := svc.SetRoomPassword(ctx, &roomv1.SetRoomPasswordRequest{
		Meta:     rocketMeta(roomID, ownerID, "lock-before-ignite"),
		Locked:   true,
		Password: "1234",
	}); err != nil {
		t.Fatalf("lock rocket room failed: %v", err)
	}

	fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, ownerID, "fill"),
		TargetType:   "user",
		TargetUserId: viewerID,
		GiftId:       "gift-overflow",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("send fill gift failed: %v", err)
	}
	rocket := fillResp.GetRocket()
	if rocket.GetCurrentLevel() != 2 || rocket.GetCurrentFuel() != 0 || rocket.GetStatus() != state.RocketStatusCharging {
		t.Fatalf("fill gift must ignite level 1 and immediately start level 2: %+v", rocket)
	}
	if len(rocket.GetPendingLaunches()) != 1 {
		t.Fatalf("filled level must create exactly one pending launch: %+v", rocket)
	}
	pending := rocket.GetPendingLaunches()[0]
	if pending.GetLevel() != 1 || pending.GetFuelThreshold() != 100 || pending.GetLaunchAtMs() != now.Now().Add(time.Second).UnixMilli() {
		t.Fatalf("pending launch must lock the filled level snapshot: %+v", pending)
	}
	if rocket.GetResetAtMs() != time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC).UnixMilli() {
		t.Fatalf("rocket timers must use UTC business time: %+v", rocket)
	}
	if pending.GetTop1UserId() != ownerID || pending.GetIgniterUserId() != ownerID {
		t.Fatalf("top1 and igniter must lock at ignited start: %+v", pending)
	}

	flushRocketProgress(t, ctx, svc, now)
	progressEvents := roomDirectIMRocketProgressEvents(t, directIM)
	if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 250 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 150 {
		t.Fatalf("progress event must expose effective fuel and discarded overflow: %+v", progressEvents)
	}
	if progressEvents[0].GetLevel() != 1 || progressEvents[0].GetStatus() != state.RocketStatusIgnited {
		t.Fatalf("progress event must describe the filled level, got %+v", progressEvents[0])
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketIgnited": 1})
	ignitedRecords := directIM.recordsByType("RoomRocketIgnited")
	var ignited roomeventsv1.RoomRocketIgnited
	if len(ignitedRecords) != 1 || proto.Unmarshal(ignitedRecords[0].GetBody(), &ignited) != nil || !ignited.GetRoomLocked() || ignited.GetOwnerUserId() != ownerID {
		t.Fatalf("rocket ignition must carry the Room Cell lock snapshot: records=%+v event=%+v", ignitedRecords, &ignited)
	}
	if got := outboxEventCounts(t, ctx, repository)["RoomRocketIgnited"]; got != 1 {
		t.Fatalf("fill gift must keep one RoomRocketIgnited outbox for launch scheduling, got %d", got)
	}

	nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, viewerID, "next-level"),
		TargetType:   "user",
		TargetUserId: ownerID,
		GiftId:       "gift-next-level",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("send next-level gift failed: %v", err)
	}
	if nextResp.GetRocket().GetCurrentLevel() != 2 || nextResp.GetRocket().GetCurrentFuel() != 50 || nextResp.GetRocket().GetStatus() != state.RocketStatusCharging {
		t.Fatalf("gift after ignition must add fuel to the next level immediately: %+v", nextResp.GetRocket())
	}
	if len(nextResp.GetRocket().GetPendingLaunches()) != 1 {
		t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket())
	}
	flushRocketProgress(t, ctx, svc, now)
	progressEvents = roomDirectIMRocketProgressEvents(t, directIM)
	if len(progressEvents) != 2 || progressEvents[1].GetLevel() != 2 || progressEvents[1].GetEffectiveAddedFuel() != 50 || progressEvents[1].GetOverflowFuel() != 0 {
		t.Fatalf("second progress event must target level 2: %+v", progressEvents)
	}
	if got := directIM.counts()["RoomRocketIgnited"]; got != 1 {
		t.Fatalf("next-level partial gift must not emit another ignited event, got %d", got)
	}
}

func TestApplyRoomRPSGiftChargesRocketOnceWithoutOrdinaryGiftFact(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
	svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, newRecordingRoomDirectIMPublisher())
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(1000, 1000))
	createRocketRoom(t, ctx, svc, "room-rps-rocket", 101, 9001)

	req := &roomv1.ApplyRoomRPSGiftRequest{
		Meta:   &roomv1.RequestMeta{AppCode: "lalu", RequestId: "req-rps", CommandId: "room_rps_gift_challenge-1", ActorUserId: 202, RoomId: "room-rps-rocket"},
		RoomId: "room-rps-rocket", SenderUserId: 202, TargetUserId: 101, GiftId: "10001", HeatValue: 100,
	}
	first, err := svc.ApplyRoomRPSGift(ctx, req)
	if err != nil {
		t.Fatalf("ApplyRoomRPSGift() error = %v", err)
	}
	replay, err := svc.ApplyRoomRPSGift(ctx, req)
	if err != nil {
		t.Fatalf("ApplyRoomRPSGift() replay error = %v", err)
	}
	if first.GetRocket().GetCurrentFuel() != 100 || replay.GetRocket().GetCurrentFuel() != 100 || replay.GetResult().GetApplied() {
		t.Fatalf("PK gift must fuel once and replay from command log: first=%+v replay=%+v", first, replay)
	}
	if got := outboxEventCounts(t, ctx, repository)["RoomGiftSent"]; got != 0 {
		t.Fatalf("PK settlement must not fabricate an ordinary RoomGiftSent fact, got %d", got)
	}
}

func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 30, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-progress-10", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"},
		{BillingReceiptId: "receipt-progress-20", GiftPointAdded: 20, HeatValue: 20, GiftTypeCode: "normal"},
		{BillingReceiptId: "receipt-progress-30", GiftPointAdded: 30, HeatValue: 30, GiftTypeCode: "normal"},
	}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(1000, 1_000))

	roomID := "room-rocket-progress-coalesce"
	ownerID := int64(111)
	viewerID := int64(222)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, viewerID)
	before := outboxEventCounts(t, ctx, repository)

	for index := 1; index <= 3; index++ {
		if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
			Meta:         rocketMeta(roomID, ownerID, fmt.Sprintf("progress-coalesce-%d", index)),
			TargetType:   "user",
			TargetUserId: viewerID,
			GiftId:       fmt.Sprintf("gift-progress-%d", index),
			GiftCount:    1,
		}); err != nil {
			t.Fatalf("send coalesced progress gift %d failed: %v", index, err)
		}
	}

	beforeFlush := outboxEventCounts(t, ctx, repository)
	if delta := beforeFlush["RoomGiftSent"] - before["RoomGiftSent"]; delta != 3 {
		t.Fatalf("RoomGiftSent must stay durable per gift, delta=%d counts=%+v", delta, beforeFlush)
	}
	for _, eventType := range []string{"RoomHeatChanged", "RoomRankChanged"} {
		if delta := beforeFlush[eventType] - before[eventType]; delta != 0 {
			t.Fatalf("%s must move to direct IM instead of durable outbox, delta=%d counts=%+v", eventType, delta, beforeFlush)
		}
	}
	if delta := beforeFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 {
		t.Fatalf("rocket progress must not write per gift before flush, delta=%d counts=%+v", delta, beforeFlush)
	}

	flushRocketProgress(t, ctx, svc, now)
	afterFlush := outboxEventCounts(t, ctx, repository)
	if delta := afterFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 {
		t.Fatalf("rocket progress must flush to direct IM instead of durable outbox, delta=%d counts=%+v", delta, afterFlush)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketFuelChanged": 1})
}

func TestRoomRocketIgnitedOutboxSchedulesDelayedLaunch(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-fill", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
	}}
	scheduler := &rocketLaunchScheduleRecorder{}
	svc := newRocketTestServiceWithScheduler(t, repository, wallet, now, scheduler)
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))

	roomID := "room-rocket-schedule"
	ownerID := int64(801)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9008)
	if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, ownerID, "fill"),
		TargetType:   "user",
		TargetUserId: ownerID,
		GiftId:       "gift-fill",
		GiftCount:    1,
	}); err != nil {
		t.Fatalf("fill rocket failed: %v", err)
	}

	if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{PublishTimeout: time.Second, BatchSize: 100}); err != nil {
		t.Fatalf("process outbox failed: %v", err)
	}
	if len(scheduler.schedules) != 1 {
		t.Fatalf("scheduled launches=%d, want 1: %+v", len(scheduler.schedules), scheduler.schedules)
	}
	scheduled := scheduler.schedules[0]
	if scheduled.RoomID != roomID || scheduled.Level != 1 || scheduled.LaunchAtMS != now.Now().Add(time.Second).UnixMilli() || scheduled.ResetAtMS == 0 || scheduled.CommandID == "" {
		t.Fatalf("unexpected delayed launch schedule: %+v", scheduled)
	}
}

func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-ignite", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
	}}
	svc := newRocketTestService(t, repository, wallet, now)
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))

	roomID := "room-rocket-mq-launch"
	ownerID := int64(901)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9009)
	resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, ownerID, "ignite"),
		TargetType:   "user",
		TargetUserId: ownerID,
		GiftId:       "gift-ignite",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("ignite rocket failed: %v", err)
	}
	rocket := resp.GetRocket()
	if len(rocket.GetPendingLaunches()) != 1 {
		t.Fatalf("ignite should create one pending launch: %+v", rocket)
	}
	pending := rocket.GetPendingLaunches()[0]
	due := roommq.RoomRocketLaunchDueMessage{
		MessageType: roommq.MessageTypeRoomRocketLaunchDue,
		AppCode:     appcode.Default,
		RoomID:      roomID,
		RocketID:    pending.GetRocketId(),
		Level:       pending.GetLevel(),
		LaunchAtMS:  pending.GetLaunchAtMs(),
		ResetAtMS:   pending.GetResetAtMs(),
		CommandID:   "cmd_room_rocket_launch_" + pending.GetRocketId(),
	}

	if err := svc.HandleRoomRocketLaunchDue(ctx, due); err == nil {
		t.Fatal("early delayed message should ask MQ to retry")
	}
	now.now = now.now.Add(time.Second)
	if err := svc.HandleRoomRocketLaunchDue(ctx, due); err != nil {
		t.Fatalf("due delayed message should launch rocket after retry: %v", err)
	}
	info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
		RoomId:       roomID,
		ViewerUserId: ownerID,
		Meta: &roomv1.RequestMeta{
			AppCode:   appcode.Default,
			RequestId: "req-check",
		},
	})
	if err != nil {
		t.Fatalf("get rocket failed: %v", err)
	}
	if info.GetRocket().GetState().GetStatus() != state.RocketStatusCharging || info.GetRocket().GetState().GetCurrentLevel() != 2 || len(info.GetRocket().GetState().GetPendingLaunches()) != 0 {
		t.Fatalf("rocket should launch once after early retry: %+v", info.GetRocket().GetState())
	}
	if len(wallet.grants) == 0 {
		t.Fatal("launch should grant configured rewards after retry")
	}
}

func TestRoomRocketLaunchGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-ignite", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
	}}
	svc := newRocketTestService(t, repository, wallet, now)
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))

	roomID := "room-rocket-launch"
	ownerID := int64(301)
	igniterID := int64(302)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9002)
	joinRocketRoom(t, ctx, svc, roomID, igniterID)

	if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, igniterID, "ignite"),
		TargetType:   "user",
		TargetUserId: ownerID,
		GiftId:       "gift-ignite",
		GiftCount:    1,
	}); err != nil {
		t.Fatalf("ignite rocket failed: %v", err)
	}
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, igniterID, "leave")}); err != nil {
		t.Fatalf("igniter leave room failed: %v", err)
	}

	now.now = now.now.Add(1100 * time.Millisecond)
	if err := svc.SweepRoomRocketLaunchings(ctx); err != nil {
		t.Fatalf("sweep room rocket launches failed: %v", err)
	}

	if !hasRocketGrant(wallet.grants, igniterID, 2001) || !hasRocketGrant(wallet.grants, igniterID, 3001) {
		t.Fatalf("left top1/igniter must still receive locked rewards: %+v", wallet.grants)
	}
	if hasRocketGrant(wallet.grants, igniterID, 1001) {
		t.Fatalf("left igniter must not receive in-room reward: %+v", wallet.grants)
	}
	if !hasRocketGrant(wallet.grants, ownerID, 1001) {
		t.Fatalf("remaining in-room owner must receive in-room reward: %+v", wallet.grants)
	}

	info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
		Meta:         &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
		RoomId:       roomID,
		ViewerUserId: ownerID,
	})
	if err != nil {
		t.Fatalf("get rocket after launch failed: %v", err)
	}
	stateView := info.GetRocket().GetState()
	if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.RocketStatusCharging || len(stateView.GetPendingLaunches()) != 0 {
		t.Fatalf("launch rocket must keep current next-level progress and clear pending launch: %+v", stateView)
	}
	if stateView.GetLaunchAtMs() != 0 || stateView.GetIgnitedAtMs() != 0 || stateView.GetTop1UserId() != 0 || stateView.GetIgniterUserId() != 0 {
		t.Fatalf("launched rocket must not leak expired launch countdown into next charging level: %+v", stateView)
	}
	if len(stateView.GetLastRewards()) != 3 {
		t.Fatalf("launch rocket must retain in-room, top1 and igniter grants for client display: %+v", stateView.GetLastRewards())
	}
	if got := rocketRewardUserIDs(stateView.GetLastRewards()); !slices.Equal(got, []int64{301, 302, 302}) {
		t.Fatalf("reward users mismatch: got %+v rewards=%+v", got, stateView.GetLastRewards())
	}
}

func TestRoomRocketTop1UsesCurrentLevelFuelContributionNotRoomGiftRank(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-old-rank", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"},
		{BillingReceiptId: "receipt-current-level", GiftPointAdded: 90, HeatValue: 90, GiftTypeCode: "normal"},
	}}
	svc := newRocketTestService(t, repository, wallet, now)
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))

	roomID := "room-rocket-current-level-top1"
	ownerID := int64(501)
	igniterID := int64(502)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9004)
	joinRocketRoom(t, ctx, svc, roomID, igniterID)

	if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, ownerID, "old-room-rank"),
		TargetType:   "user",
		TargetUserId: igniterID,
		GiftId:       "gift-old-room-rank",
		GiftCount:    1,
	}); err != nil {
		t.Fatalf("seed old room gift rank failed: %v", err)
	}
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, ownerID, "owner-leave")}); err != nil {
		t.Fatalf("owner leave room failed: %v", err)
	}
	if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, igniterID, "current-level-fill"),
		TargetType:   "user",
		TargetUserId: igniterID,
		GiftId:       "gift-current-level-fill",
		GiftCount:    1,
	}); err != nil {
		t.Fatalf("ignite current level rocket failed: %v", err)
	}

	now.now = now.now.Add(1100 * time.Millisecond)
	if err := svc.SweepRoomRocketLaunchings(ctx); err != nil {
		t.Fatalf("sweep current-level top1 rocket failed: %v", err)
	}

	if hasRocketGrant(wallet.grants, ownerID, 2001) {
		t.Fatalf("left historical room GiftRank top1 must not receive current-level top1 reward: %+v", wallet.grants)
	}
	if !hasRocketGrant(wallet.grants, igniterID, 2001) || !hasRocketGrant(wallet.grants, igniterID, 3001) || !hasRocketGrant(wallet.grants, igniterID, 1001) {
		t.Fatalf("only current-level contributor/igniter/in-room user must receive all level rewards: %+v", wallet.grants)
	}

	info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
		Meta:         &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
		RoomId:       roomID,
		ViewerUserId: igniterID,
	})
	if err != nil {
		t.Fatalf("get current-level top1 rocket failed: %v", err)
	}
	rewards := info.GetRocket().GetState().GetLastRewards()
	if len(rewards) != 3 || countRocketRewards(rewards, ownerID, "top1") != 0 ||
		countRocketRewards(rewards, igniterID, "top1") != 1 ||
		countRocketRewards(rewards, igniterID, "igniter") != 1 ||
		countRocketRewards(rewards, igniterID, "in_room") != 1 {
		t.Fatalf("reward view must not include historical room GiftRank top1: %+v", rewards)
	}
}

func TestRoomRocketLaunchStacksAllSpecialPoolsAndRandomInRoomReward(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 13, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-stack", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
	}}
	svc := newRocketTestService(t, repository, wallet, now)
	config := rocketConfigForTest(100, 1_000)
	config.Levels[0].InRoomRewards = []roomservice.RoomRocketRewardItem{
		{RewardItemID: "level-1-in-room-a", ResourceGroupID: 1001, Weight: 1, DisplayName: "In Room A"},
		{RewardItemID: "level-1-in-room-b", ResourceGroupID: 1101, Weight: 1, DisplayName: "In Room B"},
	}
	config.Levels[0].Top1Rewards = []roomservice.RoomRocketRewardItem{
		{RewardItemID: "level-1-top1-a", ResourceGroupID: 2001, Weight: 1, DisplayName: "Top 1 A"},
		{RewardItemID: "level-1-top1-b", ResourceGroupID: 2101, Weight: 1, DisplayName: "Top 1 B"},
	}
	config.Levels[0].IgniterRewards = []roomservice.RoomRocketRewardItem{
		{RewardItemID: "level-1-igniter-a", ResourceGroupID: 3001, Weight: 1, DisplayName: "Igniter A"},
		{RewardItemID: "level-1-igniter-b", ResourceGroupID: 3101, Weight: 1, DisplayName: "Igniter B"},
	}
	writeRocketConfig(t, ctx, repository, config)

	roomID := "room-rocket-stack"
	ownerID := int64(401)
	igniterID := int64(402)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9003)
	joinRocketRoom(t, ctx, svc, roomID, igniterID)

	if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, igniterID, "stack"),
		TargetType:   "user",
		TargetUserId: ownerID,
		GiftId:       "gift-stack",
		GiftCount:    1,
	}); err != nil {
		t.Fatalf("ignite stack rocket failed: %v", err)
	}

	now.now = now.now.Add(1100 * time.Millisecond)
	if err := svc.SweepRoomRocketLaunchings(ctx); err != nil {
		t.Fatalf("sweep stacked room rocket launches failed: %v", err)
	}

	info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
		Meta:         &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
		RoomId:       roomID,
		ViewerUserId: ownerID,
	})
	if err != nil {
		t.Fatalf("get stacked rocket after launch failed: %v", err)
	}
	rewards := info.GetRocket().GetState().GetLastRewards()
	if len(rewards) != 6 {
		t.Fatalf("launch must keep two in-room random grants plus all top1/igniter grants: %+v", rewards)
	}
	if countRocketRewards(rewards, ownerID, "in_room") != 1 {
		t.Fatalf("owner must receive exactly one random in-room reward: %+v", rewards)
	}
	if countRocketRewards(rewards, igniterID, "in_room") != 1 {
		t.Fatalf("top1 igniter must still receive exactly one random in-room reward: %+v", rewards)
	}
	if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1001) != 1 || countRocketRewardsByRoleAndGroup(rewards, "in_room", 1101) != 1 {
		t.Fatalf("in-room rewards must spread across the configured pool before repeating an item: %+v", rewards)
	}
	if countRocketRewards(rewards, igniterID, "top1") != 2 || countRocketRewards(rewards, igniterID, "igniter") != 2 {
		t.Fatalf("top1 igniter must receive every configured special reward: %+v", rewards)
	}
	if !hasRocketGrant(wallet.grants, igniterID, 2001) || !hasRocketGrant(wallet.grants, igniterID, 2101) ||
		!hasRocketGrant(wallet.grants, igniterID, 3001) || !hasRocketGrant(wallet.grants, igniterID, 3101) {
		t.Fatalf("wallet must grant every top1 and igniter resource group: %+v", wallet.grants)
	}
}

func TestRoomRocketInRoomRewardQuantityCapsWinners(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 14, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-quantity", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
	}}
	svc := newRocketTestService(t, repository, wallet, now)
	config := rocketConfigForTest(100, 1_000)
	config.Levels[0].InRoomRewards = []roomservice.RoomRocketRewardItem{
		{RewardItemID: "level-1-in-room-coin", ResourceGroupID: 1001, Weight: 1, Quantity: 2, DisplayName: "Coin"},
		{RewardItemID: "level-1-in-room-mic", ResourceGroupID: 1101, Weight: 1, Quantity: 3, DisplayName: "Mic Wave"},
	}
	config.Levels[0].Top1Rewards = nil
	config.Levels[0].IgniterRewards = nil
	writeRocketConfig(t, ctx, repository, config)

	roomID := "room-rocket-quantity"
	ownerID := int64(501)
	igniterID := int64(502)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9004)
	for userID := igniterID; userID <= 507; userID++ {
		joinRocketRoom(t, ctx, svc, roomID, userID)
	}

	if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, igniterID, "quantity"),
		TargetType:   "user",
		TargetUserId: ownerID,
		GiftId:       "gift-quantity",
		GiftCount:    1,
	}); err != nil {
		t.Fatalf("ignite quantity rocket failed: %v", err)
	}

	now.now = now.now.Add(1100 * time.Millisecond)
	if err := svc.SweepRoomRocketLaunchings(ctx); err != nil {
		t.Fatalf("sweep quantity room rocket launches failed: %v", err)
	}

	info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
		Meta:         &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
		RoomId:       roomID,
		ViewerUserId: ownerID,
	})
	if err != nil {
		t.Fatalf("get quantity rocket after launch failed: %v", err)
	}
	rewards := info.GetRocket().GetState().GetLastRewards()
	if countRocketRewardsByRole(rewards, "in_room") != 5 {
		t.Fatalf("in-room rewards must stop at configured quantity 5, got %+v", rewards)
	}
	if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1001) != 2 {
		t.Fatalf("coin resource group must be granted exactly 2 times: %+v", rewards)
	}
	if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1101) != 3 {
		t.Fatalf("mic wave resource group must be granted exactly 3 times: %+v", rewards)
	}
	if len(wallet.grants) != 5 {
		t.Fatalf("wallet must only receive 5 in-room grant calls, got %+v", wallet.grants)
	}
	if len(rocketRewardUserIDs(rewards)) != 5 {
		t.Fatalf("only 5 users should have reward rows; remaining online users receive empty: %+v", rewards)
	}
}

func TestRoomRocketUTCResetCancelsCrossDayIgnited(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 23, 59, 59, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
		{BillingReceiptId: "receipt-cross-day", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
		{BillingReceiptId: "receipt-new-day", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"},
	}}
	svc := newRocketTestService(t, repository, wallet, now)
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 2_000))

	roomID := "room-rocket-reset"
	ownerID := int64(401)
	viewerID := int64(402)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9003)
	joinRocketRoom(t, ctx, svc, roomID, viewerID)

	fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, viewerID, "cross-day-fill"),
		TargetType:   "user",
		TargetUserId: ownerID,
		GiftId:       "gift-cross-day",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("send cross-day gift failed: %v", err)
	}
	if len(fillResp.GetRocket().GetPendingLaunches()) != 1 || fillResp.GetRocket().GetPendingLaunches()[0].GetLaunchAtMs() <= fillResp.GetRocket().GetResetAtMs() {
		t.Fatalf("fixture must create a pending launch whose launch time crosses UTC reset: %+v", fillResp.GetRocket())
	}

	now.now = time.Date(2026, 5, 21, 0, 0, 2, 0, time.UTC)
	if err := svc.SweepRoomRocketLaunchings(ctx); err != nil {
		t.Fatalf("sweep cross-day rocket failed: %v", err)
	}
	if len(wallet.grants) != 0 {
		t.Fatalf("cross-day ignited must be canceled by UTC reset, grants=%+v", wallet.grants)
	}

	info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
		Meta:         &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
		RoomId:       roomID,
		ViewerUserId: ownerID,
	})
	if err != nil {
		t.Fatalf("get rocket after UTC reset failed: %v", err)
	}
	if stateView := info.GetRocket().GetState(); stateView.GetCurrentLevel() != 1 || stateView.GetCurrentFuel() != 0 || stateView.GetStatus() != state.RocketStatusCharging {
		t.Fatalf("query view must reset stale ignited at UTC boundary: %+v", stateView)
	}

	nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, ownerID, "new-day-gift"),
		TargetType:   "user",
		TargetUserId: viewerID,
		GiftId:       "gift-new-day",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("send new-day gift failed: %v", err)
	}
	if nextResp.GetRocket().GetCurrentLevel() != 1 || nextResp.GetRocket().GetCurrentFuel() != 10 || nextResp.GetRocket().GetStatus() != state.RocketStatusCharging {
		t.Fatalf("first new-day gift must start a fresh level 1 rocket: %+v", nextResp.GetRocket())
	}
}

func newRocketTestService(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock) *roomservice.Service {
	t.Helper()
	return newRocketTestServiceWithScheduler(t, repository, wallet, clock, nil)
}

func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, scheduler integration.RoomRocketLaunchScheduler) *roomservice.Service {
	t.Helper()
	return newRocketTestServiceWithDirectIM(t, repository, wallet, clock, scheduler, nil)
}

func newRocketTestServiceWithDirectIM(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, scheduler integration.RoomRocketLaunchScheduler, directIM integration.OutboxPublisher) *roomservice.Service {
	t.Helper()

	return roomservice.New(roomservice.Config{
		NodeID:                    "node-rocket-test",
		LeaseTTL:                  10 * time.Second,
		RankLimit:                 20,
		SnapshotEveryN:            1,
		Clock:                     clock,
		RoomRocketLaunchScheduler: scheduler,
		RoomDisplayPublisher:      directIM,
	}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
}

func waitForRoomDirectIMCounts(t testing.TB, publisher *recordingRoomDirectIMPublisher, want map[string]int) {
	t.Helper()
	deadline := time.NewTimer(2 * time.Second)
	defer deadline.Stop()
	for {
		counts := publisher.counts()
		matched := true
		for eventType, expected := range want {
			if counts[eventType] < expected {
				matched = false
				break
			}
		}
		if matched {
			return
		}
		select {
		case <-publisher.wake:
		case <-deadline.C:
			t.Fatalf("room direct IM events mismatch: want at least %+v got %+v", want, counts)
		}
	}
}

func flushRocketProgress(t testing.TB, ctx context.Context, svc *roomservice.Service, clock *fixedRoomRocketClock) {
	t.Helper()

	clock.now = clock.now.Add(time.Second)
	if err := svc.FlushRoomRocketProgress(ctx); err != nil {
		t.Fatalf("flush rocket progress failed: %v", err)
	}
}

func rocketConfigForTest(threshold int64, launchDelayMS int64) roomservice.RoomRocketConfig {
	levels := make([]roomservice.RoomRocketLevelConfig, 0, 5)
	for level := int32(1); level <= 5; level++ {
		levels = append(levels, roomservice.RoomRocketLevelConfig{
			Level:              level,
			FuelThreshold:      threshold + int64(level-1)*100,
			CoverURL:           fmt.Sprintf("https://example.test/rocket/%d-cover.png", level),
			AnimationURL:       fmt.Sprintf("https://example.test/rocket/%d-idle.webp", level),
			LaunchAnimationURL: fmt.Sprintf("https://example.test/rocket/%d-launch.webp", level),
			LaunchedImageURL:   fmt.Sprintf("https://example.test/rocket/%d-launched.png", level),
			InRoomRewards: []roomservice.RoomRocketRewardItem{{
				RewardItemID:    fmt.Sprintf("level-%d-in-room", level),
				ResourceGroupID: 1000 + int64(level),
				Weight:          1,
				DisplayName:     "In Room",
				IconURL:         "https://example.test/reward/in-room.png",
			}},
			Top1Rewards: []roomservice.RoomRocketRewardItem{{
				RewardItemID:    fmt.Sprintf("level-%d-top1", level),
				ResourceGroupID: 2000 + int64(level),
				Weight:          1,
				DisplayName:     "Top 1",
				IconURL:         "https://example.test/reward/top1.png",
			}},
			IgniterRewards: []roomservice.RoomRocketRewardItem{{
				RewardItemID:    fmt.Sprintf("level-%d-igniter", level),
				ResourceGroupID: 3000 + int64(level),
				Weight:          1,
				DisplayName:     "Igniter",
				IconURL:         "https://example.test/reward/igniter.png",
			}},
		})
	}
	return roomservice.RoomRocketConfig{
		AppCode:           appcode.Default,
		Enabled:           true,
		ConfigVersion:     1,
		FuelSource:        "heat_value",
		LaunchDelayMS:     launchDelayMS,
		BroadcastEnabled:  true,
		BroadcastScope:    "region",
		RewardStackPolicy: "allow_stack",
		Levels:            levels,
	}
}

func writeRocketConfig(t *testing.T, ctx context.Context, repository *mysqltest.Repository, config roomservice.RoomRocketConfig) {
	t.Helper()

	if err := repository.UpsertRoomRocketConfig(ctx, config); err != nil {
		t.Fatalf("upsert rocket config failed: %v", err)
	}
}

func createRocketRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, ownerID int64, regionID int64) {
	t.Helper()

	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: regionID,
		RoomName:        roomID,
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     roomID,
	}); err != nil {
		t.Fatalf("create rocket room failed: %v", err)
	}
}

func joinRocketRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, userID int64) {
	t.Helper()

	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, userID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("join rocket room failed: %v", err)
	}
}

func rocketMeta(roomID string, actorUserID int64, suffix string) *roomv1.RequestMeta {
	return &roomv1.RequestMeta{
		RequestId:   "req-room-rocket-" + suffix,
		CommandId:   "cmd-room-rocket-" + suffix,
		ActorUserId: actorUserID,
		RoomId:      roomID,
		AppCode:     appcode.Default,
		SentAtMs:    time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
	}
}

func rocketProgressEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomRocketFuelChanged {
	t.Helper()

	records, err := repository.ListPendingOutbox(ctx, 50)
	if err != nil {
		t.Fatalf("list pending outbox failed: %v", err)
	}
	events := make([]*roomeventsv1.RoomRocketFuelChanged, 0)
	for _, record := range records {
		if record.EventType != "RoomRocketFuelChanged" {
			continue
		}
		var event roomeventsv1.RoomRocketFuelChanged
		if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
			t.Fatalf("decode rocket progress event failed: %v", err)
		}
		events = append(events, &event)
	}
	return events
}

func roomDirectIMRocketProgressEvents(t *testing.T, publisher *recordingRoomDirectIMPublisher) []*roomeventsv1.RoomRocketFuelChanged {
	t.Helper()
	publisher.mu.Lock()
	records := append([]*roomeventsv1.EventEnvelope(nil), publisher.records...)
	publisher.mu.Unlock()
	events := make([]*roomeventsv1.RoomRocketFuelChanged, 0)
	for _, record := range records {
		if record.GetEventType() != "RoomRocketFuelChanged" {
			continue
		}
		var event roomeventsv1.RoomRocketFuelChanged
		if err := proto.Unmarshal(record.GetBody(), &event); err != nil {
			t.Fatalf("decode directIM rocket progress failed: %v", err)
		}
		events = append(events, &event)
	}
	return events
}

func roomDirectIMGiftBatchSentEvents(t *testing.T, publisher *recordingRoomDirectIMPublisher) []*roomeventsv1.RoomGiftBatchSent {
	t.Helper()
	publisher.mu.Lock()
	records := append([]*roomeventsv1.EventEnvelope(nil), publisher.records...)
	publisher.mu.Unlock()
	events := make([]*roomeventsv1.RoomGiftBatchSent, 0)
	for _, record := range records {
		if record.GetEventType() != "RoomGiftBatchSent" {
			continue
		}
		var event roomeventsv1.RoomGiftBatchSent
		if err := proto.Unmarshal(record.GetBody(), &event); err != nil {
			t.Fatalf("decode directIM batch gift failed: %v", err)
		}
		events = append(events, &event)
	}
	return events
}

func roomGiftSentEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomGiftSent {
	t.Helper()

	records, err := repository.ListPendingOutbox(ctx, 50)
	if err != nil {
		t.Fatalf("list pending outbox failed: %v", err)
	}
	events := make([]*roomeventsv1.RoomGiftSent, 0)
	for _, record := range records {
		if record.EventType != "RoomGiftSent" {
			continue
		}
		var event roomeventsv1.RoomGiftSent
		if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
			t.Fatalf("decode gift sent event failed: %v", err)
		}
		events = append(events, &event)
	}
	return events
}

func countRocketIgnitedEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) int {
	t.Helper()

	records, err := repository.ListPendingOutbox(ctx, 50)
	if err != nil {
		t.Fatalf("list pending outbox failed: %v", err)
	}
	count := 0
	for _, record := range records {
		if record.EventType == "RoomRocketIgnited" {
			count++
		}
	}
	return count
}

func hasRocketGrant(grants []*walletv1.GrantResourceGroupRequest, userID int64, groupID int64) bool {
	for _, grant := range grants {
		if grant.GetTargetUserId() == userID && grant.GetGroupId() == groupID {
			return true
		}
	}
	return false
}

func countRocketRewards(rewards []*roomv1.RoomRocketRewardGrant, userID int64, role string) int {
	count := 0
	for _, reward := range rewards {
		if reward.GetUserId() == userID && reward.GetRewardRole() == role {
			count++
		}
	}
	return count
}

func countRocketRewardsByRoleAndGroup(rewards []*roomv1.RoomRocketRewardGrant, role string, groupID int64) int {
	count := 0
	for _, reward := range rewards {
		if reward.GetRewardRole() == role && reward.GetResourceGroupId() == groupID {
			count++
		}
	}
	return count
}

func countRocketRewardsByRole(rewards []*roomv1.RoomRocketRewardGrant, role string) int {
	count := 0
	for _, reward := range rewards {
		if reward.GetRewardRole() == role {
			count++
		}
	}
	return count
}

func rocketRewardUserIDs(rewards []*roomv1.RoomRocketRewardGrant) []int64 {
	userIDs := make([]int64, 0, len(rewards))
	for _, reward := range rewards {
		userIDs = append(userIDs, reward.GetUserId())
	}
	slices.Sort(userIDs)
	return userIDs
}
