package luckygift

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

	"google.golang.org/grpc"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/tencentim"
	broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
	domain "hyapp/services/activity-service/internal/domain/luckygift"
	broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
)

func TestGetConfigReturnsPoolDefault(t *testing.T) {
	repository := &fakeLuckyGiftRepository{}
	service := New(repository)

	config, err := service.GetConfig(appcode.WithContext(context.Background(), "hyapp"), "pool_95")
	if err != nil {
		t.Fatalf("GetConfig returned error: %v", err)
	}
	if repository.getConfigCalls != 1 {
		t.Fatalf("expected repository to be called once, got %d", repository.getConfigCalls)
	}
	if config.PoolID != "pool_95" {
		t.Fatalf("expected pool config scope, got pool=%q", config.PoolID)
	}
}

func TestUpsertConfigPreservesPoolScope(t *testing.T) {
	repository := &fakeLuckyGiftRepository{}
	service := New(repository)
	config := DefaultRuleConfig("hyapp", "pool_98")
	config.Enabled = true

	if _, err := service.UpsertConfig(appcode.WithContext(context.Background(), "hyapp"), config); err != nil {
		t.Fatalf("UpsertConfig returned error: %v", err)
	}
	if repository.upserted.PoolID != "pool_98" {
		t.Fatalf("expected pool scope, got pool=%q", repository.upserted.PoolID)
	}
}

func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
	repository := &fakeLuckyGiftRepository{executeResult: domain.DrawResult{
		DrawID:               "lucky_draw_fast_1",
		DrawIDs:              []string{"lucky_draw_fast_1", "lucky_draw_fast_2"},
		CommandID:            "cmd-gift-fast",
		PoolID:               "super_lucky",
		GiftID:               "rose",
		RuleVersion:          12,
		ExperiencePool:       "novice",
		SelectedTierID:       "batch",
		MultiplierPPM:        3_000_000,
		EffectiveRewardCoins: 300,
		RewardStatus:         domain.StatusPending,
		CreatedAtMS:          1760000000000,
	}}
	wallet := &fakeLuckyGiftWallet{balanceAfter: 1300}
	userPublisher := &fakeLuckyGiftUserPublisher{}
	service := New(repository, WithWallet(wallet), WithUserPublisher(userPublisher))
	service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })

	result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{
		CommandID:    "cmd-gift-fast",
		PoolID:       "super_lucky",
		UserID:       42,
		TargetUserID: 99,
		CountryID:    15,
		DeviceID:     "device-1",
		RoomID:       "room-1",
		AnchorID:     "99",
		GiftID:       "rose",
		GiftCount:    2,
		CoinSpent:    200,
		PaidAtMS:     1760000000000,
	})
	if err != nil {
		t.Fatalf("Draw failed: %v", err)
	}
	if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 || wallet.last.GetCountryId() != 15 {
		t.Fatalf("wallet fast path request mismatch: %+v", wallet.last)
	}
	if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 0 {
		t.Fatalf("fast path result mismatch: %+v", result)
	}
	if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_fast_1,lucky_draw_fast_2" {
		t.Fatalf("fast path should mark all draw ids granted, got %s", got)
	}
	assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 1300)
}

func TestDrawBatchLeavesRewardSettlementToWorker(t *testing.T) {
	repository := &fakeLuckyGiftRepository{executeBatchResults: []domain.DrawResult{{
		DrawID:               "lucky_draw_batch_async_1",
		CommandID:            "cmd-gift-batch:target:42",
		PoolID:               "super_lucky",
		GiftID:               "rose",
		EffectiveRewardCoins: 300,
		RewardStatus:         domain.StatusPending,
	}}}
	wallet := &fakeLuckyGiftWallet{}
	service := New(repository, WithWallet(wallet))

	results, err := service.DrawBatch(appcode.WithContext(context.Background(), "lalu"), []domain.DrawCommand{{
		CommandID:    "cmd-gift-batch:target:42",
		PoolID:       "super_lucky",
		UserID:       42,
		TargetUserID: 99,
		DeviceID:     "device-1",
		RoomID:       "room-1",
		AnchorID:     "42",
		GiftID:       "rose",
		GiftCount:    1,
		CoinSpent:    100,
	}})
	if err != nil {
		t.Fatalf("DrawBatch failed: %v", err)
	}
	if len(results) != 1 || results[0].DrawID != "lucky_draw_batch_async_1" {
		t.Fatalf("DrawBatch result mismatch: %+v", results)
	}
	if wallet.last != nil || repository.grantedDrawID != "" || len(repository.grantedDrawIDs) != 0 {
		t.Fatalf("batch draw must not run synchronous wallet fast path: wallet=%+v granted=%s granted_ids=%v", wallet.last, repository.grantedDrawID, repository.grantedDrawIDs)
	}
	if len(repository.executeBatchCmds) != 1 || repository.executeBatchCmds[0].CommandID != "cmd-gift-batch:target:42" {
		t.Fatalf("batch draw must pass normalized commands to repository: %+v", repository.executeBatchCmds)
	}
}

func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
	repository := &fakeLuckyGiftRepository{executeResult: domain.DrawResult{
		DrawID:               "lucky_draw_retry",
		DrawIDs:              []string{"lucky_draw_retry"},
		CommandID:            "cmd-gift-retry",
		PoolID:               "super_lucky",
		GiftID:               "rose",
		EffectiveRewardCoins: 300,
		RewardStatus:         domain.StatusPending,
	}}
	wallet := &fakeLuckyGiftWallet{err: errors.New("wallet timeout")}
	service := New(repository, WithWallet(wallet))

	result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{
		CommandID: "cmd-gift-retry",
		PoolID:    "super_lucky",
		UserID:    42,
		DeviceID:  "device-1",
		RoomID:    "room-1",
		AnchorID:  "99",
		GiftID:    "rose",
		GiftCount: 1,
		CoinSpent: 100,
		PaidAtMS:  1760000000000,
	})
	if err != nil {
		t.Fatalf("Draw should keep the committed draw fact: %v", err)
	}
	if result.RewardStatus != domain.StatusPending || result.WalletTransactionID != "" || len(repository.grantedDrawIDs) != 0 {
		t.Fatalf("wallet failure must leave draw pending for outbox compensation: result=%+v granted=%v", result, repository.grantedDrawIDs)
	}
}

func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing.T) {
	payload, _ := json.Marshal(map[string]any{
		"event_type":             "lucky_gift_drawn",
		"event_id":               "lucky_gift_drawn:lucky_draw_test",
		"app_code":               "lalu",
		"draw_id":                "lucky_draw_test",
		"command_id":             "cmd-gift",
		"pool_id":                "super_lucky",
		"user_id":                42,
		"sender_user_id":         42,
		"target_user_id":         99,
		"country_id":             15,
		"visible_region_id":      210,
		"room_id":                "room-1",
		"gift_id":                "rose",
		"gift_count":             1,
		"coin_spent":             100,
		"effective_reward_coins": 300,
		"multiplier_ppm":         3000000,
		"stage_feedback":         true,
		"created_at_ms":          1760000000000,
	})
	repository := &fakeLuckyGiftRepository{
		outbox: []domain.DrawOutbox{{
			AppCode:     "lalu",
			OutboxID:    "lucky_lucky_draw_test",
			EventType:   "LuckyGiftDrawn",
			PayloadJSON: string(payload),
		}},
	}
	wallet := &fakeLuckyGiftWallet{}
	publisher := &fakeLuckyGiftPublisher{}
	userPublisher := &fakeLuckyGiftUserPublisher{}
	broadcaster := &fakeLuckyGiftRegionBroadcaster{}
	service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
	service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })

	processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
	if err != nil {
		t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("processed count mismatch: %d", processed)
	}
	if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 || wallet.last.GetCountryId() != 15 {
		t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
	}
	assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_test", "lucky_draw_test", 300)
	if broadcaster.last.EventID != "" {
		t.Fatalf("3x lucky gift must not enter region broadcast: %+v", broadcaster.last)
	}
	assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
	if repository.grantedDrawID != "lucky_draw_test" || repository.deliveredOutboxID != "lucky_lucky_draw_test" {
		t.Fatalf("repository settlement mismatch: granted=%s delivered=%s", repository.grantedDrawID, repository.deliveredOutboxID)
	}
}

func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProfile(t *testing.T) {
	payload, _ := json.Marshal(map[string]any{
		"event_type":             "lucky_gift_drawn",
		"event_id":               "lucky_gift_drawn:lucky_draw_5x",
		"app_code":               "lalu",
		"draw_id":                "lucky_draw_5x",
		"command_id":             "cmd-gift-5x",
		"pool_id":                "super_lucky",
		"user_id":                42,
		"sender_user_id":         42,
		"target_user_id":         99,
		"country_id":             15,
		"visible_region_id":      210,
		"room_id":                "room-1",
		"gift_id":                "rose",
		"gift_count":             1,
		"coin_spent":             100,
		"effective_reward_coins": 500,
		"multiplier_ppm":         luckyGiftRegionBroadcastMinMultiplierPPM,
		"created_at_ms":          1760000000000,
	})
	repository := &fakeLuckyGiftRepository{
		outbox: []domain.DrawOutbox{{
			AppCode:     "lalu",
			OutboxID:    "lucky_lucky_draw_5x",
			EventType:   "LuckyGiftDrawn",
			PayloadJSON: string(payload),
		}},
	}
	wallet := &fakeLuckyGiftWallet{}
	publisher := &fakeLuckyGiftPublisher{}
	userPublisher := &fakeLuckyGiftUserPublisher{}
	broadcaster := &fakeLuckyGiftRegionBroadcaster{}
	service := New(repository,
		WithWallet(wallet),
		WithRoomPublisher(publisher),
		WithUserPublisher(userPublisher),
		WithRegionBroadcaster(broadcaster),
		WithSenderProfileSource(fakeLuckyGiftSenderProfileSource{profiles: map[int64]broadcastservice.SenderProfile{
			42: {UserID: 42, Account: "160042", Nickname: "Real Sender", Avatar: "https://cdn.example/sender.png"},
		}}),
	)
	service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })

	processed, err := service.ProcessPendingDrawOutbox(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-1"})
	if err != nil {
		t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("processed count mismatch: %d", processed)
	}
	assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_5x", "lucky_draw_5x", 500)
	var roomPayload map[string]any
	if err := json.Unmarshal(publisher.last.PayloadJSON, &roomPayload); err != nil {
		t.Fatalf("room payload is invalid: %v", err)
	}
	if roomPayload["sender_name"] != "Real Sender" || roomPayload["sender_display_user_id"] != "160042" {
		t.Fatalf("room payload must include sender display snapshot: %+v", roomPayload)
	}
	if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_5x" || broadcaster.last.RegionID != 210 {
		t.Fatalf("5x lucky gift region broadcast mismatch: %+v", broadcaster.last)
	}
	var regionPayload map[string]any
	if err := json.Unmarshal([]byte(broadcaster.last.PayloadJSON), &regionPayload); err != nil {
		t.Fatalf("region payload is invalid: %v", err)
	}
	if regionPayload["sender_name"] != "Real Sender" || regionPayload["sender_display_user_id"] != "160042" || regionPayload["sender_avatar"] != "https://cdn.example/sender.png" {
		t.Fatalf("region payload must include sender display snapshot: %+v", regionPayload)
	}
	sender, _ := regionPayload["sender"].(map[string]any)
	if sender["nickname"] != "Real Sender" || sender["display_user_id"] != "160042" {
		t.Fatalf("nested sender profile mismatch: %+v", sender)
	}
	if _, exists := regionPayload["gift_name"]; exists {
		t.Fatalf("lucky gift region broadcast should not send a gift-name template field: %+v", regionPayload)
	}
}

func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *testing.T) {
	payload, _ := json.Marshal(map[string]any{
		"event_type":             "lucky_gift_drawn",
		"event_id":               "lucky_gift_drawn:lucky_draw_batch_1",
		"app_code":               "lalu",
		"draw_id":                "lucky_draw_batch_1",
		"draw_ids":               []string{"lucky_draw_batch_1", "lucky_draw_batch_2", "lucky_draw_batch_3"},
		"command_id":             "cmd-gift-batch",
		"pool_id":                "lucky_100",
		"user_id":                42,
		"sender_user_id":         42,
		"target_user_id":         99,
		"country_id":             15,
		"room_id":                "room-1",
		"gift_id":                "rose",
		"gift_count":             3,
		"visible_region_id":      210,
		"coin_spent":             300,
		"effective_reward_coins": 800,
		"multiplier_ppm":         8000000,
		"created_at_ms":          1760000000000,
	})
	repository := &fakeLuckyGiftRepository{
		outbox: []domain.DrawOutbox{{
			AppCode:     "lalu",
			OutboxID:    "lucky_lucky_draw_batch_1",
			EventType:   "LuckyGiftDrawn",
			PayloadJSON: string(payload),
		}},
	}
	wallet := &fakeLuckyGiftWallet{}
	publisher := &fakeLuckyGiftPublisher{}
	userPublisher := &fakeLuckyGiftUserPublisher{}
	broadcaster := &fakeLuckyGiftRegionBroadcaster{}
	service := New(repository,
		WithWallet(wallet),
		WithRoomPublisher(publisher),
		WithUserPublisher(userPublisher),
		WithRegionBroadcaster(broadcaster),
		WithSenderProfileSource(fakeLuckyGiftSenderProfileSource{profiles: map[int64]broadcastservice.SenderProfile{
			42: {UserID: 42, Account: "160042", Nickname: "Batch Sender"},
		}}),
	)
	service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })

	processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
	if err != nil {
		t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("processed count mismatch: %d", processed)
	}
	if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetCountryId() != 15 || wallet.last.GetVisibleRegionId() != 210 {
		t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last)
	}
	if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
		t.Fatalf("all sub draws should be granted together, got %s", got)
	}
	assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 800, 0)
	assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 800)
	if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_batch_1" || broadcaster.last.RegionID != 210 {
		t.Fatalf("batch big win region broadcast mismatch: %+v", broadcaster.last)
	}
}

func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(t *testing.T) {
	payload, _ := json.Marshal(map[string]any{
		"event_type":             "lucky_gift_drawn",
		"event_id":               "lucky_gift_drawn:lucky_draw_settle",
		"app_code":               "lalu",
		"draw_id":                "lucky_draw_settle",
		"command_id":             "cmd-gift-settle",
		"pool_id":                "super_lucky",
		"user_id":                42,
		"visible_region_id":      210,
		"room_id":                "room-1",
		"gift_id":                "rose",
		"gift_count":             1,
		"coin_spent":             100,
		"effective_reward_coins": 300,
		"multiplier_ppm":         3000000,
		"created_at_ms":          1760000000000,
	})
	repository := &fakeLuckyGiftRepository{
		outbox: []domain.DrawOutbox{{
			AppCode:     "lalu",
			OutboxID:    "lucky_reward_lucky_draw_settle",
			EventType:   domain.EventTypeLuckyGiftRewardSettlement,
			PayloadJSON: string(payload),
		}},
	}
	wallet := &fakeLuckyGiftWallet{}
	publisher := &fakeLuckyGiftPublisher{}
	userPublisher := &fakeLuckyGiftUserPublisher{}
	broadcaster := &fakeLuckyGiftRegionBroadcaster{}
	service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))

	processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
	if err != nil {
		t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
	}
	if processed != 1 || repository.deliveredOutboxID != "lucky_reward_lucky_draw_settle" {
		t.Fatalf("settlement outbox should be delivered: processed=%d delivered=%s", processed, repository.deliveredOutboxID)
	}
	if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" {
		t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID)
	}
	assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 300)
	if broadcaster.last.EventID != "" {
		t.Fatalf("3x settlement lucky gift must not enter region broadcast: %+v", broadcaster.last)
	}
	assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
}

func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
	drawIDs := make([]string, 0, 999)
	for index := 0; index < 999; index++ {
		drawIDs = append(drawIDs, fmt.Sprintf("lucky_draw_1780217057721_%016d", index))
	}
	publisher := &fakeLuckyGiftPublisher{}
	service := New(&fakeLuckyGiftRepository{}, WithRoomPublisher(publisher))
	payload := luckyGiftDrawnPayload{
		EventType:                 "lucky_gift_drawn",
		EventID:                   "lucky_gift_drawn:lucky_draw_1780217057721_0000000000000000",
		AppCode:                   "lalu",
		DrawID:                    drawIDs[0],
		DrawIDs:                   drawIDs,
		CommandID:                 "cmd-gift-batch",
		PoolID:                    "lucky",
		RoomID:                    "lalu_9aca4db6-35f0-4490-9cbc-1a452b7fa67d",
		GiftID:                    "31",
		GiftCount:                 999,
		UserID:                    42,
		SenderUserID:              42,
		TargetUserID:              99,
		VisibleRegionID:           210,
		CoinSpent:                 99900,
		RuleVersion:               2,
		ExperiencePool:            "advanced",
		SelectedTierID:            "advanced_1x",
		MultiplierPPM:             1000000,
		BaseRewardCoins:           73983,
		RoomAtmosphereRewardCoins: 0,
		ActivitySubsidyCoins:      0,
		EffectiveRewardCoins:      73983,
		CreatedAtMS:               1780217057721,
	}

	if err := service.publishLuckyGiftDrawn(context.Background(), payload, WorkerOptions{PublishTimeout: time.Second}); err != nil {
		t.Fatalf("publishLuckyGiftDrawn failed: %v", err)
	}
	if len(publisher.last.PayloadJSON) >= 12*1024 {
		t.Fatalf("room IM payload should stay below Tencent 12KB limit, got %d bytes", len(publisher.last.PayloadJSON))
	}
	var body map[string]any
	if err := json.Unmarshal(publisher.last.PayloadJSON, &body); err != nil {
		t.Fatalf("decode room IM payload failed: %v", err)
	}
	if _, exists := body["draw_ids"]; exists {
		t.Fatalf("room IM payload must not include internal draw_ids")
	}
	if body["draw_id"] != drawIDs[0] || body["event_id"] != payload.EventID || body["gift_count"].(float64) != 999 {
		t.Fatalf("room IM payload lost display fields: %+v", body)
	}
}

func TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(t *testing.T) {
	payload, _ := json.Marshal(map[string]any{
		"event_type":             "lucky_gift_drawn",
		"event_id":               "lucky_gift_drawn:lucky_draw_im_fail",
		"app_code":               "lalu",
		"draw_id":                "lucky_draw_im_fail",
		"command_id":             "cmd-gift-im-fail",
		"pool_id":                "super_lucky",
		"user_id":                42,
		"room_id":                "room-1",
		"gift_id":                "rose",
		"gift_count":             1,
		"coin_spent":             100,
		"effective_reward_coins": 300,
		"multiplier_ppm":         3000000,
		"created_at_ms":          1760000000000,
	})
	repository := &fakeLuckyGiftRepository{
		outbox: []domain.DrawOutbox{{
			AppCode:     "lalu",
			OutboxID:    "lucky_lucky_draw_im_fail",
			EventType:   "LuckyGiftDrawn",
			PayloadJSON: string(payload),
		}},
	}
	wallet := &fakeLuckyGiftWallet{}
	publisher := &fakeLuckyGiftPublisher{err: errors.New("im unavailable")}
	userPublisher := &fakeLuckyGiftUserPublisher{}
	service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
	service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })

	processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
	if err != nil {
		t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("processed count mismatch: %d", processed)
	}
	if repository.grantedDrawID != "lucky_draw_im_fail" {
		t.Fatalf("draw should be settled through wallet, got %q", repository.grantedDrawID)
	}
	if repository.deliveredOutboxID != "lucky_lucky_draw_im_fail" || repository.retryableOutboxID != "" {
		t.Fatalf("IM publisher must not affect settlement outbox: delivered=%s retryable=%s", repository.deliveredOutboxID, repository.retryableOutboxID)
	}
	if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_im_fail" {
		t.Fatalf("room IM should still be attempted before ignoring failure: %+v", publisher.last)
	}
	assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
}

func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) {
	payload, _ := json.Marshal(map[string]any{
		"event_type":             "lucky_gift_drawn",
		"event_id":               "lucky_gift_drawn:lucky_draw_granted",
		"app_code":               "lalu",
		"draw_id":                "lucky_draw_granted",
		"command_id":             "cmd-gift-granted",
		"pool_id":                "super_lucky",
		"user_id":                42,
		"room_id":                "room-1",
		"gift_id":                "rose",
		"gift_count":             1,
		"coin_spent":             100,
		"effective_reward_coins": 300,
		"multiplier_ppm":         3_000_000,
		"created_at_ms":          1760000000000,
	})
	repository := &fakeLuckyGiftRepository{
		rewardState: domain.DrawRewardState{AllGranted: true, WalletTransactionID: "wallet_tx_existing"},
		outbox: []domain.DrawOutbox{{
			AppCode:     "lalu",
			OutboxID:    "lucky_lucky_draw_granted",
			EventType:   "LuckyGiftDrawn",
			PayloadJSON: string(payload),
		}},
	}
	wallet := &fakeLuckyGiftWallet{}
	publisher := &fakeLuckyGiftPublisher{}
	userPublisher := &fakeLuckyGiftUserPublisher{}
	service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))

	processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
	if err != nil {
		t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("processed count mismatch: %d", processed)
	}
	if wallet.last != nil {
		t.Fatalf("already granted draw must not call wallet again: %+v", wallet.last)
	}
	if repository.grantedDrawID != "" {
		t.Fatalf("already granted draw should not be marked again, got %q", repository.grantedDrawID)
	}
	if userPublisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
		t.Fatalf("already granted draw should not resend wallet notice and must deliver outbox: user_publisher=%+v delivered=%s", userPublisher.last, repository.deliveredOutboxID)
	}
	assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 300)
}

type fakeLuckyGiftRepository struct {
	getConfigCalls      int
	upserted            domain.RuleConfig
	executeResult       domain.DrawResult
	executeCmd          domain.DrawCommand
	executeBatchResults []domain.DrawResult
	executeBatchCmds    []domain.DrawCommand
	rewardState         domain.DrawRewardState
	outbox              []domain.DrawOutbox

	grantedDrawID     string
	grantedDrawIDs    []string
	deliveredOutboxID string
	retryableOutboxID string
}

func (r *fakeLuckyGiftRepository) GetLuckyGiftRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) {
	r.getConfigCalls++
	return domain.RuleConfig{}, false, nil
}

func (r *fakeLuckyGiftRepository) PublishLuckyGiftRuleConfig(_ context.Context, config domain.RuleConfig, _ int64) (domain.RuleConfig, error) {
	r.upserted = config
	return config, nil
}

func (r *fakeLuckyGiftRepository) ListLuckyGiftRuleConfigs(context.Context) ([]domain.RuleConfig, error) {
	return nil, nil
}

func (r *fakeLuckyGiftRepository) CheckLuckyGift(context.Context, domain.CheckCommand) (domain.CheckResult, error) {
	return domain.CheckResult{}, nil
}

func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(_ context.Context, cmd domain.DrawCommand, _ int64) (domain.DrawResult, error) {
	r.executeCmd = cmd
	return r.executeResult, nil
}

func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDrawBatch(_ context.Context, cmds []domain.DrawCommand, _ int64) ([]domain.DrawResult, error) {
	r.executeBatchCmds = append([]domain.DrawCommand(nil), cmds...)
	if len(r.executeBatchResults) > 0 {
		return append([]domain.DrawResult(nil), r.executeBatchResults...), nil
	}
	results := make([]domain.DrawResult, 0, len(cmds))
	for _, cmd := range cmds {
		result := r.executeResult
		result.CommandID = cmd.CommandID
		results = append(results, result)
	}
	return results, nil
}

func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
	return nil, 0, nil
}

func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) {
	return domain.DrawSummary{}, nil
}

func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawRewardState(context.Context, string, []string) (domain.DrawRewardState, error) {
	return r.rewardState, nil
}

func (r *fakeLuckyGiftRepository) ClaimPendingLuckyGiftOutbox(context.Context, string, int64, time.Duration, int) ([]domain.DrawOutbox, error) {
	return r.outbox, nil
}

func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxDelivered(_ context.Context, event domain.DrawOutbox, _ int64) error {
	r.deliveredOutboxID = event.OutboxID
	return nil
}

func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(_ context.Context, event domain.DrawOutbox, _ int, _ int64, _ string, _ int64) error {
	r.retryableOutboxID = event.OutboxID
	return nil
}

func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxFailed(context.Context, domain.DrawOutbox, int, string, int64) error {
	return nil
}

func (r *fakeLuckyGiftRepository) RefreshLuckyGiftAdminStatsSnapshots(context.Context, int64, int) (int, error) {
	return 0, nil
}

func (r *fakeLuckyGiftRepository) RefreshLuckyGiftDatabiStatsSnapshots(context.Context, int64, int) (int, error) {
	return 0, nil
}

func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawGranted(_ context.Context, _ string, drawID string, _ string, _ int64) error {
	r.grantedDrawID = drawID
	return nil
}

func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawFailed(context.Context, string, string, string, int64) error {
	return nil
}

func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawsGranted(_ context.Context, _ string, drawIDs []string, _ string, _ int64) error {
	r.grantedDrawIDs = append([]string(nil), drawIDs...)
	if len(drawIDs) > 0 {
		r.grantedDrawID = drawIDs[0]
	}
	return nil
}

func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawsFailed(context.Context, string, []string, string, int64) error {
	return nil
}

type fakeLuckyGiftWallet struct {
	last         *walletv1.CreditLuckyGiftRewardRequest
	err          error
	balanceAfter int64
}

func (w *fakeLuckyGiftWallet) CreditLuckyGiftReward(_ context.Context, req *walletv1.CreditLuckyGiftRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error) {
	w.last = req
	if w.err != nil {
		return nil, w.err
	}
	return &walletv1.CreditLuckyGiftRewardResponse{
		TransactionId: "wallet_tx_lucky",
		Amount:        req.GetAmount(),
		Balance:       &walletv1.AssetBalance{AvailableAmount: w.balanceAfter},
	}, nil
}

type fakeLuckyGiftPublisher struct {
	last tencentim.CustomGroupMessage
	err  error
}

func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
	p.last = message
	return p.err
}

type fakeLuckyGiftUserPublisher struct {
	last tencentim.CustomUserMessage
	err  error
}

func (p *fakeLuckyGiftUserPublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error {
	p.last = message
	return p.err
}

type fakeLuckyGiftRegionBroadcaster struct {
	last broadcastservice.PublishInput
}

func (b *fakeLuckyGiftRegionBroadcaster) PublishRegionBroadcast(_ context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error) {
	b.last = input
	return broadcastdomain.PublishResult{EventID: input.EventID, Status: broadcastdomain.StatusPending, Created: true}, nil
}

type fakeLuckyGiftSenderProfileSource struct {
	profiles map[int64]broadcastservice.SenderProfile
	err      error
}

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

func assertLuckyGiftWalletNotice(t *testing.T, message tencentim.CustomUserMessage, toAccount string, transactionID string, availableDelta int64, availableAfter int64) {
	t.Helper()
	if message.ToAccount != toAccount || message.Desc != "WalletBalanceChanged" || message.Ext != "wallet_notice" {
		t.Fatalf("wallet notice envelope mismatch: %+v", message)
	}
	if message.EventID != walletBalanceChangedEventID(transactionID, 42, "COIN") {
		t.Fatalf("wallet notice event id mismatch: %s", message.EventID)
	}
	var payload map[string]any
	if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
		t.Fatalf("wallet notice payload is invalid: %v", err)
	}
	if payload["event_type"] != "WalletBalanceChanged" || payload["transaction_id"] != transactionID || payload["user_id"] != "42" || payload["asset_type"] != "COIN" {
		t.Fatalf("wallet notice identity fields mismatch: %+v", payload)
	}
	if int64(payload["available_delta"].(float64)) != availableDelta || int64(payload["available_after"].(float64)) != availableAfter {
		t.Fatalf("wallet notice balance fields mismatch: %+v", payload)
	}
}

func assertLuckyGiftRoomMessage(t *testing.T, message tencentim.CustomGroupMessage, groupID string, eventID string, drawID string, rewardCoins int64) {
	t.Helper()
	if message.GroupID != groupID || message.EventID != eventID || message.Desc != "lucky_gift_drawn" || message.Ext != "room_system_message" {
		t.Fatalf("room lucky gift envelope mismatch: %+v", message)
	}
	var payload map[string]any
	if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
		t.Fatalf("room lucky gift payload is invalid: %v", err)
	}
	if payload["event_type"] != "lucky_gift_drawn" || payload["event_id"] != eventID || payload["draw_id"] != drawID {
		t.Fatalf("room lucky gift identity fields mismatch: %+v", payload)
	}
	if int64(payload["effective_reward_coins"].(float64)) != rewardCoins {
		t.Fatalf("room lucky gift reward mismatch: %+v", payload)
	}
}
