package roomrps

import (
	"context"
	"fmt"
	"testing"
	"time"

	"google.golang.org/grpc"
	gamev1 "hyapp.local/api/proto/game/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/xerr"
)

func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) {
	catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
		"2001": roomRPSWalletGift("2001", "Rose", "https://cdn.example.test/rose.png", "", "https://cdn.example.test/rose.mp4", 100),
		"2002": roomRPSWalletGift("2002", "Bell", "https://cdn.example.test/bell.mp4", "https://cdn.example.test/bell-preview.png", "https://cdn.example.test/bell.svga", 300),
		"2003": roomRPSWalletGift("2003", "Crown", "", "", "https://cdn.example.test/crown.svga", 500),
		"2004": roomRPSWalletGift("2004", "Cup", "https://cdn.example.test/cup.png", "", "", 1000),
	})
	svc := New(Config{}, nil, nil, catalog)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	config, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
		Status:             "active",
		ChallengeTimeoutMs: 600000,
		RevealCountdownMs:  3000,
		StakeGifts: []*gamev1.RoomRPSStakeGift{
			{GiftIdText: "2001", Enabled: true, SortOrder: 1},
			{GiftIdText: "2002", Enabled: true, SortOrder: 2},
			{GiftIdText: "2003", Enabled: true, SortOrder: 3},
			{GiftIdText: "2004", Enabled: true, SortOrder: 4},
		},
	})
	if err != nil {
		t.Fatalf("UpdateConfig() error = %v", err)
	}

	if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Cup" || got.GetGiftIconUrl() != "https://cdn.example.test/cup.png" || got.GetGiftPriceCoin() != 1000 || got.GetSortOrder() != 4 {
		t.Fatalf("highest-sort gift was not enriched from wallet catalog: %+v", got)
	}
	if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "https://cdn.example.test/bell-preview.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/bell.svga" {
		t.Fatalf("sort-2 gift should use preview_url as icon and animation_url as animation: %+v", got)
	}
	if got := config.GetStakeGifts()[1]; got.GetGiftIconUrl() != "" || got.GetGiftAnimationUrl() != "https://cdn.example.test/crown.svga" {
		t.Fatalf("sort-3 gift should not use animation_url as display icon: %+v", got)
	}
	if len(catalog.requests) != 4 {
		t.Fatalf("expected one exact catalog lookup per missing gift, got %d", len(catalog.requests))
	}
	if req := catalog.requests[0]; req.GetAppCode() != "lalu" || req.GetKeyword() != "2001" || req.GetPage() != 1 || req.GetPageSize() != 100 {
		t.Fatalf("catalog lookup request mismatch: %+v", req)
	}
	if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "2004" || got.GetGiftId() != 2004 {
		t.Fatalf("numeric gift should keep string primary ID and legacy number: %+v", got)
	}
}

func TestGetConfigBackfillsLegacyEmptyGiftSnapshot(t *testing.T) {
	catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
		"Gifi-3": roomRPSWalletGift("Gifi-3", "Rocket", "https://cdn.example.test/rocket.png", "", "", 900),
	})
	svc := New(Config{}, nil, nil, catalog)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
	svc.configs["lalu"] = &gamev1.RoomRPSConfig{
		AppCode:            "lalu",
		GameId:             GameID,
		Status:             "active",
		ChallengeTimeoutMs: 600000,
		RevealCountdownMs:  3000,
		StakeGifts: []*gamev1.RoomRPSStakeGift{
			{GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1},
			{GiftId: 10002, Enabled: true, SortOrder: 2},
			{GiftId: 10003, Enabled: true, SortOrder: 3},
			{GiftId: 10004, Enabled: true, SortOrder: 4},
		},
		CreatedAtMs: 1000,
		UpdatedAtMs: 2000,
	}

	config, _, err := svc.GetConfig(context.Background(), "lalu")
	if err != nil {
		t.Fatalf("GetConfig() error = %v", err)
	}

	if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rocket" || got.GetGiftIconUrl() != "https://cdn.example.test/rocket.png" || got.GetGiftPriceCoin() != 900 {
		t.Fatalf("legacy config response was not enriched: %+v", got)
	}
	if cached := svc.configs["lalu"].GetStakeGifts()[0]; cached.GetGiftIconUrl() == "" {
		t.Fatalf("legacy config was not backfilled into service cache: %+v", cached)
	}
}

func TestGetConfigMovesLegacyAnimatedIconIntoAnimationURL(t *testing.T) {
	catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
		"Gifi-3": roomRPSWalletGift("Gifi-3", "Rocket", "https://cdn.example.test/rocket-cover.png", "", "https://cdn.example.test/rocket.mp4", 900),
	})
	svc := New(Config{}, nil, nil, catalog)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
	svc.configs["lalu"] = &gamev1.RoomRPSConfig{
		AppCode:            "lalu",
		GameId:             GameID,
		Status:             "active",
		ChallengeTimeoutMs: 600000,
		RevealCountdownMs:  3000,
		StakeGifts: []*gamev1.RoomRPSStakeGift{
			{GiftIdText: "Gifi-3", GiftName: "Rocket", GiftIconUrl: "https://cdn.example.test/rocket.mp4", GiftPriceCoin: 900, Enabled: true, SortOrder: 1},
			{GiftId: 10002, Enabled: true, SortOrder: 2},
			{GiftId: 10003, Enabled: true, SortOrder: 3},
			{GiftId: 10004, Enabled: true, SortOrder: 4},
		},
		CreatedAtMs: 1000,
		UpdatedAtMs: 2000,
	}

	config, _, err := svc.GetConfig(context.Background(), "lalu")
	if err != nil {
		t.Fatalf("GetConfig() error = %v", err)
	}

	if got := config.GetStakeGifts()[0]; got.GetGiftIconUrl() != "https://cdn.example.test/rocket-cover.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/rocket.mp4" {
		t.Fatalf("legacy animated icon was not split into cover and animation fields: %+v", got)
	}
}

func TestCreateChallengeMatchesStringGiftID(t *testing.T) {
	catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
		"Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 1),
		"Gifi-2": roomRPSWalletGift("Gifi-2", "Cowboy", "https://cdn.example.test/cowboy.png", "", "", 1),
		"Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 1),
		"lw1":    roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 111),
	})
	wallet := newFakeRoomRPSWallet(map[int64]int64{42: 10})
	svc := New(Config{}, nil, nil, catalog, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
		Status:             "active",
		ChallengeTimeoutMs: 600000,
		RevealCountdownMs:  3000,
		StakeGifts: []*gamev1.RoomRPSStakeGift{
			{GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1},
			{GiftIdText: "Gifi-2", Enabled: true, SortOrder: 2},
			{GiftIdText: "Gifi-1", Enabled: true, SortOrder: 3},
			{GiftIdText: "lw1", Enabled: true, SortOrder: 4},
		},
	})
	if err != nil {
		t.Fatalf("UpdateConfig() error = %v", err)
	}

	challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:       &gamev1.RequestMeta{AppCode: "lalu"},
		UserId:     42,
		RoomId:     "room-1",
		RegionId:   1001,
		GiftIdText: "Gifi-3",
		Gesture:    "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}
	if challenge.GetStakeGiftIdText() != "Gifi-3" || challenge.GetStakeGiftId() != 0 || challenge.GetStakeCoin() != 1 {
		t.Fatalf("string gift challenge mismatch: %+v", challenge)
	}
	if challenge.GetInitiator().GetBalanceAfter() != 9 || wallet.balances[42] != 9 {
		t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator())
	}
}

func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) {
	catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
		"Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 100),
		"Gifi-2": roomRPSWalletGift("Gifi-2", "Cowboy", "https://cdn.example.test/cowboy.png", "", "", 200),
		"Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 300),
		"lw1":    roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 400),
	})
	wallet := newFakeRoomRPSWallet(map[int64]int64{42: 99})
	svc := New(Config{}, nil, nil, catalog, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
		Status:             "active",
		ChallengeTimeoutMs: 600000,
		RevealCountdownMs:  3000,
		StakeGifts: []*gamev1.RoomRPSStakeGift{
			{GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1},
			{GiftIdText: "Gifi-2", Enabled: true, SortOrder: 2},
			{GiftIdText: "Gifi-1", Enabled: true, SortOrder: 3},
			{GiftIdText: "lw1", Enabled: true, SortOrder: 4},
		},
	})
	if err != nil {
		t.Fatalf("UpdateConfig() error = %v", err)
	}

	_, _, err = svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:       &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-low-balance"},
		UserId:     42,
		RoomId:     "room-1",
		GiftIdText: "Gifi-3",
		Gesture:    "rock",
	})
	if !xerr.IsCode(err, xerr.InsufficientBalance) {
		t.Fatalf("CreateChallenge() error = %v, want InsufficientBalance", err)
	}
	if len(svc.challenges) != 0 {
		t.Fatalf("insufficient balance must not create pending challenge: %+v", svc.challenges)
	}
	if wallet.balances[42] != 99 || len(wallet.applies) != 1 {
		t.Fatalf("insufficient create must not mutate wallet balance: balance=%d applies=%d", wallet.balances[42], len(wallet.applies))
	}
}

func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) {
	store := newFakeRoomRPSConfigStore()
	svc := New(Config{}, nil, nil, store)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
		Status:             "active",
		ChallengeTimeoutMs: 600000,
		RevealCountdownMs:  3000,
		StakeGifts: []*gamev1.RoomRPSStakeGift{
			{GiftIdText: "persist-1", GiftName: "Persist One", GiftIconUrl: "https://cdn.example.test/one.png", GiftPriceCoin: 11, Enabled: true, SortOrder: 1},
			{GiftIdText: "persist-2", GiftName: "Persist Two", GiftIconUrl: "https://cdn.example.test/two.png", GiftPriceCoin: 22, Enabled: true, SortOrder: 2},
			{GiftIdText: "persist-3", GiftName: "Persist Three", GiftIconUrl: "https://cdn.example.test/three.png", GiftPriceCoin: 33, Enabled: true, SortOrder: 3},
			{GiftIdText: "persist-4", GiftName: "Persist Four", GiftIconUrl: "https://cdn.example.test/four.png", GiftPriceCoin: 44, Enabled: true, SortOrder: 4},
		},
	})
	if err != nil {
		t.Fatalf("UpdateConfig() error = %v", err)
	}
	if store.upserts != 1 {
		t.Fatalf("expected one persistent upsert, got %d", store.upserts)
	}

	restarted := New(Config{}, nil, nil, store)
	restarted.now = func() time.Time { return time.UnixMilli(1770000060000) }
	config, _, err := restarted.GetConfig(context.Background(), "lalu")
	if err != nil {
		t.Fatalf("GetConfig() after restart error = %v", err)
	}
	if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "persist-4" || got.GetGiftPriceCoin() != 44 {
		t.Fatalf("persistent config was not reused after restart: %+v", got)
	}
}

func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) {
	store := newFakeRoomRPSConfigStore()
	store.configs["lalu:"+GameID] = &gamev1.RoomRPSConfig{
		AppCode:            "lalu",
		GameId:             GameID,
		Status:             "active",
		ChallengeTimeoutMs: 600000,
		RevealCountdownMs:  3000,
		StakeGifts: []*gamev1.RoomRPSStakeGift{
			{GiftIdText: "db-gift", GiftName: "DB Gift", GiftIconUrl: "https://cdn.example.test/db.png", GiftPriceCoin: 222, Enabled: true, SortOrder: 1},
			{GiftIdText: "db-gift-2", GiftName: "DB Gift 2", GiftIconUrl: "https://cdn.example.test/db2.png", GiftPriceCoin: 333, Enabled: true, SortOrder: 2},
			{GiftIdText: "db-gift-3", GiftName: "DB Gift 3", GiftIconUrl: "https://cdn.example.test/db3.png", GiftPriceCoin: 444, Enabled: true, SortOrder: 3},
			{GiftIdText: "db-gift-4", GiftName: "DB Gift 4", GiftIconUrl: "https://cdn.example.test/db4.png", GiftPriceCoin: 555, Enabled: true, SortOrder: 4},
		},
		CreatedAtMs: 1000,
		UpdatedAtMs: 2000,
	}
	wallet := newFakeRoomRPSWallet(map[int64]int64{42: 1000})
	svc := New(Config{}, nil, nil, store, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:       &gamev1.RequestMeta{AppCode: "lalu"},
		UserId:     42,
		RoomId:     "room-1",
		GiftIdText: "db-gift",
		Gesture:    "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}
	if challenge.GetStakeGiftIdText() != "db-gift" || challenge.GetStakeCoin() != 222 {
		t.Fatalf("challenge did not use persistent config: %+v", challenge)
	}
	if challenge.GetInitiator().GetBalanceAfter() != 778 || wallet.balances[42] != 778 {
		t.Fatalf("persistent config challenge did not debit stake: challenge=%+v wallet=%d", challenge, wallet.balances[42])
	}
}

func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) {
	wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 99})
	svc := New(Config{}, nil, nil, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:    &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"},
		UserId:  41,
		RoomId:  "room-1",
		GiftId:  10001,
		Gesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}

	_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
		Meta:        &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-low-balance"},
		UserId:      42,
		ChallengeId: created.GetChallengeId(),
		Gesture:     "paper",
	})
	if !xerr.IsCode(err, xerr.InsufficientBalance) {
		t.Fatalf("AcceptChallenge() error = %v, want InsufficientBalance", err)
	}
	current, _, err := svc.GetChallenge(context.Background(), "lalu", created.GetChallengeId())
	if err != nil {
		t.Fatalf("GetChallenge() error = %v", err)
	}
	if current.GetStatus() != StatusPending || current.GetChallenger().GetUserId() != 0 {
		t.Fatalf("insufficient balance must leave challenge pending without challenger: %+v", current)
	}
	if wallet.balances[41] != 900 || wallet.balances[42] != 99 {
		t.Fatalf("accept failure should keep initiator debit and avoid challenger debit: balances=%+v", wallet.balances)
	}
}

func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) {
	wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500})
	room := &fakeRoomRPSRoom{}
	svc := New(Config{}, nil, nil, wallet, room)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:    &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"},
		UserId:  41,
		RoomId:  "room-1",
		GiftId:  10001,
		Gesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}

	accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
		Meta:        &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept"},
		UserId:      42,
		ChallengeId: created.GetChallengeId(),
		Gesture:     "paper",
	})
	if err != nil {
		t.Fatalf("AcceptChallenge() error = %v", err)
	}
	if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementSettled || accepted.GetInitiator().GetBalanceAfter() != 900 || accepted.GetChallenger().GetBalanceAfter() != 500 {
		t.Fatalf("accepted challenge balance snapshot mismatch: %+v", accepted)
	}
	if wallet.balances[41] != 900 || wallet.balances[42] != 500 {
		t.Fatalf("winner refund and loser debit mismatch: balances=%+v", wallet.balances)
	}
	if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund]" {
		t.Fatalf("winner settlement should only debit both and refund winner, got ops=%v", got)
	}
	if len(room.requests) != 1 {
		t.Fatalf("settled PK must apply exactly one loser gift to room rocket, got %d", len(room.requests))
	}
	rocketGift := room.requests[0]
	if rocketGift.GetSenderUserId() != 41 || rocketGift.GetTargetUserId() != 42 || rocketGift.GetGiftId() != "10001" || rocketGift.GetHeatValue() != 100 {
		t.Fatalf("room rocket gift must describe loser -> winner and server stake: %+v", rocketGift)
	}
}

func TestRetrySettlementReplaysIdempotentWalletAndRoomCommands(t *testing.T) {
	wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500})
	// 模拟 room 已落地 command_id，但响应在网络上丢失的未知结果；这是
	// 最容易因盲目重试造成双充能的边界，必须靠稳定 room command_id 收敛。
	room := &fakeRoomRPSRoom{failAfterApply: 1}
	publisher := &fakeRoomRPSPublisher{}
	svc := New(Config{}, nil, publisher, wallet, room)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:    &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-retry"},
		UserId:  41,
		RoomId:  "room-retry",
		GiftId:  10001,
		Gesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}

	_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
		Meta:        &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-retry"},
		UserId:      42,
		ChallengeId: created.GetChallengeId(),
		Gesture:     "paper",
	})
	if !xerr.IsCode(err, xerr.Unavailable) {
		t.Fatalf("AcceptChallenge() error = %v, want room Unavailable", err)
	}
	failed, _, err := svc.GetChallenge(context.Background(), "lalu", created.GetChallengeId())
	if err != nil {
		t.Fatalf("GetChallenge() failed state error = %v", err)
	}
	if failed.GetStatus() != StatusSettlementFailed || failed.GetSettlementStatus() != SettlementFailed {
		t.Fatalf("room unknown result must remain retryable: %+v", failed)
	}
	if wallet.balances[41] != 900 || wallet.balances[42] != 500 {
		t.Fatalf("first settlement must debit loser and refund winner once: balances=%+v", wallet.balances)
	}
	if len(room.requests) != 1 || len(publisher.messages) != 1 {
		t.Fatalf("failed settlement must not publish terminal IM: room_calls=%d messages=%v", len(room.requests), publisher.messageTypes())
	}

	retried, _, err := svc.RetrySettlement(context.Background(), "lalu", created.GetChallengeId())
	if err != nil {
		t.Fatalf("RetrySettlement() error = %v", err)
	}
	if retried.GetStatus() != StatusFinished || retried.GetSettlementStatus() != SettlementSettled || retried.GetChallenger().GetBalanceAfter() != 500 {
		t.Fatalf("retry did not converge settled snapshot: %+v", retried)
	}
	if wallet.balances[41] != 900 || wallet.balances[42] != 500 {
		t.Fatalf("wallet replay must not refund winner twice: balances=%+v", wallet.balances)
	}
	if len(wallet.applies) != 4 || wallet.applies[2].GetCommandId() != wallet.applies[3].GetCommandId() {
		t.Fatalf("retry must replay the same wallet refund command: applies=%+v", wallet.applies)
	}
	if len(room.requests) != 2 || room.requests[0].GetMeta().GetCommandId() != room.requests[1].GetMeta().GetCommandId() {
		t.Fatalf("retry must replay the same room gift command: requests=%+v", room.requests)
	}
	roomCommandID := room.requests[0].GetMeta().GetCommandId()
	if room.applied[roomCommandID] != 1 {
		t.Fatalf("room unknown-result replay must apply one gift, command_id=%q applied=%d", roomCommandID, room.applied[roomCommandID])
	}
	if got := publisher.messageTypes(); fmt.Sprint(got) != "[room_rps_challenge_created room_rps_finished]" {
		t.Fatalf("retry must publish exactly one terminal IM after all owners settle, got %v", got)
	}

	walletCalls := len(wallet.applies)
	roomCalls := len(room.requests)
	messageCount := len(publisher.messages)
	if _, _, err := svc.RetrySettlement(context.Background(), "lalu", created.GetChallengeId()); !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("second RetrySettlement() error = %v, want Conflict", err)
	}
	if len(wallet.applies) != walletCalls || len(room.requests) != roomCalls || len(publisher.messages) != messageCount {
		t.Fatalf("settled retry must have no wallet, room, or IM side effects")
	}
}

func TestAcceptChallengeReturnsTakenWhenAnotherUserAlreadyAccepted(t *testing.T) {
	wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500, 43: 500})
	svc := New(Config{}, nil, nil, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:    &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"},
		UserId:  41,
		RoomId:  "room-1",
		GiftId:  10001,
		Gesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}
	_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
		Meta:        &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-first-accept"},
		UserId:      42,
		ChallengeId: created.GetChallengeId(),
		Gesture:     "paper",
	})
	if err != nil {
		t.Fatalf("first AcceptChallenge() error = %v", err)
	}

	_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
		Meta:        &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-late-accept"},
		UserId:      43,
		ChallengeId: created.GetChallengeId(),
		Gesture:     "scissors",
	})
	if !xerr.IsCode(err, xerr.RoomRPSChallengeTaken) {
		t.Fatalf("late AcceptChallenge() error = %v, want RoomRPSChallengeTaken", err)
	}
	if wallet.balances[43] != 500 {
		t.Fatalf("late challenger must not be debited: balance=%d", wallet.balances[43])
	}
}

func TestCreateChallengeFreezesConfiguredTimeoutForEachNewOrder(t *testing.T) {
	wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 1000})
	store := newFakeRoomRPSConfigStore()
	svc := New(Config{}, nil, nil, wallet, store)
	now := time.UnixMilli(1770000000000)
	svc.now = func() time.Time { return now }

	updateTimeout := func(timeoutMS int64) {
		t.Helper()
		_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
			Status:             "active",
			ChallengeTimeoutMs: timeoutMS,
			RevealCountdownMs:  3000,
			StakeGifts:         defaultGifts(),
		})
		if err != nil {
			t.Fatalf("UpdateConfig(%d) error = %v", timeoutMS, err)
		}
	}
	create := func(userID int64, requestID string) *gamev1.RoomRPSChallenge {
		t.Helper()
		challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
			Meta:    &gamev1.RequestMeta{AppCode: "lalu", RequestId: requestID},
			UserId:  userID,
			RoomId:  "room-1",
			GiftId:  10001,
			Gesture: "rock",
		})
		if err != nil {
			t.Fatalf("CreateChallenge() error = %v", err)
		}
		return challenge
	}

	updateTimeout(60000)
	first := create(41, "req-create-60s")
	if got, want := first.GetTimeoutAtMs(), now.UnixMilli()+60000; got != want {
		t.Fatalf("first timeout_at_ms = %d, want configured boundary %d", got, want)
	}

	updateTimeout(120000)
	second := create(42, "req-create-120s")
	if got, want := second.GetTimeoutAtMs(), now.UnixMilli()+120000; got != want {
		t.Fatalf("second timeout_at_ms = %d, want configured boundary %d", got, want)
	}
	if got, want := first.GetTimeoutAtMs(), now.UnixMilli()+60000; got != want {
		t.Fatalf("existing challenge timeout changed after config update: got %d want %d", got, want)
	}
}

func TestAcceptChallengeReturnsExpiredAtConfiguredBoundary(t *testing.T) {
	wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 1000})
	svc := New(Config{ChallengeTimeout: 60 * time.Second}, nil, nil, wallet)
	now := time.UnixMilli(1770000000000)
	svc.now = func() time.Time { return now }

	created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:    &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-expiring"},
		UserId:  41,
		RoomId:  "room-1",
		GiftId:  10001,
		Gesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}

	// 截止毫秒是排他边界：到达 timeout_at_ms 即失效，不能再多放行一个毫秒。
	now = time.UnixMilli(created.GetTimeoutAtMs())
	accept := func(requestID string) error {
		t.Helper()
		_, _, acceptErr := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
			Meta:        &gamev1.RequestMeta{AppCode: "lalu", RequestId: requestID},
			UserId:      42,
			ChallengeId: created.GetChallengeId(),
			Gesture:     "paper",
		})
		return acceptErr
	}
	if err := accept("req-accept-expired"); !xerr.IsCode(err, xerr.RoomRPSChallengeExpired) {
		t.Fatalf("AcceptChallenge() error = %v, want RoomRPSChallengeExpired", err)
	}
	if wallet.balances[41] != 1000 || wallet.balances[42] != 1000 {
		t.Fatalf("expired challenge must refund initiator without debiting challenger: balances=%+v", wallet.balances)
	}
	// 已收敛为 timeout 的旧挑战再次点击时仍返回同一业务码，不能退化回通用 CONFLICT。
	if err := accept("req-accept-expired-again"); !xerr.IsCode(err, xerr.RoomRPSChallengeExpired) {
		t.Fatalf("repeated AcceptChallenge() error = %v, want RoomRPSChallengeExpired", err)
	}
}

func TestAcceptChallengeDrawRefundsBothPlayers(t *testing.T) {
	wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500})
	svc := New(Config{}, nil, nil, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:    &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-draw"},
		UserId:  41,
		RoomId:  "room-1",
		GiftId:  10001,
		Gesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}

	accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
		Meta:        &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-draw"},
		UserId:      42,
		ChallengeId: created.GetChallengeId(),
		Gesture:     "rock",
	})
	if err != nil {
		t.Fatalf("AcceptChallenge() error = %v", err)
	}
	if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementRefunded || accepted.GetWinnerUserId() != 0 {
		t.Fatalf("draw challenge settlement mismatch: %+v", accepted)
	}
	if accepted.GetInitiator().GetBalanceAfter() != 1000 || accepted.GetChallenger().GetBalanceAfter() != 500 {
		t.Fatalf("draw must refund both players in response: %+v", accepted)
	}
	if wallet.balances[41] != 1000 || wallet.balances[42] != 500 {
		t.Fatalf("draw must refund both players in wallet: balances=%+v", wallet.balances)
	}
	if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund refund]" {
		t.Fatalf("draw should only debit both and refund both without gift op, got ops=%v", got)
	}
}

func TestExpireChallengeRefundsInitiatorDebit(t *testing.T) {
	wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000})
	svc := New(Config{}, nil, nil, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1770000000000) }

	created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:    &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-expire"},
		UserId:  41,
		RoomId:  "room-1",
		GiftId:  10001,
		Gesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateChallenge() error = %v", err)
	}
	if wallet.balances[41] != 900 || created.GetInitiator().GetBalanceAfter() != 900 {
		t.Fatalf("create should debit initiator before pending challenge: challenge=%+v wallet=%d", created, wallet.balances[41])
	}

	expired, _, err := svc.ExpireChallenge(context.Background(), "lalu", created.GetChallengeId())
	if err != nil {
		t.Fatalf("ExpireChallenge() error = %v", err)
	}
	if expired.GetStatus() != StatusTimeout || expired.GetSettlementStatus() != SettlementRefunded || expired.GetInitiator().GetBalanceAfter() != 1000 {
		t.Fatalf("expired challenge should refund initiator: %+v", expired)
	}
	if wallet.balances[41] != 1000 {
		t.Fatalf("expired challenge wallet balance mismatch: %d", wallet.balances[41])
	}
	if got := wallet.ops(); fmt.Sprint(got) != "[debit refund]" {
		t.Fatalf("expire should only debit then refund initiator, got ops=%v", got)
	}
}

type fakeGiftCatalog struct {
	gifts    map[string]*walletv1.GiftConfig
	requests []*walletv1.ListGiftConfigsRequest
}

func newFakeGiftCatalog(gifts map[string]*walletv1.GiftConfig) *fakeGiftCatalog {
	return &fakeGiftCatalog{gifts: gifts}
}

func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
	f.requests = append(f.requests, req)
	if gift := f.gifts[req.GetKeyword()]; gift != nil {
		return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{gift}, Total: 1}, nil
	}
	return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil
}

type fakeRoomRPSWallet struct {
	balances map[int64]int64
	requests []*walletv1.GetBalancesRequest
	applies  []*walletv1.ApplyGameCoinChangeRequest
	replays  map[string]*walletv1.ApplyGameCoinChangeResponse
}

type fakeRoomRPSRoom struct {
	requests       []*roomv1.ApplyRoomRPSGiftRequest
	applied        map[string]int
	failAfterApply int
}

func (f *fakeRoomRPSRoom) ApplyRoomRPSGift(_ context.Context, req *roomv1.ApplyRoomRPSGiftRequest, _ ...grpc.CallOption) (*roomv1.ApplyRoomRPSGiftResponse, error) {
	f.requests = append(f.requests, req)
	if f.applied == nil {
		f.applied = make(map[string]int)
	}
	commandID := req.GetMeta().GetCommandId()
	if f.applied[commandID] == 0 {
		f.applied[commandID]++
	}
	if f.failAfterApply > 0 {
		f.failAfterApply--
		return nil, xerr.New(xerr.Unavailable, "room response lost after apply")
	}
	return &roomv1.ApplyRoomRPSGiftResponse{}, nil
}

type fakeRoomRPSPublisher struct {
	messages []tencentim.CustomGroupMessage
}

func (f *fakeRoomRPSPublisher) EnsureGroup(context.Context, tencentim.GroupSpec) error { return nil }

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

func (f *fakeRoomRPSPublisher) messageTypes() []string {
	types := make([]string, 0, len(f.messages))
	for _, message := range f.messages {
		types = append(types, message.Desc)
	}
	return types
}

func newFakeRoomRPSWallet(balances map[int64]int64) *fakeRoomRPSWallet {
	return &fakeRoomRPSWallet{balances: balances, replays: make(map[string]*walletv1.ApplyGameCoinChangeResponse)}
}

func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
	f.requests = append(f.requests, req)
	return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{
		AppCode:         req.GetAppCode(),
		AssetType:       "COIN",
		AvailableAmount: f.balances[req.GetUserId()],
	}}}, nil
}

func (f *fakeRoomRPSWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
	f.applies = append(f.applies, req)
	if replay := f.replays[req.GetCommandId()]; replay != nil {
		return replay, nil
	}
	switch req.GetOpType() {
	case roomRPSOpDebit:
		if f.balances[req.GetUserId()] < req.GetCoinAmount() {
			return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance")
		}
		f.balances[req.GetUserId()] -= req.GetCoinAmount()
	case roomRPSOpRefund:
		f.balances[req.GetUserId()] += req.GetCoinAmount()
	default:
		return nil, xerr.New(xerr.InvalidArgument, "unsupported op_type")
	}
	resp := &walletv1.ApplyGameCoinChangeResponse{
		WalletTransactionId: "wtx-" + req.GetCommandId(),
		BalanceAfter:        f.balances[req.GetUserId()],
	}
	f.replays[req.GetCommandId()] = resp
	return resp, nil
}

func (f *fakeRoomRPSWallet) ops() []string {
	out := make([]string, 0, len(f.applies))
	for _, req := range f.applies {
		out = append(out, req.GetOpType())
	}
	return out
}

func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig {
	return &walletv1.GiftConfig{
		AppCode:   "lalu",
		GiftId:    giftID,
		Name:      name,
		Status:    "active",
		CoinPrice: coinPrice,
		Resource: &walletv1.Resource{
			ResourceId:   100,
			ResourceType: "gift",
			Name:         name,
			Status:       "active",
			AssetUrl:     assetURL,
			PreviewUrl:   previewURL,
			AnimationUrl: animationURL,
		},
	}
}

type fakeRoomRPSConfigStore struct {
	configs map[string]*gamev1.RoomRPSConfig
	upserts int
}

func newFakeRoomRPSConfigStore() *fakeRoomRPSConfigStore {
	return &fakeRoomRPSConfigStore{configs: make(map[string]*gamev1.RoomRPSConfig)}
}

func (f *fakeRoomRPSConfigStore) GetRoomRPSConfig(_ context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error) {
	if gameID == "" {
		gameID = GameID
	}
	config := f.configs[appCode+":"+gameID]
	if config == nil {
		return nil, xerr.New(xerr.NotFound, "room rps config not found")
	}
	return cloneConfig(config), nil
}

func (f *fakeRoomRPSConfigStore) UpsertRoomRPSConfig(_ context.Context, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) {
	if config.GetGameId() == "" {
		config.GameId = GameID
	}
	f.upserts++
	f.configs[config.GetAppCode()+":"+config.GetGameId()] = cloneConfig(config)
	return cloneConfig(config), nil
}
