package task_test

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

	"google.golang.org/grpc"
	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	taskdomain "hyapp/services/activity-service/internal/domain/task"
	taskservice "hyapp/services/activity-service/internal/service/task"
	"hyapp/services/activity-service/internal/testutil/mysqltest"
)

// TestHandleRoomEventKeepsCountForProductionZeroValueGift 回归生产 gift_id=459 的零价礼物。
// 这类事实必须确认并累计送礼次数，不能生成会被领域层拒绝的零增量 spend 事件。
func TestHandleRoomEventKeepsCountForProductionZeroValueGift(t *testing.T) {
	repository := newRecordingTaskRepository()
	svc := taskservice.New(repository, &fakeWalletClient{})
	envelope := roomGiftEnvelope(t, "evt-production-free-gift-459", &roomeventsv1.RoomGiftSent{
		SenderUserId: 10001,
		TargetUserId: 20002,
		GiftId:       "459",
		GiftCount:    1,
		GiftValue:    0,
		CoinSpent:    0,
		GiftTypeCode: "normal",
	})

	consumed, err := svc.HandleRoomEvent(context.Background(), envelope)
	if err != nil {
		t.Fatalf("HandleRoomEvent zero-value gift failed: %v", err)
	}
	if consumed != 1 {
		t.Fatalf("zero-value gift consumed=%d, want one count projection", consumed)
	}
	assertRecordedTaskEvents(t, repository.events, []expectedTaskEvent{{
		EventID:    "evt-production-free-gift-459:task:gift_count",
		MetricType: taskdomain.MetricGiftSendCount,
		Value:      1,
	}})

	// RocketMQ 重投会用相同 envelope event_id 再次进入；稳定后缀让仓储幂等确认，不重复累加。
	if _, err := svc.HandleRoomEvent(context.Background(), envelope); err != nil {
		t.Fatalf("HandleRoomEvent duplicate zero-value gift failed: %v", err)
	}
	if len(repository.events) != 1 || repository.calls != 2 {
		t.Fatalf("duplicate projection must keep one unique event: calls=%d events=%+v", repository.calls, repository.events)
	}
}

func TestHandleRoomEventUsesPositiveSpendCompatibilityFields(t *testing.T) {
	tests := []struct {
		name      string
		eventID   string
		giftValue int64
		coinSpent int64
		wantSpend int64
	}{
		{name: "legacy gift_value fallback", eventID: "evt-legacy-gift-value", giftValue: 40, coinSpent: 0, wantSpend: 40},
		{name: "coin_spent preferred", eventID: "evt-coin-spent", giftValue: 0, coinSpent: 60, wantSpend: 60},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			repository := newRecordingTaskRepository()
			svc := taskservice.New(repository, &fakeWalletClient{})
			envelope := roomGiftEnvelope(t, tt.eventID, &roomeventsv1.RoomGiftSent{
				SenderUserId: 10001,
				TargetUserId: 20002,
				GiftId:       "gift-compat",
				GiftCount:    3,
				GiftValue:    tt.giftValue,
				CoinSpent:    tt.coinSpent,
				GiftTypeCode: "normal",
			})

			if _, err := svc.HandleRoomEvent(context.Background(), envelope); err != nil {
				t.Fatalf("HandleRoomEvent failed: %v", err)
			}
			assertRecordedTaskEvents(t, repository.events, []expectedTaskEvent{
				{EventID: envelope.GetEventId() + ":task:gift_spend", MetricType: taskdomain.MetricGiftSpendCoin, Value: tt.wantSpend},
				{EventID: envelope.GetEventId() + ":task:gift_count", MetricType: taskdomain.MetricGiftSendCount, Value: 3},
			})
		})
	}
}

// TestHandleRoomEventKeepsLuckyCountsForZeroValueGift 锁定幸运礼物的两层计数口径：
// 免费礼物不累计金币，但仍同时属于普通送礼次数和幸运礼物次数。
func TestHandleRoomEventKeepsLuckyCountsForZeroValueGift(t *testing.T) {
	repository := newRecordingTaskRepository()
	svc := taskservice.New(repository, &fakeWalletClient{})
	envelope := roomGiftEnvelope(t, "evt-free-lucky-gift", &roomeventsv1.RoomGiftSent{
		SenderUserId: 10001,
		TargetUserId: 20002,
		GiftId:       "free-lucky",
		GiftCount:    5,
		GiftValue:    0,
		CoinSpent:    0,
		PoolId:       "lucky_pool_free",
		GiftTypeCode: "normal",
	})

	if _, err := svc.HandleRoomEvent(context.Background(), envelope); err != nil {
		t.Fatalf("HandleRoomEvent zero-value lucky gift failed: %v", err)
	}
	assertRecordedTaskEvents(t, repository.events, []expectedTaskEvent{
		{EventID: "evt-free-lucky-gift:task:gift_count", MetricType: taskdomain.MetricGiftSendCount, Value: 5},
		{EventID: "evt-free-lucky-gift:task:lucky_gift_count", MetricType: taskdomain.MetricLuckyGiftSendCount, Value: 5},
	})
}

// TestConsumeTaskEventStillRejectsZeroValue 保留通用领域边界：零增量应在事实转换层被过滤，
// 不能进入仓储的幂等表和进度表，否则会混淆“已消费”与“有有效进度”。
func TestConsumeTaskEventStillRejectsZeroValue(t *testing.T) {
	repository := newRecordingTaskRepository()
	svc := taskservice.New(repository, &fakeWalletClient{})
	_, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
		EventID:       "evt-zero-domain-value",
		EventType:     "RoomGiftSent",
		SourceService: "room-service",
		UserID:        10001,
		MetricType:    taskdomain.MetricGiftSpendCoin,
		Value:         0,
	})
	if !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("zero task value error=%v, want INVALID_ARGUMENT", err)
	}
	if repository.calls != 0 || len(repository.events) != 0 {
		t.Fatalf("invalid zero-value event must not reach repository: calls=%d events=%+v", repository.calls, repository.events)
	}
}

// TestHandleRoomEventStillRejectsNegativeSpend 确认兼容分支只确认合法零值，不把负值上游事实降级成计数事件。
func TestHandleRoomEventStillRejectsNegativeSpend(t *testing.T) {
	tests := []struct {
		name      string
		giftValue int64
		coinSpent int64
	}{
		{name: "negative coin spent does not fallback", giftValue: 100, coinSpent: -1},
		{name: "negative legacy gift value", giftValue: -1, coinSpent: 0},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			repository := newRecordingTaskRepository()
			svc := taskservice.New(repository, &fakeWalletClient{})
			envelope := roomGiftEnvelope(t, "evt-negative-gift-value", &roomeventsv1.RoomGiftSent{
				SenderUserId: 10001,
				TargetUserId: 20002,
				GiftId:       "broken-gift",
				GiftCount:    1,
				GiftValue:    test.giftValue,
				CoinSpent:    test.coinSpent,
			})

			if _, err := svc.HandleRoomEvent(context.Background(), envelope); !xerr.IsCode(err, xerr.InvalidArgument) {
				t.Fatalf("negative spend error=%v, want INVALID_ARGUMENT", err)
			}
			if repository.calls != 0 || len(repository.events) != 0 {
				t.Fatalf("negative spend must fail before repository: calls=%d events=%+v", repository.calls, repository.events)
			}
		})
	}
}

// TestTaskEventQueryAndClaimFlow 覆盖任务定义、事件进度、App 查询、领奖和领奖幂等的主闭环。
func TestTaskEventQueryAndClaimFlow(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	wallet := &fakeWalletClient{}
	svc := taskservice.New(repository, wallet)
	now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.FixedZone("CST", 8*60*60))
	svc.SetClock(func() time.Time { return now })

	definition, created, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{
		TaskType:         taskdomain.TypeDaily,
		Category:         "gift",
		MetricType:       "gift_spend_coin",
		Title:            "send gift",
		TargetValue:      100,
		TargetUnit:       "coin",
		RewardCoinAmount: 10,
		RewardAssetType:  taskdomain.RewardAssetCoin,
		Status:           taskdomain.StatusActive,
		SortOrder:        1,
		OperatorAdminID:  90001,
	})
	if err != nil || !created {
		t.Fatalf("create task definition failed: created=%v err=%v", created, err)
	}

	result, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
		EventID:       "evt-gift-1",
		EventType:     "WalletGiftDebited",
		SourceService: "wallet",
		UserID:        10001,
		MetricType:    "gift_spend_coin",
		Value:         120,
		OccurredAtMS:  now.UnixMilli(),
	})
	if err != nil {
		t.Fatalf("ConsumeTaskEvent failed: %v", err)
	}
	if result.Status != taskdomain.EventStatusConsumed || result.MatchedTaskCount != 1 {
		t.Fatalf("event result mismatch: %+v", result)
	}
	duplicate, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
		EventID:       "evt-gift-1",
		EventType:     "WalletGiftDebited",
		SourceService: "wallet",
		UserID:        10001,
		MetricType:    "gift_spend_coin",
		Value:         120,
		OccurredAtMS:  now.UnixMilli(),
	})
	if err != nil || duplicate.Status != taskdomain.EventStatusConsumed {
		t.Fatalf("duplicate event should be acknowledged: result=%+v err=%v", duplicate, err)
	}

	list, err := svc.ListUserTasks(context.Background(), 10001)
	if err != nil {
		t.Fatalf("ListUserTasks failed: %v", err)
	}
	daily := list.Sections[0]
	if len(daily.Items) != 1 || daily.Items[0].TaskID != definition.TaskID || daily.Items[0].ProgressValue != 120 || daily.Items[0].UserStatus != taskdomain.StatusCompleted || !daily.Items[0].Claimable {
		t.Fatalf("daily task item mismatch: %+v", daily.Items)
	}

	claim, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{
		UserID:    10001,
		TaskID:    definition.TaskID,
		TaskType:  taskdomain.TypeDaily,
		CycleKey:  "2026-05-09",
		CommandID: "cmd-claim-1",
	})
	if err != nil {
		t.Fatalf("ClaimTaskReward failed: %v", err)
	}
	if claim.Status != taskdomain.ClaimStatusGranted || claim.WalletTransactionID != "wtx-task-1" || wallet.calls != 1 {
		t.Fatalf("claim result mismatch: claim=%+v wallet_calls=%d", claim, wallet.calls)
	}
	again, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{
		UserID:    10001,
		TaskID:    definition.TaskID,
		TaskType:  taskdomain.TypeDaily,
		CycleKey:  "2026-05-09",
		CommandID: "cmd-claim-1",
	})
	if err != nil {
		t.Fatalf("ClaimTaskReward retry failed: %v", err)
	}
	if again.ClaimID != claim.ClaimID || wallet.calls != 1 {
		t.Fatalf("claim idempotency mismatch: first=%+v second=%+v calls=%d", claim, again, wallet.calls)
	}
}

func TestTaskEventDimensionFilter(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := taskservice.New(repository, &fakeWalletClient{})
	now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
	svc.SetClock(func() time.Time { return now })

	definition, created, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{
		TaskType:            taskdomain.TypeDaily,
		Category:            "gift",
		MetricType:          taskdomain.MetricGiftSpendCoin,
		Title:               "send specified gift",
		DimensionFilterJSON: `{"gift_id":"gift_1001"}`,
		TargetValue:         100,
		TargetUnit:          "coin",
		RewardCoinAmount:    10,
		RewardAssetType:     taskdomain.RewardAssetCoin,
		Status:              taskdomain.StatusActive,
		OperatorAdminID:     90001,
	})
	if err != nil || !created {
		t.Fatalf("create filtered task definition failed: created=%v err=%v", created, err)
	}

	skipped, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
		EventID:        "evt-gift-filter-skip",
		EventType:      "RoomGiftSent",
		SourceService:  "room-service",
		UserID:         10001,
		MetricType:     taskdomain.MetricGiftSpendCoin,
		Value:          100,
		OccurredAtMS:   now.UnixMilli(),
		DimensionsJSON: `{"gift_id":"gift_2002"}`,
	})
	if err != nil {
		t.Fatalf("ConsumeTaskEvent skip failed: %v", err)
	}
	if skipped.Status != taskdomain.EventStatusSkipped || skipped.MatchedTaskCount != 0 {
		t.Fatalf("non-matching dimensions should skip: %+v", skipped)
	}

	consumed, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
		EventID:        "evt-gift-filter-hit",
		EventType:      "RoomGiftSent",
		SourceService:  "room-service",
		UserID:         10001,
		MetricType:     taskdomain.MetricGiftSpendCoin,
		Value:          100,
		OccurredAtMS:   now.UnixMilli(),
		DimensionsJSON: `{"gift_id":"gift_1001"}`,
	})
	if err != nil {
		t.Fatalf("ConsumeTaskEvent hit failed: %v", err)
	}
	if consumed.Status != taskdomain.EventStatusConsumed || consumed.MatchedTaskCount != 1 {
		t.Fatalf("matching dimensions should consume: %+v", consumed)
	}

	list, err := svc.ListUserTasks(context.Background(), 10001)
	if err != nil {
		t.Fatalf("ListUserTasks failed: %v", err)
	}
	if len(list.Sections[0].Items) != 1 || list.Sections[0].Items[0].TaskID != definition.TaskID || list.Sections[0].Items[0].ProgressValue != 100 {
		t.Fatalf("filtered progress mismatch: %+v", list.Sections[0].Items)
	}
}

func TestExplicitPointTaskDefinitionClaimsPoint(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	wallet := &fakeWalletClient{}
	svc := taskservice.New(repository, wallet)
	now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
	svc.SetClock(func() time.Time { return now })
	ctx := appcode.WithContext(context.Background(), "huwaa")

	definition, created, err := svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{
		TaskType:         taskdomain.TypeDaily,
		Category:         "gift",
		MetricType:       taskdomain.MetricGiftSpendCoin,
		Title:            "send gift for point",
		TargetValue:      100,
		TargetUnit:       "coin",
		RewardCoinAmount: 12,
		RewardAssetType:  taskdomain.RewardAssetPoint,
		Status:           taskdomain.StatusActive,
		OperatorAdminID:  90001,
	})
	if err != nil || !created {
		t.Fatalf("create explicit POINT task definition failed: created=%v err=%v", created, err)
	}
	if definition.RewardAssetType != taskdomain.RewardAssetPoint {
		t.Fatalf("explicit reward asset mismatch: %+v", definition)
	}

	if _, err := svc.ConsumeTaskEvent(ctx, taskdomain.Event{
		EventID:       "evt-huwaa-point-task",
		EventType:     "RoomGiftSent",
		SourceService: "room-service",
		UserID:        41001,
		MetricType:    taskdomain.MetricGiftSpendCoin,
		Value:         100,
		OccurredAtMS:  now.UnixMilli(),
	}); err != nil {
		t.Fatalf("ConsumeTaskEvent failed: %v", err)
	}
	claim, err := svc.ClaimTaskReward(ctx, taskdomain.ClaimCommand{
		UserID:    41001,
		TaskID:    definition.TaskID,
		TaskType:  taskdomain.TypeDaily,
		CycleKey:  "2026-05-09",
		CommandID: "cmd-huwaa-point-task",
	})
	if err != nil {
		t.Fatalf("ClaimTaskReward failed: %v", err)
	}
	if claim.RewardAssetType != taskdomain.RewardAssetPoint {
		t.Fatalf("claim reward asset mismatch: %+v", claim)
	}
	if wallet.last == nil ||
		wallet.last.GetAppCode() != "huwaa" ||
		wallet.last.GetRewardAssetType() != taskdomain.RewardAssetPoint ||
		wallet.last.GetAmount() != 12 {
		t.Fatalf("wallet POINT reward request mismatch: %+v", wallet.last)
	}
}

func TestExplicitPointTaskDefinitionDoesNotDependOnApplicationName(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := taskservice.New(repository, &fakeWalletClient{})
	ctx := appcode.WithContext(context.Background(), "lalu")

	definition, _, err := svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{
		TaskType:         taskdomain.TypeDaily,
		Category:         "gift",
		MetricType:       taskdomain.MetricGiftSpendCoin,
		Title:            "bad point task",
		TargetValue:      100,
		TargetUnit:       "coin",
		RewardCoinAmount: 12,
		RewardAssetType:  taskdomain.RewardAssetPoint,
		Status:           taskdomain.StatusActive,
		OperatorAdminID:  90001,
	})
	if err != nil || definition.RewardAssetType != taskdomain.RewardAssetPoint {
		t.Fatalf("configured POINT task definition failed: definition=%+v err=%v", definition, err)
	}
}

func TestResourceGroupTaskDisplaysEveryCoverAndGrantsGroup(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	wallet := &fakeWalletClient{
		group: &walletv1.ResourceGroup{
			GroupId:     9901,
			Status:      "active",
			UpdatedAtMs: 1778291000000,
			Items: []*walletv1.ResourceGroupItem{
				{
					ItemType:   "resource",
					ResourceId: 8801,
					Quantity:   1,
					Resource: &walletv1.Resource{
						ResourceId:   8801,
						ResourceType: "avatar_frame",
						Status:       "active",
						Grantable:    true,
						PreviewUrl:   "https://cdn.example.com/rewards/frame.webp",
					},
				},
				{
					ItemType:   "resource",
					ResourceId: 8802,
					Quantity:   2,
					Resource: &walletv1.Resource{
						ResourceId:   8802,
						ResourceType: "badge",
						Status:       "active",
						Grantable:    true,
						AssetUrl:     "https://cdn.example.com/rewards/badge.webp",
					},
				},
				{
					ItemType:          "wallet_asset",
					WalletAssetType:   "COIN",
					WalletAssetAmount: 50,
				},
			},
		},
	}
	svc := taskservice.New(repository, wallet)
	now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
	svc.SetClock(func() time.Time { return now })

	definition, created, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{
		TaskType:              taskdomain.TypeDaily,
		Category:              "gift",
		MetricType:            taskdomain.MetricGiftSendCount,
		Title:                 "send gift for resource group",
		TargetValue:           1,
		TargetUnit:            "count",
		RewardType:            taskdomain.RewardTypeResourceGroup,
		RewardResourceGroupID: 9901,
		Status:                taskdomain.StatusActive,
		OperatorAdminID:       90001,
	})
	if err != nil || !created {
		t.Fatalf("create resource group task failed: created=%v err=%v", created, err)
	}
	var rewardItems []taskdomain.RewardItem
	if err := json.Unmarshal([]byte(definition.RewardItemsJSON), &rewardItems); err != nil {
		t.Fatalf("decode reward items failed: %v", err)
	}
	if len(rewardItems) != 3 ||
		rewardItems[0].CoverURL != "https://cdn.example.com/rewards/frame.webp" ||
		rewardItems[1].CoverURL != "https://cdn.example.com/rewards/badge.webp" ||
		rewardItems[2].WalletAssetAmount != 50 ||
		definition.RewardSnapshotID != "snapshot-task-group-1" ||
		definition.RewardSnapshotHash != fakeTaskRewardSnapshotHash {
		t.Fatalf("resource group display snapshot mismatch: %+v", rewardItems)
	}

	if _, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
		EventID:       "evt-resource-group-task",
		EventType:     "RoomGiftSent",
		SourceService: "room-service",
		UserID:        10001,
		MetricType:    taskdomain.MetricGiftSendCount,
		Value:         1,
		OccurredAtMS:  now.UnixMilli(),
	}); err != nil {
		t.Fatalf("complete resource group task failed: %v", err)
	}
	claim, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{
		UserID:    10001,
		TaskID:    definition.TaskID,
		TaskType:  taskdomain.TypeDaily,
		CycleKey:  "2026-05-09",
		CommandID: "cmd-resource-group-task",
	})
	if err != nil {
		t.Fatalf("claim resource group task failed: %v", err)
	}
	if claim.WalletResourceGrantID != "grant-task-1" || wallet.pinnedGroupGrantCalls != 1 ||
		wallet.lastPinnedGroupGrant.GetSnapshotId() != "snapshot-task-group-1" ||
		wallet.lastPinnedGroupGrant.GetExpectedSnapshotHash() != fakeTaskRewardSnapshotHash {
		t.Fatalf("resource group claim mismatch: claim=%+v wallet=%+v", claim, wallet.lastPinnedGroupGrant)
	}
}

func TestSingleResourceTaskGrantsConfiguredQuantityAndDuration(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	wallet := &fakeWalletClient{resource: &walletv1.Resource{
		ResourceId:   8701,
		ResourceType: "vehicle",
		Status:       "active",
		Grantable:    true,
		PreviewUrl:   "https://cdn.example.com/rewards/vehicle.webp",
	}}
	svc := taskservice.New(repository, wallet)
	now := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC)
	svc.SetClock(func() time.Time { return now })
	const durationMS = int64(7 * 24 * time.Hour / time.Millisecond)

	definition, _, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{
		TaskType:         taskdomain.TypeDaily,
		Category:         "game",
		MetricType:       taskdomain.MetricGameDicePlayCount,
		Title:            "play for vehicle",
		TargetValue:      1,
		TargetUnit:       "count",
		RewardType:       taskdomain.RewardTypeResource,
		RewardResourceID: 8701,
		RewardQuantity:   2,
		RewardDurationMS: durationMS,
		Status:           taskdomain.StatusActive,
		OperatorAdminID:  90001,
	})
	if err != nil {
		t.Fatalf("create resource task failed: %v", err)
	}
	if _, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
		EventID:       "evt-single-resource-task",
		EventType:     "GameOrderSettled",
		SourceService: "game-service",
		UserID:        10002,
		MetricType:    taskdomain.MetricGameDicePlayCount,
		Value:         1,
		OccurredAtMS:  now.UnixMilli(),
	}); err != nil {
		t.Fatalf("complete resource task failed: %v", err)
	}
	claim, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{
		UserID:    10002,
		TaskID:    definition.TaskID,
		TaskType:  taskdomain.TypeDaily,
		CycleKey:  "2026-05-10",
		CommandID: "cmd-single-resource-task",
	})
	if err != nil {
		t.Fatalf("claim resource task failed: %v", err)
	}
	if claim.WalletResourceGrantID != "grant-task-resource-1" || wallet.resourceGrantCalls != 1 ||
		wallet.lastResourceGrant.GetResourceId() != 8701 || wallet.lastResourceGrant.GetQuantity() != 2 ||
		wallet.lastResourceGrant.GetDurationMs() != durationMS {
		t.Fatalf("single resource grant mismatch: claim=%+v request=%+v", claim, wallet.lastResourceGrant)
	}
}

func TestEmptyRewardAssetUpdateDoesNotOverwriteStoredPoint(t *testing.T) {
	repository := &fakeTaskDefinitionRepository{storedAsset: taskdomain.RewardAssetPoint}
	svc := taskservice.New(repository, &fakeWalletClient{})

	_, created, err := svc.UpsertTaskDefinition(appcode.WithContext(context.Background(), "configured-app"), taskdomain.DefinitionCommand{
		TaskID:           "existing-point-task",
		TaskType:         taskdomain.TypeDaily,
		Category:         "gift",
		MetricType:       taskdomain.MetricGiftSpendCoin,
		Title:            "fake point task",
		TargetValue:      100,
		TargetUnit:       "coin",
		RewardCoinAmount: 12,
		Status:           taskdomain.StatusActive,
		OperatorAdminID:  90001,
	})
	if !xerr.IsCode(err, xerr.InvalidArgument) || created {
		t.Fatalf("empty reward asset update error=%v created=%v, want INVALID_ARGUMENT", err, created)
	}
	if repository.upsertCalls != 0 || repository.storedAsset != taskdomain.RewardAssetPoint {
		t.Fatalf("rejected empty asset update must preserve stored POINT definition: repo=%+v", repository)
	}
}

type fakeTaskDefinitionRepository struct {
	taskservice.Repository

	lastUpsert  taskdomain.DefinitionCommand
	storedAsset string
	upsertCalls int
}

func (f *fakeTaskDefinitionRepository) UpsertTaskDefinition(_ context.Context, command taskdomain.DefinitionCommand, _ int64) (taskdomain.Definition, bool, error) {
	f.upsertCalls++
	f.lastUpsert = command
	f.storedAsset = command.RewardAssetType
	return taskdomain.Definition{
		AppCode:          "huwaa",
		TaskID:           "fake-point-task",
		TaskType:         command.TaskType,
		Category:         command.Category,
		MetricType:       command.MetricType,
		Title:            command.Title,
		TargetValue:      command.TargetValue,
		TargetUnit:       command.TargetUnit,
		RewardCoinAmount: command.RewardCoinAmount,
		RewardAssetType:  command.RewardAssetType,
		Status:           command.Status,
	}, true, nil
}

type expectedTaskEvent struct {
	EventID    string
	MetricType string
	Value      int64
}

// recordingTaskRepository 只实现 HandleRoomEvent 依赖的写入边界，并用 event_id 模拟 MySQL
// task_event_consumption 的幂等行为；测试因此可以同时断言投影内容和 MQ 重投结果。
type recordingTaskRepository struct {
	taskservice.Repository

	calls  int
	events []taskdomain.Event
	seen   map[string]struct{}
}

func newRecordingTaskRepository() *recordingTaskRepository {
	return &recordingTaskRepository{seen: make(map[string]struct{})}
}

func (r *recordingTaskRepository) ConsumeTaskEvent(_ context.Context, event taskdomain.Event, _ string, _ int64) (taskdomain.EventResult, error) {
	r.calls++
	if _, exists := r.seen[event.EventID]; exists {
		return taskdomain.EventResult{EventID: event.EventID, Status: taskdomain.EventStatusConsumed}, nil
	}
	r.seen[event.EventID] = struct{}{}
	r.events = append(r.events, event)
	return taskdomain.EventResult{EventID: event.EventID, Status: taskdomain.EventStatusConsumed, MatchedTaskCount: 1}, nil
}

func roomGiftEnvelope(t *testing.T, eventID string, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
	t.Helper()
	body, err := proto.Marshal(gift)
	if err != nil {
		t.Fatalf("marshal RoomGiftSent failed: %v", err)
	}
	return &roomeventsv1.EventEnvelope{
		AppCode:      "lalu",
		EventId:      eventID,
		RoomId:       "lalu-regression-room",
		EventType:    "RoomGiftSent",
		OccurredAtMs: time.Date(2026, 7, 9, 0, 0, 0, 0, time.UTC).UnixMilli(),
		Body:         body,
	}
}

func assertRecordedTaskEvents(t *testing.T, got []taskdomain.Event, want []expectedTaskEvent) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("projected event count=%d, want=%d: %+v", len(got), len(want), got)
	}
	for i := range want {
		if got[i].EventID != want[i].EventID || got[i].MetricType != want[i].MetricType || got[i].Value != want[i].Value {
			t.Fatalf("projected event[%d]=%+v, want=%+v", i, got[i], want[i])
		}
	}
}

type fakeWalletClient struct {
	calls                 int
	last                  *walletv1.CreditTaskRewardRequest
	resource              *walletv1.Resource
	group                 *walletv1.ResourceGroup
	resourceGrantCalls    int
	pinnedGroupGrantCalls int
	lastResourceGrant     *walletv1.GrantResourceRequest
	lastPinnedGroupGrant  *walletv1.GrantPinnedResourceGroupRequest
}

const fakeTaskRewardSnapshotHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

func (f *fakeWalletClient) CreditTaskReward(_ context.Context, req *walletv1.CreditTaskRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) {
	f.calls++
	f.last = req
	assetType := req.GetRewardAssetType()
	if assetType == "" {
		assetType = taskdomain.RewardAssetCoin
	}
	return &walletv1.CreditTaskRewardResponse{
		TransactionId: "wtx-task-1",
		Amount:        req.GetAmount(),
		GrantedAtMs:   1778292000000,
		Balance:       &walletv1.AssetBalance{AssetType: assetType, AvailableAmount: req.GetAmount()},
	}, nil
}

func (f *fakeWalletClient) GetResource(_ context.Context, _ *walletv1.GetResourceRequest, _ ...grpc.CallOption) (*walletv1.GetResourceResponse, error) {
	return &walletv1.GetResourceResponse{Resource: f.resource}, nil
}

func (f *fakeWalletClient) GetResourceGroup(_ context.Context, _ *walletv1.GetResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error) {
	return &walletv1.GetResourceGroupResponse{Group: f.group, SourceContentHash: fakeTaskRewardSnapshotHash}, nil
}

func (f *fakeWalletClient) PinResourceGroupSnapshot(_ context.Context, _ *walletv1.PinResourceGroupSnapshotRequest, _ ...grpc.CallOption) (*walletv1.PinResourceGroupSnapshotResponse, error) {
	return &walletv1.PinResourceGroupSnapshotResponse{Snapshot: &walletv1.ResourceGroupSnapshot{
		SnapshotId:             "snapshot-task-group-1",
		SourceGroupId:          f.group.GetGroupId(),
		SourceGroupUpdatedAtMs: f.group.GetUpdatedAtMs(),
		SourceContentHash:      fakeTaskRewardSnapshotHash,
		SnapshotHash:           fakeTaskRewardSnapshotHash,
		Group:                  f.group,
	}}, nil
}

func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
	f.resourceGrantCalls++
	f.lastResourceGrant = req
	return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
		GrantId:     "grant-task-resource-1",
		CreatedAtMs: 1778292000000,
		UpdatedAtMs: 1778292000000,
	}}, nil
}

func (f *fakeWalletClient) GrantResourceGroup(_ context.Context, _ *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
	return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
		GrantId:     "grant-task-legacy-group-1",
		CreatedAtMs: 1778292000000,
		UpdatedAtMs: 1778292000000,
	}}, nil
}

func (f *fakeWalletClient) GrantPinnedResourceGroup(_ context.Context, req *walletv1.GrantPinnedResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
	f.pinnedGroupGrantCalls++
	f.lastPinnedGroupGrant = req
	return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
		GrantId:     "grant-task-1",
		CreatedAtMs: 1778292000000,
		UpdatedAtMs: 1778292000000,
	}}, nil
}
