package broadcast

import (
	"context"
	"encoding/json"
	"errors"
	"testing"
	"time"

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

type crossAppBroadcastRepository struct {
	Repository
	claimed       map[string]bool
	deliveredApps []string
}

func (r *crossAppBroadcastRepository) ListBroadcastOutboxAppCodes(context.Context) ([]string, error) {
	return []string{"huwaa", "fami"}, nil
}

func (r *crossAppBroadcastRepository) ClaimPendingBroadcastOutbox(ctx context.Context, _ string, _ int64, _ int64, _ int, _ int) ([]broadcastdomain.OutboxRecord, error) {
	appCode := appcode.FromContext(ctx)
	if r.claimed == nil {
		r.claimed = map[string]bool{}
	}
	if r.claimed[appCode] {
		return nil, nil
	}
	r.claimed[appCode] = true
	return []broadcastdomain.OutboxRecord{{
		AppCode:       appCode,
		EventID:       "cross-app:" + appCode,
		Scope:         broadcastdomain.ScopeRoom,
		GroupID:       "room-" + appCode,
		BroadcastType: broadcastdomain.TypeLuckyGiftBigWin,
		PayloadJSON:   `{}`,
	}}, nil
}

func (r *crossAppBroadcastRepository) MarkBroadcastOutboxDelivered(ctx context.Context, _ string, _ int64) error {
	r.deliveredApps = append(r.deliveredApps, appcode.FromContext(ctx))
	return nil
}

func (r *crossAppBroadcastRepository) MarkBroadcastOutboxFailed(context.Context, string, string, int64, int64, int, string) error {
	return nil
}

func TestProcessPendingBroadcastsUsesEachRecordAppContext(t *testing.T) {
	repository := &crossAppBroadcastRepository{}
	service := New(Config{NodeID: "cross-app-broadcast"}, repository, &fakePublisher{}, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() })

	result, err := service.ProcessPendingBroadcasts(context.Background(), WorkerOptions{WorkerID: "worker", BatchSize: 2, LockTTL: time.Second, MaxRetry: 8})
	if err != nil {
		t.Fatalf("process cross-app broadcasts: %v", err)
	}
	if result.SuccessCount != 2 || len(repository.deliveredApps) != 2 {
		t.Fatalf("cross-app broadcasts were not delivered: result=%+v apps=%v", result, repository.deliveredApps)
	}
	want := map[string]bool{"fami": true, "huwaa": true}
	for _, appCode := range repository.deliveredApps {
		if !want[appCode] {
			t.Fatalf("unexpected delivered app context %q", appCode)
		}
		delete(want, appCode)
	}
	if len(want) != 0 {
		t.Fatalf("missing delivered app contexts: %v", want)
	}
}

func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000) })
	service.SetSenderProfileSource(fakeSenderProfileSource{profiles: map[int64]SenderProfile{
		42: {UserID: 42, Account: "160042", Nickname: "Sender", Avatar: "https://cdn.example/sender.png", AvatarFrameURL: "https://cdn.example/sender-frame.png"},
		43: {UserID: 43, Account: "160043", Nickname: "Receiver", Avatar: "https://cdn.example/receiver.png", AvatarFrameURL: "https://cdn.example/receiver-frame.png"},
	}})
	envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
		SenderUserId:                    42,
		TargetUserId:                    43,
		GiftId:                          "rocket",
		GiftCount:                       2,
		GiftValue:                       999,
		VisibleRegionId:                 1001,
		RoomLocked:                      true,
		OwnerUserId:                     323411852102995968,
		CommandId:                       "cmd-send-gift-1",
		GiftName:                        "Rocket",
		GiftIconUrl:                     "https://cdn.example/rocket.png",
		GiftEffectTypes:                 []string{"animation", "global_broadcast"},
		SenderVipLevel:                  7,
		SenderGiftBroadcastAnimationUrl: "https://cdn.example/vip-gift-tray.svga",
	})

	result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
	if err != nil {
		t.Fatalf("HandleRoomEvent failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "gift_broadcast:room-1001:cmd-send-gift-1" {
		t.Fatalf("unexpected result: %+v", result)
	}
	record := repository.records[result.BroadcastEventID]
	if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
		t.Fatalf("unexpected broadcast record: %+v", record)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
		t.Fatalf("payload is not json: %v", err)
	}
	if payload["room_id"] != "room-1001" || payload["room_locked"] != true || payload["owner_user_id"] != "323411852102995968" || payload["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" || payload["sender_vip_level"] != float64(7) || payload["sender_gift_broadcast_animation_url"] != "https://cdn.example/vip-gift-tray.svga" {
		t.Fatalf("payload mismatch: %+v", payload)
	}
	action, _ := payload["action"].(map[string]any)
	if action["room_locked"] != true || action["owner_user_id"] != "323411852102995968" {
		t.Fatalf("gift action must carry room owner snapshot: %+v", action)
	}
	sender, _ := payload["sender"].(map[string]any)
	receiver, _ := payload["receiver"].(map[string]any)
	if sender["nickname"] != "Sender" || sender["avatar_frame_url"] != "https://cdn.example/sender-frame.png" || receiver["nickname"] != "Receiver" {
		t.Fatalf("payload user profile mismatch: sender=%+v receiver=%+v", sender, receiver)
	}

	again, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
	if err != nil {
		t.Fatalf("second HandleRoomEvent failed: %v", err)
	}
	if again.BroadcastCreated {
		t.Fatalf("second consume must be idempotent: %+v", again)
	}
}

func TestHandleLuckyGiftEventCreatesTenTimesRegionBroadcastIdempotently(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a"}, repository, nil, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_001_000) })
	// owner fact 已带送礼时展示快照；即使当前 user-service 不可用，也不应回查并阻断这条事实。
	service.SetSenderProfileSource(fakeSenderProfileSource{err: errors.New("user service unavailable")})
	envelope := mustLuckyGiftEnvelope(t, &luckygifteventsv1.LuckyGiftDrawn{
		DrawId:                    "lucky_draw_10x",
		CommandId:                 "cmd-lucky-10x",
		RoomId:                    "room-210",
		PoolId:                    "super_lucky",
		GiftId:                    "43",
		GiftCount:                 1,
		SenderUserId:              42,
		TargetUserId:              99,
		SenderName:                "Real Sender",
		SenderAvatar:              "https://cdn.example/sender.png",
		SenderDisplayUserId:       "160042",
		SenderPrettyDisplayUserId: "888042",
		VisibleRegionId:           210,
		RoomLocked:                true,
		OwnerUserId:               323411852102995968,
		CountryId:                 15,
		CoinSpent:                 100,
		RuleVersion:               7,
		ExperiencePool:            "advanced",
		SelectedTierId:            "10x",
		MultiplierPpm:             luckygiftmq.LuckyGiftDisplayMinMultiplierPPM,
		BaseRewardCoins:           1000,
		EffectiveRewardCoins:      1000,
		HighMultiplier:            true,
		RewardStatus:              "granted",
		WalletTransactionId:       "wallet-tx-10x",
		DrawCreatedAtMs:           1_700_000_000_000,
		RewardGrantedAtMs:         1_700_000_000_500,
	})

	result, err := service.HandleLuckyGiftEvent(context.Background(), envelope)
	if err != nil {
		t.Fatalf("HandleLuckyGiftEvent failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "lucky_gift_big_win:lucky_draw_10x" {
		t.Fatalf("unexpected lucky gift consume result: %+v", result)
	}
	record := repository.records[result.BroadcastEventID]
	if record.GroupID != "hy_lalu_bc_r_v2_210" || record.BroadcastType != broadcastdomain.TypeLuckyGiftBigWin {
		t.Fatalf("unexpected lucky gift broadcast record: %+v", record)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
		t.Fatalf("lucky gift payload is not json: %v", err)
	}
	if payload["event_type"] != "lucky_gift_drawn" || payload["reward_status"] != "granted" || payload["sender_name"] != "Real Sender" || payload["sender_pretty_display_user_id"] != "888042" {
		t.Fatalf("lucky gift payload identity/profile mismatch: %+v", payload)
	}
	if payload["room_id"] != "room-210" || payload["room_locked"] != true || payload["owner_user_id"] != "323411852102995968" || payload["draw_id"] != "lucky_draw_10x" || payload["multiplier_ppm"] != float64(luckygiftmq.LuckyGiftDisplayMinMultiplierPPM) || payload["effective_reward_coins"] != float64(1000) || payload["sent_at_ms"] != float64(1_700_000_001_000) {
		t.Fatalf("lucky gift payload reward mismatch: %+v", payload)
	}
	if _, exists := payload["gift_name"]; exists {
		t.Fatalf("region payload must let Flutter resolve localized gift name: %+v", payload)
	}
	action, _ := payload["action"].(map[string]any)
	if action["type"] != "enter_room" || action["room_id"] != "room-210" || action["room_locked"] != true || action["owner_user_id"] != "323411852102995968" {
		t.Fatalf("lucky gift action mismatch: %+v", action)
	}
	sender, _ := payload["sender"].(map[string]any)
	if sender["nickname"] != "Real Sender" || sender["display_user_id"] != "160042" || sender["pretty_display_user_id"] != "888042" {
		t.Fatalf("nested lucky gift sender mismatch: %+v", sender)
	}

	again, err := service.HandleLuckyGiftEvent(context.Background(), envelope)
	if err != nil {
		t.Fatalf("duplicate HandleLuckyGiftEvent failed: %v", err)
	}
	if again.BroadcastCreated || len(repository.records) != 1 {
		t.Fatalf("duplicate lucky owner fact must reuse one outbox: result=%+v records=%d", again, len(repository.records))
	}
}

func TestHandleGameWinUsesAppThresholdAndLockedRoomSnapshot(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-game-win", GameWinMinCoins: 100000}, repository, nil, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(1_800_000_000_000).UTC() })
	service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{UserID: 42, Account: "160042", Nickname: "Winner", Avatar: "https://cdn.example/winner.png"}})
	service.SetRoomSnapshotSource(fakeRoomBroadcastSource{snapshot: RoomBroadcastSnapshot{RoomID: "room-310", Title: "Game Room", Locked: true, OwnerUserID: 323411852102995968, VisibleRegionID: 310, Status: "active"}})
	ctx := appcode.WithContext(context.Background(), "fami")
	config, err := service.UpdateGameWinBroadcastConfig(ctx, true, 500, 7)
	if err != nil || config.MinWinCoins != 500 || config.UpdatedByAdminID != 7 {
		t.Fatalf("update game win config failed: config=%+v err=%v", config, err)
	}

	message := gamemq.GameOutboxMessage{
		AppCode: "fami", EventID: "GameOrderSettled:order-1", EventType: gamemq.EventTypeGameOrderSettled,
		OrderID: "order-1", UserID: 42, PlatformCode: "provider", GameID: "slots", OpType: "credit",
		CoinAmount: 500, OccurredAtMS: 1_799_999_999_000,
		PayloadJSON: `{"room_id":"room-310","visible_region_id":310,"game_name":"Slots","game_icon_url":"https://cdn.example/slots.png","provider_round_id":"round-1","wallet_transaction_id":"wallet-1"}`,
	}
	result, err := service.HandleGameEvent(ctx, message)
	if err != nil {
		t.Fatalf("HandleGameEvent failed: %v", err)
	}
	if !result.BroadcastCreated || result.BroadcastEventID != "game_win:order-1" {
		t.Fatalf("game win result mismatch: %+v", result)
	}
	record := repository.records[result.BroadcastEventID]
	if record.BroadcastType != broadcastdomain.TypeGameWin || record.GroupID != "hy_fami_bc_r_v2_310" {
		t.Fatalf("game win outbox mismatch: %+v", record)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
		t.Fatalf("decode game win payload: %v", err)
	}
	if payload["room_id"] != "room-310" || payload["room_locked"] != true || payload["owner_user_id"] != "323411852102995968" || payload["coins"] != float64(500) || payload["game_name"] != "Slots" || payload["user_name"] != "Winner" {
		t.Fatalf("game win payload mismatch: %+v", payload)
	}
	jump, _ := payload["jump_payload"].(map[string]any)
	if jump["room_locked"] != true || jump["owner_user_id"] != "323411852102995968" || jump["window"] != "game_window" {
		t.Fatalf("game win jump must carry lock and game window: %+v", jump)
	}
	action, _ := payload["action"].(map[string]any)
	if action["owner_user_id"] != "323411852102995968" {
		t.Fatalf("game win action must carry owner snapshot: %+v", action)
	}

	message.OrderID = "order-below"
	message.EventID = "GameOrderSettled:order-below"
	message.CoinAmount = 499
	below, err := service.HandleGameEvent(ctx, message)
	if err != nil || below.Status != broadcastdomain.StatusSkipped || len(repository.records) != 1 {
		t.Fatalf("below-threshold payout must be skipped: result=%+v err=%v records=%d", below, err, len(repository.records))
	}
}

func TestHandleLuckyGiftEventSkipsNonBigWinFacts(t *testing.T) {
	tests := []struct {
		name   string
		mutate func(*luckygifteventsv1.LuckyGiftDrawn)
	}{
		{name: "below ten times", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) {
			event.MultiplierPpm = luckygiftmq.LuckyGiftDisplayMinMultiplierPPM - 1
		}},
		{name: "pending reward", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.RewardStatus = "pending" }},
		{name: "zero reward", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.EffectiveRewardCoins = 0 }},
		{name: "unknown region", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.VisibleRegionId = 0 }},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			repository := newFakeRepository()
			service := New(Config{NodeID: "node-a"}, repository, nil, nil)
			event := validLuckyGiftDrawn()
			test.mutate(event)
			result, err := service.HandleLuckyGiftEvent(context.Background(), mustLuckyGiftEnvelope(t, event))
			if err != nil {
				t.Fatalf("HandleLuckyGiftEvent failed: %v", err)
			}
			if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" || len(repository.records) != 0 {
				t.Fatalf("non-big-win fact must be skipped: result=%+v records=%+v", result, repository.records)
			}
		})
	}
}

func TestHandleLuckyGiftEventReturnsProfileFailureForMQRetry(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a"}, repository, nil, nil)
	service.SetSenderProfileSource(fakeSenderProfileSource{err: errors.New("user service timeout")})
	event := validLuckyGiftDrawn()
	// 旧 pending outbox 可能没有展示快照；activity 必须回查资料，失败时不能确认 MQ 或写不完整播报。
	event.SenderName = ""
	event.SenderDisplayUserId = ""

	if _, err := service.HandleLuckyGiftEvent(context.Background(), mustLuckyGiftEnvelope(t, event)); err == nil {
		t.Fatal("missing snapshot with profile failure should return an error")
	}
	if len(repository.records) != 0 {
		t.Fatalf("profile failure must not persist incomplete broadcast: %+v", repository.records)
	}
}

func TestHandleRoomGiftSentCreatesRegionBroadcastForTaggedGiftBelowThreshold(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a", SuperGiftMinValue: 1000}, repository, nil, nil)
	envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
		SenderUserId:    42,
		TargetUserId:    43,
		GiftId:          "tagged-gift",
		GiftCount:       1,
		GiftValue:       10,
		VisibleRegionId: 1001,
		CommandId:       "cmd-tagged-gift",
		GiftEffectTypes: []string{"animation", "global_broadcast"},
	})

	result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
	if err != nil {
		t.Fatalf("HandleRoomEvent tagged gift failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated {
		t.Fatalf("tagged gift should create broadcast: %+v", result)
	}
	record := repository.records[result.BroadcastEventID]
	if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
		t.Fatalf("unexpected tagged gift broadcast record: %+v", record)
	}
}

func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a"}, repository, nil, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_001_000) })
	envelope := mustPasswordChangedEnvelope(t, &roomeventsv1.RoomPasswordChanged{
		ActorUserId:     323411852102995968,
		Locked:          true,
		VisibleRegionId: 1001,
	})

	result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
	if err != nil {
		t.Fatalf("HandleRoomEvent password changed failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "room_password_changed:"+envelope.GetEventId() {
		t.Fatalf("unexpected password broadcast result: %+v", result)
	}
	record := repository.records[result.BroadcastEventID]
	if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.Scope != broadcastdomain.ScopeRegion || record.BroadcastType != broadcastdomain.TypeRoomPasswordChanged {
		t.Fatalf("unexpected password broadcast record: %+v", record)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
		t.Fatalf("password payload is not json: %v", err)
	}
	if payload["room_id"] != "room-1001" || payload["scope"] != "region" || payload["locked"] != true || payload["actor_user_id"] != "323411852102995968" {
		t.Fatalf("password payload mismatch: %+v", payload)
	}
	action, ok := payload["action"].(map[string]any)
	if !ok || action["type"] != "refresh_room" || action["room_id"] != "room-1001" {
		t.Fatalf("password payload action mismatch: %+v", payload["action"])
	}
}

func TestRoomRocketPayloadCarriesRoomLockSnapshot(t *testing.T) {
	payloadJSON, err := roomRocketPayload(&roomeventsv1.EventEnvelope{
		AppCode:     "lalu",
		RoomId:      "room-rocket-locked",
		RoomVersion: 9,
	}, &roomeventsv1.RoomRocketIgnited{
		RocketId:       "rocket-1",
		BroadcastScope: broadcastdomain.ScopeGlobal,
		RoomLocked:     true,
		OwnerUserId:    323411852102995968,
	}, "rocket-event-1", 1_700_000_000_000, SenderProfile{})
	if err != nil {
		t.Fatalf("roomRocketPayload failed: %v", err)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
		t.Fatalf("rocket payload is not json: %v", err)
	}
	action, _ := payload["action"].(map[string]any)
	if payload["room_locked"] != true || action["room_locked"] != true || payload["owner_user_id"] != "323411852102995968" || action["owner_user_id"] != "323411852102995968" {
		t.Fatalf("rocket payload and action must carry room lock snapshot: %+v", payload)
	}
}

func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) {
	service := New(Config{SuperGiftMinValue: 1000}, newFakeRepository(), nil, nil)
	belowThreshold := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001})
	result, err := service.HandleRoomEvent(context.Background(), belowThreshold)
	if err != nil {
		t.Fatalf("below threshold failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" {
		t.Fatalf("below threshold should skip: %+v", result)
	}

	globalRegion := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 1000, VisibleRegionId: 0})
	result, err = service.HandleRoomEvent(context.Background(), globalRegion)
	if err != nil {
		t.Fatalf("global region failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" {
		t.Fatalf("global region should skip: %+v", result)
	}
}

func TestProcessPendingBroadcastsPublishesCustomMessage(t *testing.T) {
	repository := newFakeRepository()
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
	repository.records["evt-1"] = broadcastdomain.OutboxRecord{
		AppCode:       "lalu",
		EventID:       "evt-1",
		GroupID:       "hy_lalu_bc_g",
		BroadcastType: broadcastdomain.TypeRedPacket,
		PayloadJSON:   `{"event_id":"evt-1","broadcast_type":"red_packet"}`,
		Status:        broadcastdomain.StatusPending,
	}

	result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
	if err != nil {
		t.Fatalf("ProcessPendingBroadcasts failed: %v", err)
	}
	if result.SuccessCount != 1 || len(publisher.messages) != 1 {
		t.Fatalf("publish result mismatch: result=%+v messages=%d", result, len(publisher.messages))
	}
	if repository.records["evt-1"].Status != broadcastdomain.StatusDelivered {
		t.Fatalf("record should be delivered: %+v", repository.records["evt-1"])
	}
	if publisher.messages[0].GroupID != "hy_lalu_bc_g" || publisher.messages[0].Desc != broadcastdomain.TypeRedPacket {
		t.Fatalf("message mismatch: %+v", publisher.messages[0])
	}
}

func TestVIPOnlineNoticeUsesGlobalOutboxAndStableEnvelope(t *testing.T) {
	repository := newFakeRepository()
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(1_800_000_000_000) })
	ctx := appcode.WithContext(context.Background(), "fami")
	input := PublishInput{
		EventID:       "vip_online_notice:42:digest",
		BroadcastType: broadcastdomain.TypeVIPOnlineNotice,
		PayloadJSON:   `{"event_id":"forged","broadcast_type":"forged","scope":"region","app_code":"lalu","region_id":999,"user_id":42,"vip_level":9}`,
	}
	result, err := service.PublishGlobalBroadcast(ctx, input)
	if err != nil {
		t.Fatalf("PublishGlobalBroadcast failed: %v", err)
	}
	if !result.Created || result.GroupID != "hy_fami_bc_g_v2" || result.Status != broadcastdomain.StatusPending {
		t.Fatalf("global publish result mismatch: %+v", result)
	}
	record := repository.records[input.EventID]
	if record.Scope != broadcastdomain.ScopeGlobal || record.GroupID != "hy_fami_bc_g_v2" || record.BroadcastType != broadcastdomain.TypeVIPOnlineNotice {
		t.Fatalf("global outbox mismatch: %+v", record)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
		t.Fatalf("decode global payload: %v", err)
	}
	if payload["event_id"] != input.EventID || payload["broadcast_type"] != broadcastdomain.TypeVIPOnlineNotice || payload["scope"] != broadcastdomain.ScopeGlobal || payload["app_code"] != "fami" || payload["sent_at_ms"] != float64(1_800_000_000_000) {
		t.Fatalf("forced global envelope mismatch: %+v", payload)
	}
	if _, exists := payload["region_id"]; exists {
		t.Fatalf("global payload must not carry region_id: %+v", payload)
	}

	// 同一个 event_id 的 HTTP 重试只返回已有 outbox，不能覆盖首个用户资料快照或重复发送。
	retry, err := service.PublishGlobalBroadcast(ctx, PublishInput{EventID: input.EventID, BroadcastType: broadcastdomain.TypeVIPOnlineNotice, PayloadJSON: `{"user_id":99}`})
	if err != nil || retry.Created || repository.records[input.EventID].PayloadJSON != record.PayloadJSON {
		t.Fatalf("global retry must be idempotent: retry=%+v record=%+v err=%v", retry, repository.records[input.EventID], err)
	}
	processed, err := service.ProcessPendingBroadcasts(ctx, WorkerOptions{WorkerID: "worker-vip", BatchSize: 10})
	if err != nil || processed.SuccessCount != 1 || len(publisher.messages) != 2 {
		// 全局 v2 成功后会向 v1 旧群补发一份，两个 TIMCustomElem 共享同一业务 payload。
		t.Fatalf("VIP online IM publish mismatch: result=%+v messages=%+v err=%v", processed, publisher.messages, err)
	}
	if publisher.messages[0].Desc != broadcastdomain.TypeVIPOnlineNotice || publisher.messages[0].Ext != "im_broadcast" || publisher.messages[0].EventID != input.EventID {
		t.Fatalf("VIP online TIMCustomElem mismatch: %+v", publisher.messages[0])
	}
}

func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a"}, repository, nil, nil)
	service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{
		UserID:     10001,
		Account:    "10001",
		Nickname:   "Tom",
		Avatar:     "https://example.com/avatar.png",
		RegionCode: "AR",
	}})
	event := broadcastdomain.RedPacketWalletEvent{
		AppCode:         "lalu",
		EventID:         "wallet-red-packet-created-1",
		EventType:       "WalletRedPacketCreated",
		PacketID:        "VRRP2026052712340001",
		PacketType:      "delayed",
		RoomID:          "9001",
		RegionID:        310,
		RoomLocked:      true,
		OwnerUserID:     323411852102995968,
		SenderUserID:    10001,
		TotalAmount:     15000,
		TotalCount:      5,
		RemainingAmount: 15000,
		RemainingCount:  5,
		Status:          "waiting_open",
		OpenAtMS:        1_780_000_000_000,
		ExpiresAtMS:     1_780_086_400_000,
		CreatedAtMS:     1_779_999_700_000,
	}

	if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil {
		t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err)
	}

	record := repository.records[event.EventID]
	data := redPacketPayloadData(t, record.PayloadJSON)
	if data["packetNo"] != event.PacketID || data["packetId"] != event.PacketID || data["scope"] != "ROOM" {
		t.Fatalf("packet identity mismatch: %+v", data)
	}
	if data["senderUserId"] != "10001" || data["actualAccount"] != "10001" || data["userNickname"] != "Tom" || data["userAvatar"] != "https://example.com/avatar.png" {
		t.Fatalf("sender display fields mismatch: %+v", data)
	}
	if data["totalAmount"] != float64(15000) || data["totalCount"] != float64(5) || data["remainAmount"] != float64(15000) || data["remainCount"] != float64(5) {
		t.Fatalf("red packet amount/count fields mismatch: %+v", data)
	}
	if data["packetMode"] != "DELAYED" || data["status"] != "WAITING_OPEN" || data["delaySeconds"] != float64(300) || data["regionCode"] != "AR" {
		t.Fatalf("red packet display state mismatch: %+v", data)
	}
	if data["room_locked"] != true {
		t.Fatalf("red packet payload must carry room lock snapshot: %+v", data)
	}
	if data["owner_user_id"] != "323411852102995968" {
		t.Fatalf("red packet payload must carry owner snapshot without JSON precision loss: %+v", data)
	}
}

func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
	nowMS := int64(1_779_999_700_000)
	openAtMS := int64(1_780_000_000_000)
	repository := newFakeRepository()
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(nowMS) })
	service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{
		UserID:     10001,
		Account:    "10001",
		Nickname:   "Tom",
		Avatar:     "https://example.com/avatar.png",
		RegionCode: "AR",
	}})
	event := broadcastdomain.RedPacketWalletEvent{
		AppCode:         "lalu",
		EventID:         "wallet-red-packet-created-1",
		EventType:       "WalletRedPacketCreated",
		PacketID:        "VRRP2026052712340001",
		PacketType:      "delayed",
		RoomID:          "9001",
		RegionID:        310,
		RoomLocked:      true,
		OwnerUserID:     323411852102995968,
		SenderUserID:    10001,
		TotalAmount:     15000,
		TotalCount:      5,
		RemainingAmount: 15000,
		RemainingCount:  5,
		Status:          "waiting_open",
		OpenAtMS:        openAtMS,
		ExpiresAtMS:     1_780_086_400_000,
		CreatedAtMS:     nowMS,
	}

	if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil {
		t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err)
	}

	roomCreated := repository.records[event.EventID]
	regionCreated := repository.records[event.EventID+":region_created"]
	openDue := repository.records[event.EventID+":open_due"]
	if roomCreated.GroupID != "9001" || roomCreated.Scope != broadcastdomain.ScopeRoom || roomCreated.NextRetryAtMS != nowMS {
		t.Fatalf("room created outbox mismatch: %+v", roomCreated)
	}
	if regionCreated.GroupID != "hy_lalu_bc_r_v2_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS {
		t.Fatalf("region created outbox mismatch: %+v", regionCreated)
	}
	if openDue.GroupID != "9001" || openDue.Scope != broadcastdomain.ScopeRoom || openDue.NextRetryAtMS != openAtMS {
		t.Fatalf("open due outbox mismatch: %+v", openDue)
	}
	regionData := redPacketPayloadData(t, regionCreated.PayloadJSON)
	if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" || regionData["room_locked"] != true || regionData["owner_user_id"] != "323411852102995968" {
		t.Fatalf("region created payload mismatch: %+v", regionData)
	}
	openDueData := redPacketPayloadData(t, openDue.PayloadJSON)
	if openDueData["event"] != "OPEN_DUE" || openDueData["scope"] != "ROOM" || openDueData["claimStartTime"] != float64(openAtMS) || openDueData["room_locked"] != true || openDueData["owner_user_id"] != "323411852102995968" {
		t.Fatalf("open due payload mismatch: %+v", openDueData)
	}

	result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
	if err != nil {
		t.Fatalf("ProcessPendingBroadcasts before open failed: %v", err)
	}
	// v2 区域群投递成功后过渡期会向 v1 旧群补发一条 _legacy 副本，所以消息比 outbox 记录多一条。
	if result.SuccessCount != 2 || len(publisher.messages) != 3 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
		t.Fatalf("before open only room/region CREATED should be sent: result=%+v messages=%+v", result, publisher.messages)
	}
	legacyCopy := findCustomMessageEvent(publisher.messages, event.EventID+":region_created_legacy")
	if legacyCopy == nil || legacyCopy.GroupID != "hy_lalu_bc_r_310" {
		t.Fatalf("region broadcast should dual-send to legacy v1 group: %+v", publisher.messages)
	}

	service.SetClock(func() time.Time { return time.UnixMilli(openAtMS) })
	result, err = service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
	if err != nil {
		t.Fatalf("ProcessPendingBroadcasts after open failed: %v", err)
	}
	if result.SuccessCount != 1 || !containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
		t.Fatalf("after open should send OPEN_DUE: result=%+v messages=%+v", result, publisher.messages)
	}
}

func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil)

	groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001)
	if err != nil {
		t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
	}
	if !removed || groupID != "hy_lalu_bc_r_v2_1001" {
		t.Fatalf("unexpected remove result: group_id=%s removed=%t", groupID, removed)
	}
	// 切区必须同时清 v2 主群和过渡期 v1 旧群的成员关系。
	if len(publisher.deletedGroupIDs) != 2 || publisher.deletedGroupIDs[0] != "hy_lalu_bc_r_v2_1001" || publisher.deletedGroupIDs[1] != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 {
		t.Fatalf("unexpected deleted member: group_ids=%v user_id=%d", publisher.deletedGroupIDs, publisher.deletedUserID)
	}

	groupID, removed, err = service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 0)
	if err != nil {
		t.Fatalf("zero region should be a no-op, got: %v", err)
	}
	if removed || groupID != "" {
		t.Fatalf("zero region should not delete any group member: group_id=%s removed=%t", groupID, removed)
	}
}

func TestRemoveRegionBroadcastMemberAcknowledgesAVChatKickRestriction(t *testing.T) {
	publisher := &fakePublisher{deleteError: tencentim.RESTError{Code: 10007, Info: "this group disable kick member"}}
	service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil)

	groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001)
	if err != nil {
		t.Fatalf("AVChat kick restriction must be acknowledged without retry: %v", err)
	}
	if removed || groupID != "hy_lalu_bc_r_v2_1001" {
		t.Fatalf("AVChat restriction result mismatch: group_id=%s removed=%t", groupID, removed)
	}
	if len(publisher.deletedGroupIDs) != 1 {
		t.Fatalf("unsupported v2 removal must not retry the legacy AVChat group: %v", publisher.deletedGroupIDs)
	}

	// 10007 还可能表示其他群业务错误；只能降级腾讯明确返回的 disable-kick 语义。
	publisher.deleteError = tencentim.RESTError{Code: 10007, Info: "another group error"}
	if _, _, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001); err == nil {
		t.Fatal("unrelated Tencent IM 10007 error must remain retryable")
	}
}

func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) {
	repository := newFakeRepository()
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a", GroupIDPrefix: "test_"}, repository, publisher, nil)

	result, err := service.PublishRegionBroadcast(appcode.WithContext(context.Background(), "lalu"), PublishInput{
		EventID:       "evt-prefixed",
		BroadcastType: broadcastdomain.TypeSuperGift,
		RegionID:      1001,
		PayloadJSON:   `{"ok":true}`,
	})
	if err != nil {
		t.Fatalf("PublishRegionBroadcast failed: %v", err)
	}
	if result.GroupID != "test_hy_lalu_bc_r_v2_1001" {
		t.Fatalf("prefixed publish group mismatch: %+v", result)
	}
	if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_v2_1001" {
		t.Fatalf("prefixed outbox group mismatch: %+v", record)
	}

	groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001)
	if err != nil {
		t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
	}
	if !removed || groupID != "test_hy_lalu_bc_r_v2_1001" || len(publisher.deletedGroupIDs) != 2 || publisher.deletedGroupIDs[0] != "test_hy_lalu_bc_r_v2_1001" || publisher.deletedGroupIDs[1] != "test_hy_lalu_bc_r_1001" {
		t.Fatalf("prefixed remove mismatch: group_id=%s removed=%t deleted=%v", groupID, removed, publisher.deletedGroupIDs)
	}
}

func TestEnsureBroadcastGroupsUsesAVChatRoomAndSurvivesGlobalFailure(t *testing.T) {
	publisher := &fakePublisher{ensureErrors: map[string]error{"hy_lalu_bc_g_v2": errors.New("quota exceeded")}}
	service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, fakeRegionSource{regionIDs: []int64{310, 320}})

	count, err := service.EnsureBroadcastGroups(appcode.WithContext(context.Background(), "lalu"))
	if err == nil {
		t.Fatal("global group failure must be reported")
	}
	// 全局群失败不再阻断区域群补偿：两个区域群仍应建成。
	if count != 2 {
		t.Fatalf("region groups must still be ensured after global failure: count=%d", count)
	}
	if len(publisher.ensuredGroups) != 3 {
		t.Fatalf("expected global+2 region ensure calls: %+v", publisher.ensuredGroups)
	}
	for _, spec := range publisher.ensuredGroups[1:] {
		// 区域群一律生成 v2 并使用 AVChatRoom（无 ApplyJoinOption），不再受 ChatRoom 成员上限约束。
		if spec.Type != tencentim.BroadcastGroupType || spec.ApplyJoinOption != "" {
			t.Fatalf("region group must be AVChatRoom without apply_join_option: %+v", spec)
		}
	}
	if publisher.ensuredGroups[1].GroupID != "hy_lalu_bc_r_v2_310" || publisher.ensuredGroups[2].GroupID != "hy_lalu_bc_r_v2_320" {
		t.Fatalf("region group ids mismatch: %+v", publisher.ensuredGroups)
	}
}

func TestReconcileBroadcastGroupsIncludesConfiguredAppWithoutOutbox(t *testing.T) {
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "multi-app-reconcile", AppCodes: []string{"lalu", "fami"}}, newFakeRepository(), publisher, fakeRegionSource{regionIDs: []int64{1001}})

	service.reconcileBroadcastGroups(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker"})

	wantGroups := map[string]bool{
		"hy_lalu_bc_g_v2":      true,
		"hy_lalu_bc_r_v2_1001": true,
		"hy_fami_bc_g_v2":      true,
		"hy_fami_bc_r_v2_1001": true,
	}
	for index, spec := range publisher.ensuredGroups {
		if !wantGroups[spec.GroupID] {
			t.Fatalf("unexpected configured app group: %+v", spec)
		}
		delete(wantGroups, spec.GroupID)
		parsed := imgroup.Parse(spec.GroupID)
		if parsed.AppCode != publisher.ensuredAppCodes[index] {
			t.Fatalf("group %s reconciled with wrong app context %s", spec.GroupID, publisher.ensuredAppCodes[index])
		}
	}
	if len(wantGroups) != 0 {
		t.Fatalf("configured app groups were not reconciled: %+v", wantGroups)
	}
}

func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
	t.Helper()
	body, err := proto.Marshal(gift)
	if err != nil {
		t.Fatalf("marshal gift failed: %v", err)
	}
	return &roomeventsv1.EventEnvelope{
		EventId:      "evt-room-gift-1",
		RoomId:       "room-1001",
		EventType:    "RoomGiftSent",
		RoomVersion:  7,
		OccurredAtMs: 1_700_000_000_000,
		AppCode:      "lalu",
		Body:         body,
	}
}

func mustPasswordChangedEnvelope(t *testing.T, event *roomeventsv1.RoomPasswordChanged) *roomeventsv1.EventEnvelope {
	t.Helper()
	body, err := proto.Marshal(event)
	if err != nil {
		t.Fatalf("marshal password changed failed: %v", err)
	}
	return &roomeventsv1.EventEnvelope{
		EventId:      "evt-room-password-1",
		RoomId:       "room-1001",
		EventType:    "RoomPasswordChanged",
		RoomVersion:  8,
		OccurredAtMs: 1_700_000_001_000,
		AppCode:      "lalu",
		Body:         body,
	}
}

func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any {
	t.Helper()

	var payload struct {
		Type string         `json:"type"`
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
		t.Fatalf("payload is not json: %v", err)
	}
	if payload.Type != "ROOM_RED_PACKET" || payload.Data == nil {
		t.Fatalf("unexpected red packet payload: %+v", payload)
	}
	return payload.Data
}

func validLuckyGiftDrawn() *luckygifteventsv1.LuckyGiftDrawn {
	return &luckygifteventsv1.LuckyGiftDrawn{
		DrawId:               "lucky_draw_valid",
		CommandId:            "cmd-lucky-valid",
		RoomId:               "room-210",
		PoolId:               "super_lucky",
		GiftId:               "43",
		GiftCount:            1,
		SenderUserId:         42,
		TargetUserId:         99,
		SenderName:           "Real Sender",
		SenderAvatar:         "https://cdn.example/sender.png",
		SenderDisplayUserId:  "160042",
		VisibleRegionId:      210,
		CoinSpent:            100,
		MultiplierPpm:        luckygiftmq.LuckyGiftDisplayMinMultiplierPPM,
		BaseRewardCoins:      1000,
		EffectiveRewardCoins: 1000,
		RewardStatus:         "granted",
		WalletTransactionId:  "wallet-tx-valid",
		DrawCreatedAtMs:      1_700_000_000_000,
		RewardGrantedAtMs:    1_700_000_000_500,
	}
}

func mustLuckyGiftEnvelope(t *testing.T, event *luckygifteventsv1.LuckyGiftDrawn) *luckygifteventsv1.EventEnvelope {
	t.Helper()
	body, err := proto.Marshal(event)
	if err != nil {
		t.Fatalf("marshal lucky gift drawn: %v", err)
	}
	return &luckygifteventsv1.EventEnvelope{
		EventId:      "lucky_gift_drawn:" + event.GetDrawId(),
		EventType:    luckygiftmq.EventTypeLuckyGiftDrawn,
		AppCode:      "lalu",
		DrawId:       event.GetDrawId(),
		OccurredAtMs: event.GetDrawCreatedAtMs(),
		Body:         body,
	}
}

func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool {
	return findCustomMessageEvent(messages, eventID) != nil
}

func findCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) *tencentim.CustomGroupMessage {
	for index := range messages {
		if messages[index].EventID == eventID {
			return &messages[index]
		}
	}
	return nil
}

type fakeRepository struct {
	records        map[string]broadcastdomain.OutboxRecord
	gameWinConfigs map[string]broadcastdomain.GameWinConfig
}

func newFakeRepository() *fakeRepository {
	return &fakeRepository{records: map[string]broadcastdomain.OutboxRecord{}, gameWinConfigs: map[string]broadcastdomain.GameWinConfig{}}
}

func (r *fakeRepository) GetGameWinBroadcastConfig(ctx context.Context) (broadcastdomain.GameWinConfig, bool, error) {
	config, exists := r.gameWinConfigs[appcode.FromContext(ctx)]
	return config, exists, nil
}

func (r *fakeRepository) UpsertGameWinBroadcastConfig(ctx context.Context, config broadcastdomain.GameWinConfig) (broadcastdomain.GameWinConfig, error) {
	app := appcode.FromContext(ctx)
	if existing, exists := r.gameWinConfigs[app]; exists {
		config.CreatedAtMS = existing.CreatedAtMS
	}
	r.gameWinConfigs[app] = config
	return config, nil
}

func (r *fakeRepository) SaveBroadcastOutbox(_ context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) {
	if existing, ok := r.records[record.EventID]; ok {
		return false, existing, nil
	}
	r.records[record.EventID] = record
	return true, record, nil
}

func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) {
	records := make([]broadcastdomain.OutboxRecord, 0, limit)
	for eventID, record := range r.records {
		if len(records) >= limit {
			break
		}
		if record.Status != broadcastdomain.StatusPending && record.Status != broadcastdomain.StatusRetryable {
			continue
		}
		if record.NextRetryAtMS > nowMS {
			continue
		}
		record.Status = broadcastdomain.StatusDelivering
		record.LockedBy = workerID
		record.LockedUntilMS = lockUntilMS
		r.records[eventID] = record
		records = append(records, record)
	}
	return records, nil
}

func (r *fakeRepository) MarkBroadcastOutboxDelivered(_ context.Context, eventID string, nowMS int64) error {
	record, ok := r.records[eventID]
	if !ok {
		return errors.New("record not found")
	}
	record.Status = broadcastdomain.StatusDelivered
	record.UpdatedAtMS = nowMS
	r.records[eventID] = record
	return nil
}

func (r *fakeRepository) MarkBroadcastOutboxFailed(_ context.Context, eventID string, _ string, nowMS int64, nextRetryAtMS int64, _ int, lastError string) error {
	record, ok := r.records[eventID]
	if !ok {
		return errors.New("record not found")
	}
	record.Status = broadcastdomain.StatusRetryable
	record.AttemptCount++
	record.NextRetryAtMS = nextRetryAtMS
	record.LastError = lastError
	record.UpdatedAtMS = nowMS
	r.records[eventID] = record
	return nil
}

type fakePublisher struct {
	messages        []tencentim.CustomGroupMessage
	ensuredGroups   []tencentim.GroupSpec
	ensuredAppCodes []string
	ensureErrors    map[string]error
	deletedGroupIDs []string
	deletedUserID   int64
	deleteError     error
}

func (p *fakePublisher) EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error {
	p.ensuredGroups = append(p.ensuredGroups, spec)
	p.ensuredAppCodes = append(p.ensuredAppCodes, appcode.FromContext(ctx))
	return p.ensureErrors[spec.GroupID]
}

func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
	p.messages = append(p.messages, message)
	return nil
}

func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, userID int64) error {
	p.deletedGroupIDs = append(p.deletedGroupIDs, groupID)
	p.deletedUserID = userID
	return p.deleteError
}

type fakeRegionSource struct {
	regionIDs []int64
}

func (s fakeRegionSource) ListActiveRegionIDs(context.Context) ([]int64, error) {
	return s.regionIDs, nil
}

type fakeSenderProfileSource struct {
	profile  SenderProfile
	profiles map[int64]SenderProfile
	err      error
}

type fakeRoomBroadcastSource struct {
	snapshot RoomBroadcastSnapshot
	err      error
}

func (s fakeRoomBroadcastSource) GetRoomBroadcastSnapshot(context.Context, string) (RoomBroadcastSnapshot, error) {
	return s.snapshot, s.err
}

func (s fakeSenderProfileSource) GetSenderProfile(_ context.Context, userID int64) (SenderProfile, error) {
	if s.profiles != nil {
		if profile, ok := s.profiles[userID]; ok {
			return profile, s.err
		}
	}
	profile := s.profile
	if profile.UserID <= 0 {
		profile.UserID = userID
	}
	return profile, s.err
}
