package service_test

import (
	"context"
	"sync"
	"testing"
	"time"

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

func TestRoomUserGiftValueFollowsMicChangeAndResetsAfterLeave(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 10, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
		BillingReceiptId: "receipt-target-heat",
		CoinSpent:        1000,
		ChargeAmount:     1000,
		HeatValue:        117,
		GiftTypeCode:     "normal",
	}}}
	directIM := newRecordingRoomDirectIMPublisher()
	svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-value", directIM)

	roomID := "room-user-gift-value"
	ownerID := int64(61001)
	targetID := int64(61002)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
		Meta:   rocketMeta(roomID, targetID, "target-heat-up"),
		SeatNo: 2,
	}); err != nil {
		t.Fatalf("target mic up failed: %v", err)
	}

	giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, ownerID, "target-heat-gift"),
		TargetType:   "user",
		TargetUserId: targetID,
		GiftId:       "gift-target-heat",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("send gift failed: %v", err)
	}
	if user := onlineUserByID(giftResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 117 {
		t.Fatalf("target online user gift_value mismatch: %+v", user)
	}
	if seat := seatByNo(giftResp.GetRoom(), 2); seat == nil || seat.GetUserId() != targetID || seat.GetGiftValue() != 117 {
		t.Fatalf("target seat gift_value mismatch after gift: %+v", seat)
	}
	giftEvents := roomGiftSentEvents(t, ctx, repository)
	if len(giftEvents) != 1 || giftEvents[0].GetTargetGiftValue() != 117 {
		t.Fatalf("RoomGiftSent must carry target cumulative gift value: %+v", giftEvents)
	}

	changeResp, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{
		Meta:         rocketMeta(roomID, targetID, "target-heat-change"),
		TargetUserId: targetID,
		SeatNo:       5,
	})
	if err != nil {
		t.Fatalf("change mic failed: %v", err)
	}
	if oldSeat := seatByNo(changeResp.GetRoom(), 2); oldSeat == nil || oldSeat.GetUserId() != 0 || oldSeat.GetGiftValue() != 0 {
		t.Fatalf("old seat must be empty after change: %+v", oldSeat)
	}
	if newSeat := seatByNo(changeResp.GetRoom(), 5); newSeat == nil || newSeat.GetUserId() != targetID || newSeat.GetGiftValue() != 117 {
		t.Fatalf("gift value must follow user to new seat: %+v", newSeat)
	}
	waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomMicChanged": 2})
	if changed := firstDirectMicChangedEventByAction(t, directIM, "change"); changed == nil || changed.GetTargetGiftValue() != 117 {
		t.Fatalf("direct RoomMicChanged/change must carry target gift value: %+v", changed)
	}
	if changed := firstMicChangedEventByAction(t, ctx, repository, "change"); changed != nil {
		t.Fatalf("RoomMicChanged/change must not be written to durable outbox: %+v", changed)
	}

	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, targetID, "target-heat-leave")}); err != nil {
		t.Fatalf("leave room failed: %v", err)
	}
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	reupResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
		Meta:   rocketMeta(roomID, targetID, "target-heat-reup"),
		SeatNo: 1,
	})
	if err != nil {
		t.Fatalf("target mic re-up failed: %v", err)
	}
	if user := onlineUserByID(reupResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
		t.Fatalf("gift value must reset after leave and rejoin: %+v", user)
	}
	if seat := seatByNo(reupResp.GetRoom(), 1); seat == nil || seat.GetUserId() != targetID || seat.GetGiftValue() != 0 {
		t.Fatalf("rejoined user seat gift_value must be zero: %+v", seat)
	}
}

func TestRobotGiftIsDisplayOnlyWithoutRoomPersistenceOrWalletDebit(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
		robotGiftConfigForTest("robot-display-gift", 100, "normal"),
	}}
	robotDisplay := newRecordingRobotDisplayPublisher()
	svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-boundary", robotDisplay)

	roomID := "robot-room-outbox-boundary"
	senderID := int64(63001)
	targetID := int64(63002)
	createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	beforeMain := outboxEventCounts(t, ctx, repository)
	trackedTables := []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries"}
	beforeRows := roomTableRows(t, repository, roomID, trackedTables)

	resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
		Meta:         rocketMeta(roomID, senderID, "robot-display-only"),
		TargetUserID: targetID,
		GiftID:       "robot-display-gift",
		GiftCount:    1,
		RobotUserIDs: []int64{senderID, targetID},
		RealRoomHeat: true,
	})
	if err != nil {
		t.Fatalf("robot send gift failed: %v", err)
	}
	if resp.GetRoomHeat() != 0 || len(resp.GetGiftRank()) != 0 {
		t.Fatalf("robot display must not change room heat or rank: %+v", resp)
	}
	if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
		t.Fatalf("robot display must not change target gift value: %+v", user)
	}
	if len(wallet.robotDebitRequests) != 0 || len(wallet.debitRequests) != 0 || wallet.lastBatch != nil {
		t.Fatalf("robot display must not call any wallet debit: robot=%d single=%d batch=%v", len(wallet.robotDebitRequests), len(wallet.debitRequests), wallet.lastBatch)
	}
	if len(wallet.robotCatalogRequests) != 1 || !wallet.robotCatalogRequests[0].GetActiveOnly() || !wallet.robotCatalogRequests[0].GetFilterRegion() {
		t.Fatalf("robot display must only read the active regional gift catalog once: %+v", wallet.robotCatalogRequests)
	}
	assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows)

	afterMain := outboxEventCounts(t, ctx, repository)
	if delta := afterMain["RoomGiftSent"] - beforeMain["RoomGiftSent"]; delta != 0 {
		t.Fatalf("robot gift must not create main gift outbox, delta=%d counts=%+v", delta, afterMain)
	}
	if delta := afterMain["RoomHeatChanged"] - beforeMain["RoomHeatChanged"]; delta != 0 {
		t.Fatalf("robot gift must not create main heat outbox, delta=%d counts=%+v", delta, afterMain)
	}
	if delta := afterMain["RoomRankChanged"] - beforeMain["RoomRankChanged"]; delta != 0 {
		t.Fatalf("robot gift must not create main rank outbox, delta=%d counts=%+v", delta, afterMain)
	}

	waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
		"RoomGiftSent": 1,
	})
	giftEvents := robotDisplay.roomGiftSentEvents(t)
	if len(giftEvents) != 1 {
		t.Fatalf("robot display must publish exactly one gift event: %+v", giftEvents)
	}
	if event := giftEvents[0]; event.GetGiftValue() != 0 || event.GetTargetGiftValue() != 0 || event.GetCoinSpent() != 100 {
		t.Fatalf("robot display must preserve visual price but expose zero business contribution: %+v", event)
	}
	if event := giftEvents[0]; event.GetGiftIconUrl() == "" || event.GetGiftAnimationUrl() == "" {
		t.Fatalf("robot display must keep catalog visual assets: %+v", event)
	}
	if counts := robotDisplay.counts(); counts["RoomHeatChanged"] != 0 || counts["RoomRankChanged"] != 0 {
		t.Fatalf("robot display must not publish fake room state changes: %+v", counts)
	}
}

func TestRobotGiftMissingRoomReturnsNotFoundWithoutCatalogOrDebit(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 15, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
		robotGiftConfigForTest("missing-room-gift", 100, "normal"),
	}}
	display := newRecordingRobotDisplayPublisher()
	svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-missing-room", display)

	_, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
		Meta:         rocketMeta("missing-robot-room", 65001, "robot-missing-room"),
		TargetUserID: 65002,
		GiftID:       "missing-room-gift",
		GiftCount:    1,
		RobotUserIDs: []int64{65001, 65002},
	})
	if xerr.CodeOf(err) != xerr.NotFound {
		t.Fatalf("missing robot room must return not found, got %v", err)
	}
	if len(wallet.robotCatalogRequests) != 0 || len(wallet.robotDebitRequests) != 0 || len(display.envelopes) != 0 {
		t.Fatalf("missing room must stop before catalog, debit and IM: catalog=%d debits=%d events=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests), len(display.envelopes))
	}
}

func TestRobotGiftLostOwnershipStopsBeforeCatalogDebitAndIM(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 20, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
		robotGiftConfigForTest("lost-owner-gift", 100, "normal"),
	}}
	display := newRecordingRobotDisplayPublisher()
	directory := router.NewMemoryDirectory()
	svc := roomservice.New(roomservice.Config{
		NodeID:                "node-robot-old-owner",
		LeaseTTL:              10 * time.Second,
		RankLimit:             20,
		SnapshotEveryN:        100,
		Clock:                 now,
		RobotDisplayPublisher: display,
	}, directory, repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())

	roomID := "robot-room-lost-owner"
	senderID := int64(65101)
	targetID := int64(65102)
	createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	key := roomservice.RuntimeRoomKey(appcode.Default, roomID)
	directory.ForceExpire(key)
	if lease, err := directory.EnsureOwner(ctx, key, "node-robot-new-owner", now.Now(), 10*time.Second); err != nil || lease.NodeID != "node-robot-new-owner" {
		t.Fatalf("move robot room ownership failed: lease=%+v err=%v", lease, err)
	}
	beforeRows := roomTableRows(t, repository, roomID, []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries"})

	_, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
		Meta:         rocketMeta(roomID, senderID, "robot-lost-owner"),
		TargetUserID: targetID,
		GiftID:       "lost-owner-gift",
		GiftCount:    1,
		RobotUserIDs: []int64{senderID, targetID},
	})
	if xerr.CodeOf(err) != xerr.Conflict {
		t.Fatalf("lost robot owner must return conflict, got %v", err)
	}
	if len(wallet.robotCatalogRequests) != 0 || len(wallet.robotDebitRequests) != 0 || len(display.envelopes) != 0 {
		t.Fatalf("lost owner must stop before catalog, debit and IM: catalog=%d debits=%d events=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests), len(display.envelopes))
	}
	assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows)
}

func TestRobotGiftDownsamplesDisplayAndKeepsRoomStateUnchanged(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
		robotGiftConfigForTest("robot-sampler-gift", 1, "normal"),
	}}
	robotDisplay := newRecordingRobotDisplayPublisher()
	svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-sampler", robotDisplay)

	roomID := "robot-room-outbox-sampler"
	senderID := int64(63101)
	targetID := int64(63102)
	createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	beforeMain := outboxEventCounts(t, ctx, repository)

	firstResp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
		Meta:         rocketMeta(roomID, senderID, "robot-sampler-first"),
		TargetUserID: targetID,
		GiftID:       "robot-sampler-gift",
		GiftCount:    1,
		RobotUserIDs: []int64{senderID, targetID},
		RealRoomHeat: true,
	})
	if err != nil {
		t.Fatalf("first robot send gift failed: %v", err)
	}
	secondResp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
		Meta:         rocketMeta(roomID, senderID, "robot-sampler-second"),
		TargetUserID: targetID,
		GiftID:       "robot-sampler-gift",
		GiftCount:    1,
		RobotUserIDs: []int64{senderID, targetID},
		RealRoomHeat: true,
	})
	if err != nil {
		t.Fatalf("second robot send gift failed: %v", err)
	}

	if firstResp.GetRoomHeat() != 0 || secondResp.GetRoomHeat() != 0 {
		t.Fatalf("robot display must keep room heat unchanged: first=%d second=%d", firstResp.GetRoomHeat(), secondResp.GetRoomHeat())
	}
	if len(secondResp.GetGiftRank()) != 0 {
		t.Fatalf("robot display must keep room rank unchanged: %+v", secondResp.GetGiftRank())
	}
	if user := onlineUserByID(secondResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
		t.Fatalf("robot display must keep target gift value unchanged: %+v", user)
	}
	if len(wallet.robotDebitRequests) != 0 || len(wallet.robotCatalogRequests) != 1 {
		t.Fatalf("two sampled displays must use one cached catalog read and zero debits: catalog=%d debits=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests))
	}

	afterMain := outboxEventCounts(t, ctx, repository)
	for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged"} {
		if delta := afterMain[eventType] - beforeMain[eventType]; delta != 0 {
			t.Fatalf("robot sampler must not write %s to main outbox, delta=%d counts=%+v", eventType, delta, afterMain)
		}
	}
	waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
		"RoomGiftSent": 1,
	})
}

func TestHumanRoomRobotGiftIsDisplayOnlyWithoutContributionOrRocket(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 30, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
		robotGiftConfigForTest("real-room-robot-gift", 100, "super_lucky"),
	}}
	leaderboard := &recordingRoomGiftLeaderboard{}
	robotDisplay := newRecordingRobotDisplayPublisher()
	svc := roomservice.New(roomservice.Config{
		NodeID:                "node-real-room-robot-display",
		LeaseTTL:              10 * time.Second,
		RankLimit:             20,
		SnapshotEveryN:        100,
		Clock:                 now,
		RoomGiftLeaderboard:   leaderboard,
		RobotDisplayPublisher: robotDisplay,
	}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
	writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))

	roomID := "real-room-robot-outbox"
	senderID := int64(64001)
	targetID := int64(64002)
	createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
		Meta:   rocketMeta(roomID, targetID, "real-room-robot-target-up"),
		SeatNo: 1,
	}); err != nil {
		t.Fatalf("target mic up failed: %v", err)
	}
	beforeMain := outboxEventCounts(t, ctx, repository)
	trackedTables := []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries", "room_user_gift_stats", "room_user_contribution_period_stats"}
	beforeRows := roomTableRows(t, repository, roomID, trackedTables)

	resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
		Meta:                   rocketMeta(roomID, senderID, "real-room-robot-gift"),
		TargetUserID:           targetID,
		GiftID:                 "real-room-robot-gift",
		GiftCount:              1,
		PoolID:                 "human_robot_super_lucky_display",
		RobotUserIDs:           []int64{senderID, targetID},
		SyntheticMultiplierPPM: 20_000_000,
		RealRoomHeat:           true,
	})
	if err != nil {
		t.Fatalf("real room robot gift failed: %v", err)
	}

	if resp.GetRoomHeat() != 0 {
		t.Fatalf("human-room robot display must not update room heat, got %d", resp.GetRoomHeat())
	}
	if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
		t.Fatalf("human-room robot display must not update target gift value: %+v", user)
	}
	if seat := seatByNo(resp.GetRoom(), 1); seat == nil || seat.GetGiftValue() != 0 {
		t.Fatalf("human-room robot display must not update mic gift value: %+v", seat)
	}
	if len(resp.GetGiftRank()) != 0 {
		t.Fatalf("human-room robot display must not update room gift rank: %+v", resp.GetGiftRank())
	}
	if rocket := resp.GetRocket(); rocket != nil && (rocket.GetCurrentFuel() != 0 || len(rocket.GetPendingLaunches()) != 0) {
		t.Fatalf("human-room robot display must not fuel or ignite room rocket: %+v", rocket)
	}
	if len(leaderboard.increments) != 0 {
		t.Fatalf("human-room robot display must not update cross-room contribution: %+v", leaderboard.increments)
	}
	if lucky := resp.GetLuckyGift(); lucky == nil || lucky.GetMultiplierPpm() != 20_000_000 || lucky.GetEffectiveRewardCoins() != 2_000 {
		t.Fatalf("human-room robot lucky display must derive reward from configured multiplier: %+v", lucky)
	}
	if len(wallet.robotDebitRequests) != 0 || len(wallet.robotCatalogRequests) != 1 {
		t.Fatalf("human-room robot display must use one catalog read and zero debits: catalog=%d debits=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests))
	}
	assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows)

	afterMain := outboxEventCounts(t, ctx, repository)
	for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} {
		if delta := afterMain[eventType] - beforeMain[eventType]; delta != 0 {
			t.Fatalf("real room robot gift must not write %s to main outbox, delta=%d counts=%+v", eventType, delta, afterMain)
		}
	}
	waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
		"RoomGiftSent":            1,
		"RoomRobotLuckyGiftDrawn": 1,
	})
	if counts := robotDisplay.counts(); counts["RoomHeatChanged"] != 0 || counts["RoomRankChanged"] != 0 || counts["RoomRocketFuelChanged"] != 0 || counts["RoomRocketIgnited"] != 0 {
		t.Fatalf("human-room robot display must not publish fake room state changes: %+v", counts)
	}
}

func TestRoomUserGiftValueRecoversFromCommandLog(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 10, 30, 0, 0, time.UTC)}
	wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
		BillingReceiptId: "receipt-replay-target-heat",
		CoinSpent:        1500,
		ChargeAmount:     1500,
		HeatValue:        367,
		GiftTypeCode:     "lucky",
	}}}
	svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-replay-writer")

	roomID := "room-user-gift-replay"
	ownerID := int64(62001)
	targetID := int64(62002)
	createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
	joinRocketRoom(t, ctx, svc, roomID, targetID)
	if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
		Meta:   rocketMeta(roomID, targetID, "replay-up"),
		SeatNo: 2,
	}); err != nil {
		t.Fatalf("target mic up failed: %v", err)
	}
	if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
		Meta:         rocketMeta(roomID, ownerID, "replay-gift"),
		TargetType:   "user",
		TargetUserId: targetID,
		GiftId:       "gift-replay-target-heat",
		GiftCount:    1,
	}); err != nil {
		t.Fatalf("send gift failed: %v", err)
	}
	if _, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{
		Meta:         rocketMeta(roomID, targetID, "replay-change"),
		TargetUserId: targetID,
		SeatNo:       4,
	}); err != nil {
		t.Fatalf("change mic failed: %v", err)
	}

	recoveredSvc := newUserGiftValueTestService(repository, &rocketTestWallet{}, now, "node-user-gift-replay-reader")
	resp, err := recoveredSvc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{
		Meta:         rocketMeta(roomID, ownerID, "replay-snapshot"),
		RoomId:       roomID,
		ViewerUserId: ownerID,
	})
	if err != nil {
		t.Fatalf("recover room snapshot failed: %v", err)
	}
	if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 367 {
		t.Fatalf("recovered online user gift_value mismatch: %+v", user)
	}
	if oldSeat := seatByNo(resp.GetRoom(), 2); oldSeat == nil || oldSeat.GetUserId() != 0 || oldSeat.GetGiftValue() != 0 {
		t.Fatalf("recovered old seat must stay empty: %+v", oldSeat)
	}
	if newSeat := seatByNo(resp.GetRoom(), 4); newSeat == nil || newSeat.GetUserId() != targetID || newSeat.GetGiftValue() != 367 {
		t.Fatalf("recovered seat must keep target gift value after change: %+v", newSeat)
	}
}

func robotGiftConfigForTest(giftID string, coinPrice int64, giftType string) *walletv1.GiftConfig {
	return &walletv1.GiftConfig{
		AppCode:      appcode.Default,
		GiftId:       giftID,
		Status:       "active",
		Name:         giftID,
		PriceVersion: "robot-display-v1",
		CoinPrice:    coinPrice,
		HeatValue:    coinPrice,
		GiftTypeCode: giftType,
		EffectTypes:  []string{"center_flight"},
		RegionIds:    []int64{0},
		Resource:     &walletv1.Resource{ResourceType: "gift", Status: "active", PreviewUrl: "https://cdn.example/" + giftID + ".png", AnimationUrl: "https://cdn.example/" + giftID + ".svga"},
	}
}

func roomTableRows(t *testing.T, repository *mysqltest.Repository, roomID string, tables []string) map[string]int64 {
	t.Helper()
	rows := make(map[string]int64, len(tables))
	for _, table := range tables {
		rows[table] = repository.CountRowsByRoom(table, roomID)
	}
	return rows
}

func assertRoomTableRowsUnchanged(t *testing.T, repository *mysqltest.Repository, roomID string, before map[string]int64) {
	t.Helper()
	for table, want := range before {
		if got := repository.CountRowsByRoom(table, roomID); got != want {
			t.Fatalf("robot display must not write %s: before=%d after=%d", table, want, got)
		}
	}
}

func outboxEventCounts(t *testing.T, ctx context.Context, repository *mysqltest.Repository) map[string]int {
	t.Helper()
	records, err := repository.ListPendingOutbox(ctx, 200)
	if err != nil {
		t.Fatalf("list pending outbox failed: %v", err)
	}
	counts := make(map[string]int, len(records))
	for _, record := range records {
		counts[record.EventType]++
	}
	return counts
}

type recordingRobotDisplayPublisher struct {
	mu        sync.Mutex
	envelopes []*roomeventsv1.EventEnvelope
	wake      chan struct{}
}

func newRecordingRobotDisplayPublisher() *recordingRobotDisplayPublisher {
	return &recordingRobotDisplayPublisher{wake: make(chan struct{}, 32)}
}

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

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

func (p *recordingRobotDisplayPublisher) roomGiftSentEvents(t *testing.T) []*roomeventsv1.RoomGiftSent {
	t.Helper()
	p.mu.Lock()
	defer p.mu.Unlock()

	events := make([]*roomeventsv1.RoomGiftSent, 0, len(p.envelopes))
	for _, envelope := range p.envelopes {
		if envelope.GetEventType() != "RoomGiftSent" {
			continue
		}
		var event roomeventsv1.RoomGiftSent
		if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
			t.Fatalf("decode robot RoomGiftSent failed: %v", err)
		}
		events = append(events, &event)
	}
	return events
}

func waitForRobotDisplayCounts(t *testing.T, publisher *recordingRobotDisplayPublisher, want map[string]int) map[string]int {
	t.Helper()

	deadline := time.NewTimer(2 * time.Second)
	defer deadline.Stop()
	for {
		counts := publisher.counts()
		matched := true
		for eventType, wantCount := range want {
			if counts[eventType] < wantCount {
				matched = false
				break
			}
		}
		if matched {
			return counts
		}
		select {
		case <-publisher.wake:
		case <-deadline.C:
			t.Fatalf("robot display IM events mismatch: want at least %+v got %+v", want, counts)
		}
	}
}

func newUserGiftValueTestService(repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, nodeID string, robotDisplayPublisher ...integration.OutboxPublisher) *roomservice.Service {
	var publisher integration.OutboxPublisher
	if len(robotDisplayPublisher) > 0 {
		publisher = robotDisplayPublisher[0]
	}
	return roomservice.New(roomservice.Config{
		NodeID:                nodeID,
		LeaseTTL:              10 * time.Second,
		RankLimit:             20,
		SnapshotEveryN:        100,
		Clock:                 clock,
		RobotDisplayPublisher: publisher,
	}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
}

func firstMicChangedEventByAction(t *testing.T, ctx context.Context, repository *mysqltest.Repository, action string) *roomeventsv1.RoomMicChanged {
	t.Helper()

	records, err := repository.ListPendingOutbox(ctx, 100)
	if err != nil {
		t.Fatalf("list pending outbox failed: %v", err)
	}
	for _, record := range records {
		if record.EventType != "RoomMicChanged" {
			continue
		}
		var event roomeventsv1.RoomMicChanged
		if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
			t.Fatalf("decode mic changed event failed: %v", err)
		}
		if event.GetAction() == action {
			return &event
		}
	}
	return nil
}

func firstDirectMicChangedEventByAction(t *testing.T, publisher *recordingRoomDirectIMPublisher, action string) *roomeventsv1.RoomMicChanged {
	t.Helper()

	for _, record := range publisher.recordsByType("RoomMicChanged") {
		var event roomeventsv1.RoomMicChanged
		if err := proto.Unmarshal(record.GetBody(), &event); err != nil {
			t.Fatalf("decode direct mic changed event failed: %v", err)
		}
		if event.GetAction() == action {
			return &event
		}
	}
	return nil
}
