package roomrps

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

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

const (
	GameID = "room_rps"

	StatusPending          = "pending"
	StatusMatched          = "matched"
	StatusRevealing        = "revealing"
	StatusFinished         = "finished"
	StatusTimeout          = "timeout"
	StatusSettlementFailed = "settlement_failed"
	StatusCancelled        = "cancelled"

	SettlementPending  = "pending"
	SettlementSettled  = "settled"
	SettlementRefunded = "refunded"
	SettlementFailed   = "failed"

	GestureRock     = "rock"
	GesturePaper    = "paper"
	GestureScissors = "scissors"

	ResultWin  = "win"
	ResultLose = "lose"
	ResultDraw = "draw"

	eventChallengeCreated  = "room_rps_challenge_created"
	eventChallengeAccepted = "room_rps_challenge_accepted"
	eventRevealCountdown   = "room_rps_reveal_countdown"
	eventFinished          = "room_rps_finished"
	eventExpired           = "room_rps_expired"

	roomRPSWalletPlatform = "room_rps"
	roomRPSOpDebit        = "debit"
	roomRPSOpRefund       = "refund"
)

// UserClient 是 room-rps 唯一需要的 user-service 能力；这里只取展示资料，不把用户领域模型引入游戏用例层。
type UserClient interface {
	GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
}

// GiftCatalogClient 只暴露 room-rps 配置补齐需要的礼物配置查询能力；礼物名称、图标和币价仍由 wallet-service 作为 owner 输出。
type GiftCatalogClient interface {
	ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
}

// WalletClient 只暴露 room-rps 需要的钱包能力；PK 状态仍由 game-service 管，金币事实和幂等改账由 wallet-service 管。
type WalletClient interface {
	ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
}

// RoomClient 只暴露 PK 结算礼物进入 Room Cell 的能力；火箭状态和规则始终由 room-service 持有。
type RoomClient interface {
	ApplyRoomRPSGift(ctx context.Context, req *roomv1.ApplyRoomRPSGiftRequest, opts ...grpc.CallOption) (*roomv1.ApplyRoomRPSGiftResponse, error)
}

// ConfigStore 是 room-rps 配置的持久化边界；service 只关心完整配置快照，表结构和 JSON 编码留在 MySQL 仓储层。
type ConfigStore interface {
	GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error)
	UpsertRoomRPSConfig(ctx context.Context, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error)
}

// Publisher 是房间 IM 投递边界；生产用腾讯 IM REST，测试可以注入 fake，service 不感知具体 SDK。
type Publisher interface {
	EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
	PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
}

type Config struct {
	ChallengeTimeout time.Duration
	RevealCountdown  time.Duration
}

type Service struct {
	config Config
	user   UserClient
	gifts  GiftCatalogClient
	wallet WalletClient
	room   RoomClient
	pub    Publisher
	store  ConfigStore

	mu         sync.Mutex
	configs    map[string]*gamev1.RoomRPSConfig
	challenges map[string]*gamev1.RoomRPSChallenge
	now        func() time.Time
}

// New 创建房内猜拳用例层。
// 配置已经接入 MySQL 持久化；挑战事实仍是进程内状态，后续独立迁移时继续沿用当前状态机和 RPC 契约。
func New(config Config, user UserClient, publisher Publisher, extras ...any) *Service {
	if config.ChallengeTimeout <= 0 {
		// 房内猜拳产品规则是 10 分钟无人应战自动超时；默认打开时必须带稳定超时，避免 pending 永久悬挂。
		config.ChallengeTimeout = 10 * time.Minute
	}
	if config.RevealCountdown <= 0 {
		// 揭晓动画固定 3 秒；IM 的 countdown 事件和 Flutter 动画以这个值做时间校准。
		config.RevealCountdown = 3 * time.Second
	}
	var gifts GiftCatalogClient
	var wallet WalletClient
	var store ConfigStore
	var room RoomClient
	for _, extra := range extras {
		if typed, ok := extra.(GiftCatalogClient); ok {
			// room-rps 运行配置只保存四个 gift_id；后台不再携带展示字段时，必须从 wallet-service 读取真实礼物快照。
			gifts = typed
		}
		if typed, ok := extra.(WalletClient); ok {
			// 发起和应战都必须进入 wallet-service 改账；只做余额查询会让用户看到 PK 成功但金币事实没有变化。
			wallet = typed
		}
		if typed, ok := extra.(ConfigStore); ok {
			// MySQL 是配置事实源；没有仓储的测试场景才落回进程内 map，避免把运行态差异写成前端兜底。
			store = typed
		}
		if typed, ok := extra.(RoomClient); ok {
			// PK 最终败者礼物必须进入 Room Cell，不能由 game-service 自己修改火箭状态。
			room = typed
		}
	}
	return &Service{
		config:     config,
		user:       user,
		gifts:      gifts,
		wallet:     wallet,
		room:       room,
		pub:        publisher,
		store:      store,
		configs:    make(map[string]*gamev1.RoomRPSConfig),
		challenges: make(map[string]*gamev1.RoomRPSChallenge),
		now:        time.Now,
	}
}

// NewTencentIMPublisher 创建真实腾讯 IM publisher。
// game-service 的房内 PK 发起接口是同步返回给客户端的入口，因此 IM 配置必须来自已校验的 YAML，而不是进程环境变量的隐式副作用。
func NewTencentIMPublisher(config tencentim.RESTConfig) (Publisher, error) {
	return tencentim.NewRESTClient(config)
}

func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) {
	if s == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
	}
	app = appcode.Normalize(app)
	ctx = appcode.WithContext(ctx, app)

	if s.store != nil {
		return s.getPersistentConfig(ctx, app)
	}

	s.mu.Lock()
	config := s.configForAppLocked(app)
	cloned := cloneConfig(config)
	originalUpdatedAtMS := config.GetUpdatedAtMs()
	s.mu.Unlock()

	enriched, changed, err := s.enrichConfigGifts(ctx, app, cloned)
	if err != nil {
		return nil, 0, err
	}
	if changed {
		s.mu.Lock()
		// 线上已有旧配置可能只保存 gift_id；读接口补齐成功后同步回内存，后续发起挑战也能使用真实币价和展示快照。
		if current := s.configs[app]; current != nil && current.GetUpdatedAtMs() == originalUpdatedAtMS {
			s.configs[app] = cloneConfig(enriched)
		}
		s.mu.Unlock()
	}
	return enriched, s.now().UnixMilli(), nil
}

func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, int64, error) {
	if s == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
	}
	app = appcode.Normalize(app)
	ctx = appcode.WithContext(ctx, app)

	normalized, err := s.normalizeConfig(ctx, app, config)
	if err != nil {
		return nil, 0, err
	}
	nowMS := s.now().UnixMilli()

	if s.store != nil {
		// 数据库保存时由唯一键保持 created_at_ms；service 每次提交只表达“这个时刻产生了新配置版本”。
		normalized.CreatedAtMs = nowMS
		normalized.UpdatedAtMs = nowMS
		saved, err := s.store.UpsertRoomRPSConfig(ctx, normalized)
		if err != nil {
			return nil, 0, err
		}
		s.mu.Lock()
		delete(s.configs, app)
		s.mu.Unlock()
		return cloneConfig(saved), nowMS, nil
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if existing := s.configs[app]; existing != nil && existing.GetCreatedAtMs() > 0 {
		normalized.CreatedAtMs = existing.GetCreatedAtMs()
	} else {
		normalized.CreatedAtMs = nowMS
	}
	normalized.UpdatedAtMs = nowMS
	s.configs[app] = normalized
	return cloneConfig(normalized), nowMS, nil
}

func (s *Service) ListChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) ([]*gamev1.RoomRPSChallenge, string, int32, int64, error) {
	if s == nil {
		return nil, "", 0, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
	}
	app := appcode.Normalize(req.GetMeta().GetAppCode())
	ctx = appcode.WithContext(ctx, app)
	pageSize := req.GetPageSize()
	if pageSize <= 0 {
		pageSize = 20
	}
	if pageSize > 100 {
		// 后台列表和 App 房间补拉都走同一接口；这里硬限制每页 100，防止一次请求把进程内状态全量复制出去。
		pageSize = 100
	}
	cursorOffset := 0
	if raw := strings.TrimSpace(req.GetCursor()); raw != "" {
		parsed, err := strconv.Atoi(raw)
		if err != nil || parsed < 0 {
			return nil, "", 0, 0, xerr.New(xerr.InvalidArgument, "room rps cursor is invalid")
		}
		cursorOffset = parsed
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	status := normalizeStatus(req.GetStatus())
	items := make([]*gamev1.RoomRPSChallenge, 0, len(s.challenges))
	for _, item := range s.challenges {
		if item.GetAppCode() != app {
			continue
		}
		if roomID := strings.TrimSpace(req.GetRoomId()); roomID != "" && item.GetRoomId() != roomID {
			continue
		}
		if status != "" && item.GetStatus() != status {
			continue
		}
		if req.GetInitiatorUserId() > 0 && item.GetInitiator().GetUserId() != req.GetInitiatorUserId() {
			continue
		}
		if req.GetChallengerUserId() > 0 && item.GetChallenger().GetUserId() != req.GetChallengerUserId() {
			continue
		}
		if req.GetStartTimeMs() > 0 && item.GetCreatedAtMs() < req.GetStartTimeMs() {
			continue
		}
		if req.GetEndTimeMs() > 0 && item.GetCreatedAtMs() > req.GetEndTimeMs() {
			continue
		}
		items = append(items, cloneChallenge(item))
	}
	sort.Slice(items, func(i, j int) bool {
		if items[i].GetCreatedAtMs() == items[j].GetCreatedAtMs() {
			return items[i].GetChallengeId() > items[j].GetChallengeId()
		}
		return items[i].GetCreatedAtMs() > items[j].GetCreatedAtMs()
	})

	if cursorOffset >= len(items) {
		return []*gamev1.RoomRPSChallenge{}, "", pageSize, s.now().UnixMilli(), nil
	}
	end := cursorOffset + int(pageSize)
	nextCursor := ""
	if end < len(items) {
		nextCursor = strconv.Itoa(end)
	} else {
		end = len(items)
	}
	return items[cursorOffset:end], nextCursor, pageSize, s.now().UnixMilli(), nil
}

func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) {
	if s == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
	}
	app := appcode.Normalize(req.GetMeta().GetAppCode())
	ctx = appcode.WithContext(ctx, app)
	roomID := strings.TrimSpace(req.GetRoomId())
	gesture, ok := normalizeGesture(req.GetGesture())
	giftID := roomRPSRequestGiftID(req)
	if req.GetUserId() <= 0 || roomID == "" || giftID == "" || !ok {
		return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge command is incomplete")
	}

	config, err := s.runtimeConfig(ctx, app)
	if err != nil {
		return nil, 0, err
	}
	if config.GetStatus() != "active" {
		return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled")
	}
	gift, ok := stakeGiftByID(config, giftID)
	if !ok || !gift.GetEnabled() {
		return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled")
	}
	stakeCoin := gift.GetGiftPriceCoin()
	if stakeCoin <= 0 {
		return nil, 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid")
	}
	challengeID := newChallengeID(app)
	initiatorBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit)
	if err != nil {
		return nil, 0, err
	}

	s.mu.Lock()
	now := s.now()
	nowMS := now.UnixMilli()
	challenge := &gamev1.RoomRPSChallenge{
		AppCode:          app,
		ChallengeId:      challengeID,
		RoomId:           roomID,
		RegionId:         req.GetRegionId(),
		Status:           StatusPending,
		StakeGiftId:      legacyRoomRPSGiftID(giftID),
		StakeGiftIdText:  giftID,
		StakeCoin:        stakeCoin,
		Initiator:        &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: initiatorBalance, JoinedAtMs: nowMS},
		SettlementStatus: SettlementPending,
		// timeout_at_ms 是订单创建时冻结的运营配置事实；后台后续改配置只影响新挑战，不改写已有订单截止时间。
		TimeoutAtMs: now.Add(time.Duration(config.GetChallengeTimeoutMs()) * time.Millisecond).UnixMilli(),
		CreatedAtMs: nowMS,
		UpdatedAtMs: nowMS,
	}
	s.challenges[challenge.GetChallengeId()] = challenge
	cloned := cloneChallenge(challenge)
	s.mu.Unlock()

	if err := s.publishChallengeEvent(ctx, eventChallengeCreated, cloned); err != nil {
		return nil, 0, err
	}
	return cloned, nowMS, nil
}

func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) {
	if s == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
	}
	app := appcode.Normalize(req.GetMeta().GetAppCode())
	ctx = appcode.WithContext(ctx, app)
	challengeID := strings.TrimSpace(req.GetChallengeId())
	gesture, ok := normalizeGesture(req.GetGesture())
	if req.GetUserId() <= 0 || challengeID == "" || !ok {
		return nil, 0, xerr.New(xerr.InvalidArgument, "room rps accept command is incomplete")
	}

	s.mu.Lock()
	challenge, ok := s.challenges[challengeID]
	if !ok || challenge.GetAppCode() != app {
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
	}
	now := s.now()
	nowMS := now.UnixMilli()
	if challenge.GetStatus() != StatusPending {
		// timeout 和“被其他人接单”都需要稳定业务码；其它终态才保留通用冲突，避免客户端按 message 猜状态。
		expired := challenge.GetStatus() == StatusTimeout
		takenByAnother := roomRPSChallengeTakenByAnother(challenge, req.GetUserId())
		s.mu.Unlock()
		if expired {
			return nil, 0, xerr.New(xerr.RoomRPSChallengeExpired, "room rps challenge is timeout")
		}
		if takenByAnother {
			return nil, 0, xerr.New(xerr.RoomRPSChallengeTaken, "room rps challenge was accepted by another user")
		}
		return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending")
	}
	if challenge.GetTimeoutAtMs() > 0 && nowMS >= challenge.GetTimeoutAtMs() {
		// 读到已超时但还未收敛的 pending 时，先在锁内改成 timeout，避免下一个用户还能应战同一单。
		challenge.Status = StatusTimeout
		challenge.SettlementStatus = SettlementRefunded
		challenge.UpdatedAtMs = nowMS
		cloned := cloneChallenge(challenge)
		s.mu.Unlock()
		refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned)
		if refundErr != nil {
			s.markSettlementFailed(app, challengeID, refundErr)
			return nil, 0, refundErr
		}
		s.replaceChallengeSnapshot(app, challengeID, refunded)
		cloned = refunded
		_ = s.publishChallengeEvent(ctx, eventExpired, cloned)
		return nil, 0, xerr.New(xerr.RoomRPSChallengeExpired, "room rps challenge is timeout")
	}
	if challenge.GetInitiator().GetUserId() == req.GetUserId() {
		// 发起人不能应战自己的单；这个判断必须在 service 锁内做，不能依赖前端按钮状态。
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge")
	}
	stakeCoin := challenge.GetStakeCoin()
	roomID := challenge.GetRoomId()
	s.mu.Unlock()

	challengerBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit)
	if err != nil {
		return nil, 0, err
	}

	s.mu.Lock()
	challenge, ok = s.challenges[challengeID]
	if !ok || challenge.GetAppCode() != app {
		s.mu.Unlock()
		_, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund)
		return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
	}
	now = s.now()
	nowMS = now.UnixMilli()
	if challenge.GetStatus() != StatusPending {
		// 钱包扣款期间可能已有其他用户抢先应战；状态机必须以锁内二次校验为准，失败时立即退回刚扣的挑战方本金。
		expired := challenge.GetStatus() == StatusTimeout
		takenByAnother := roomRPSChallengeTakenByAnother(challenge, req.GetUserId())
		s.mu.Unlock()
		_, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund)
		if expired {
			return nil, 0, xerr.New(xerr.RoomRPSChallengeExpired, "room rps challenge is timeout")
		}
		if takenByAnother {
			return nil, 0, xerr.New(xerr.RoomRPSChallengeTaken, "room rps challenge was accepted by another user")
		}
		return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending")
	}
	if challenge.GetTimeoutAtMs() > 0 && nowMS >= challenge.GetTimeoutAtMs() {
		// 二次校验也要收敛超时单，避免扣款耗时跨过 timeout 后仍然匹配成功。
		challenge.Status = StatusTimeout
		challenge.SettlementStatus = SettlementRefunded
		challenge.UpdatedAtMs = nowMS
		cloned := cloneChallenge(challenge)
		s.mu.Unlock()
		_, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund)
		refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned)
		if refundErr != nil {
			s.markSettlementFailed(app, challengeID, refundErr)
			return nil, 0, refundErr
		}
		s.replaceChallengeSnapshot(app, challengeID, refunded)
		cloned = refunded
		_ = s.publishChallengeEvent(ctx, eventExpired, cloned)
		return nil, 0, xerr.New(xerr.RoomRPSChallengeExpired, "room rps challenge is timeout")
	}
	if challenge.GetInitiator().GetUserId() == req.GetUserId() {
		// 二次校验保留同一条权限边界，防止测试或未来接口在第一次检查后篡改挑战归属。
		s.mu.Unlock()
		_, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund)
		return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge")
	}

	challenge.Status = StatusFinished
	challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: challengerBalance, JoinedAtMs: nowMS}
	challenge.MatchedAtMs = nowMS
	challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli()
	challenge.SettledAtMs = nowMS
	challenge.UpdatedAtMs = nowMS
	applyResult(challenge)
	cloned := cloneChallenge(challenge)
	s.mu.Unlock()

	settled, err := s.settleChallenge(ctx, req.GetMeta().GetRequestId(), cloned)
	if err != nil {
		s.mu.Lock()
		if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app {
			current.Status = StatusSettlementFailed
			current.SettlementStatus = SettlementFailed
			current.FailureReason = err.Error()
			current.UpdatedAtMs = s.now().UnixMilli()
		}
		s.mu.Unlock()
		return nil, 0, err
	}
	s.mu.Lock()
	if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app {
		current.Initiator.BalanceAfter = settled.GetInitiator().GetBalanceAfter()
		current.Challenger.BalanceAfter = settled.GetChallenger().GetBalanceAfter()
		current.SettlementStatus = settled.GetSettlementStatus()
		current.FailureReason = ""
		current.SettledAtMs = s.now().UnixMilli()
		current.UpdatedAtMs = current.GetSettledAtMs()
		cloned = cloneChallenge(current)
	} else {
		cloned = settled
	}
	s.mu.Unlock()

	if err := s.publishChallengeEvent(ctx, eventChallengeAccepted, cloned); err != nil {
		return nil, 0, err
	}
	if err := s.publishChallengeEvent(ctx, eventRevealCountdown, cloned); err != nil {
		return nil, 0, err
	}
	if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil {
		return nil, 0, err
	}
	return cloned, nowMS, nil
}

// roomRPSChallengeTakenByAnother 只识别“已有其他挑战者”这一个可操作冲突，避免把超时、结算失败或当事人重试误报为被他人接单。
func roomRPSChallengeTakenByAnother(challenge *gamev1.RoomRPSChallenge, userID int64) bool {
	challengerID := challenge.GetChallenger().GetUserId()
	return challengerID > 0 && challengerID != userID
}

func (s *Service) GetChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
	if s == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
	}
	app = appcode.Normalize(app)
	ctx = appcode.WithContext(ctx, app)
	challengeID = strings.TrimSpace(challengeID)
	if challengeID == "" {
		return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	challenge, ok := s.challenges[challengeID]
	if !ok || challenge.GetAppCode() != app {
		return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
	}
	return cloneChallenge(challenge), s.now().UnixMilli(), nil
}

func (s *Service) RetrySettlement(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
	if s == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
	}
	app = appcode.Normalize(app)
	ctx = appcode.WithContext(ctx, app)
	challengeID = strings.TrimSpace(challengeID)
	if challengeID == "" {
		return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
	}

	s.mu.Lock()
	challenge, ok := s.challenges[challengeID]
	if !ok || challenge.GetAppCode() != app {
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
	}
	if challenge.GetStatus() != StatusSettlementFailed && challenge.GetSettlementStatus() != SettlementFailed {
		// 只有结算失败单能进入重试；已结算、已退款、pending 或 timeout 都不能被后台按钮强制改结果。
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not settlement_failed")
	}
	nowMS := s.now().UnixMilli()
	// 先把失败单改为结算中，再释放进程锁调用 wallet/room；这个中间态阻止
	// 并发后台重试同时发两条 finished IM。wallet command_id 和 room command_id
	// 都由 challenge_id 稳定派生，所以未知结果的跨服务请求可以安全重放。
	challenge.Status = StatusFinished
	challenge.SettlementStatus = SettlementPending
	challenge.FailureReason = ""
	challenge.UpdatedAtMs = nowMS
	cloned := cloneChallenge(challenge)
	s.mu.Unlock()

	settled, err := s.settleChallenge(ctx, "room-rps-retry:"+challengeID, cloned)
	if err != nil {
		// 重试仍失败时必须回到可重试终态；不发 finished IM，也不能让
		// 查询端把未完成的跨服务结算当成成功。已成功的单边动作依赖稳定
		// command_id 在下次重试时原样重放，不做反向补偿以免双退款。
		s.markSettlementFailed(app, challengeID, err)
		return nil, 0, err
	}

	nowMS = s.now().UnixMilli()
	s.mu.Lock()
	current := s.challenges[challengeID]
	if current == nil || current.GetAppCode() != app {
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
	}
	// settleChallenge 返回 wallet 幂等回执中的真实余额；只有 room 礼物也成功后
	// 才将内存快照收敛为 settled，保证 finished IM 不会描述一份缺失的火箭礼物。
	current.Status = StatusFinished
	current.Initiator.BalanceAfter = settled.GetInitiator().GetBalanceAfter()
	current.Challenger.BalanceAfter = settled.GetChallenger().GetBalanceAfter()
	current.SettlementStatus = settled.GetSettlementStatus()
	current.FailureReason = ""
	current.SettledAtMs = nowMS
	current.UpdatedAtMs = nowMS
	cloned = cloneChallenge(current)
	s.mu.Unlock()

	if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil {
		return nil, 0, err
	}
	return cloned, nowMS, nil
}

func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
	if s == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
	}
	app = appcode.Normalize(app)
	ctx = appcode.WithContext(ctx, app)
	challengeID = strings.TrimSpace(challengeID)
	if challengeID == "" {
		return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
	}

	s.mu.Lock()
	challenge, ok := s.challenges[challengeID]
	if !ok || challenge.GetAppCode() != app {
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
	}
	if challenge.GetStatus() != StatusPending || challenge.GetChallenger().GetUserId() > 0 {
		// 手动过期只允许无人应战的 pending 单；一旦有 challenger，就必须走揭晓/结算链路，不能后台撤销。
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.Conflict, "room rps challenge can not expire")
	}
	nowMS := s.now().UnixMilli()
	challenge.Status = StatusTimeout
	challenge.SettlementStatus = SettlementRefunded
	challenge.UpdatedAtMs = nowMS
	cloned := cloneChallenge(challenge)
	s.mu.Unlock()

	refunded, err := s.refundPendingChallenge(ctx, "room-rps-expire:"+challengeID, cloned)
	if err != nil {
		s.markSettlementFailed(app, challengeID, err)
		return nil, 0, err
	}
	s.replaceChallengeSnapshot(app, challengeID, refunded)
	cloned = refunded

	if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil {
		return nil, 0, err
	}
	return cloned, nowMS, nil
}

func (s *Service) getPersistentConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) {
	nowMS := s.now().UnixMilli()
	config, err := s.store.GetRoomRPSConfig(ctx, app, GameID)
	persisted := true
	if err != nil {
		if !xerr.IsCode(err, xerr.NotFound) {
			return nil, 0, err
		}
		// 首次部署或本地空库没有配置行时仍返回产品默认值；只有后台保存后才写入 MySQL，避免读接口偷偷覆盖运营数据。
		config = defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS)
		persisted = false
	}

	enriched, changed, err := s.enrichConfigGifts(ctx, app, cloneConfig(config))
	if err != nil {
		return nil, 0, err
	}
	if changed && persisted {
		// 历史行可能只保存 gift_id 或误把 mp4 写进 icon；读路径修正成功后回写，后续重启和发起挑战都使用同一份快照。
		enriched.UpdatedAtMs = nowMS
		saved, err := s.store.UpsertRoomRPSConfig(ctx, enriched)
		if err != nil {
			return nil, 0, err
		}
		enriched = saved
	}
	return cloneConfig(enriched), nowMS, nil
}

func (s *Service) runtimeConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, error) {
	if s.store != nil {
		config, _, err := s.GetConfig(ctx, app)
		return config, err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	return cloneConfig(s.configForAppLocked(app)), nil
}

func (s *Service) configForAppLocked(app string) *gamev1.RoomRPSConfig {
	if config := s.configs[app]; config != nil {
		return config
	}
	nowMS := s.now().UnixMilli()
	config := defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS)
	s.configs[app] = config
	return config
}

func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) {
	if config == nil {
		return nil, xerr.New(xerr.InvalidArgument, "room rps config is required")
	}
	status := normalizeStatus(config.GetStatus())
	if status == "" {
		// 逻辑功能配置默认打开；后台未传 status 时按 active 保存，不能静默写成关闭。
		status = "active"
	}
	if status != "active" && status != "disabled" {
		return nil, xerr.New(xerr.InvalidArgument, "room rps status is invalid")
	}
	timeoutMS := config.GetChallengeTimeoutMs()
	if timeoutMS <= 0 {
		timeoutMS = int64(s.config.ChallengeTimeout / time.Millisecond)
	}
	revealMS := config.GetRevealCountdownMs()
	if revealMS <= 0 {
		revealMS = int64(s.config.RevealCountdown / time.Millisecond)
	}
	if len(config.GetStakeGifts()) != 4 {
		return nil, xerr.New(xerr.InvalidArgument, "room rps requires exactly 4 stake gifts")
	}

	defaultsByID := defaultGiftMap()
	gifts := make([]*gamev1.RoomRPSStakeGift, 0, 4)
	seenGift := make(map[string]struct{}, 4)
	seenSort := make(map[int32]struct{}, 4)
	for index, gift := range config.GetStakeGifts() {
		giftID := roomRPSStakeGiftID(gift)
		if giftID == "" {
			return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is invalid")
		}
		if _, ok := seenGift[giftID]; ok {
			return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is duplicated")
		}
		sortOrder := gift.GetSortOrder()
		if sortOrder <= 0 {
			// 提交数组已经代表后台从上到下的顺序；大值优先后，越靠前的缺省档位必须获得越大的权重。
			sortOrder = int32(len(config.GetStakeGifts()) - index)
		}
		if _, ok := seenSort[sortOrder]; ok {
			return nil, xerr.New(xerr.InvalidArgument, "room rps gift sort_order is duplicated")
		}
		seenGift[giftID] = struct{}{}
		seenSort[sortOrder] = struct{}{}

		normalized := &gamev1.RoomRPSStakeGift{
			GiftId:           legacyRoomRPSGiftID(giftID),
			GiftIdText:       giftID,
			GiftName:         strings.TrimSpace(gift.GetGiftName()),
			GiftIconUrl:      strings.TrimSpace(gift.GetGiftIconUrl()),
			GiftAnimationUrl: strings.TrimSpace(gift.GetGiftAnimationUrl()),
			GiftPriceCoin:    gift.GetGiftPriceCoin(),
			Enabled:          gift.GetEnabled(),
			SortOrder:        sortOrder,
		}
		gifts = append(gifts, normalized)
	}
	if _, err := s.enrichStakeGifts(ctx, app, gifts); err != nil {
		return nil, err
	}
	for _, normalized := range gifts {
		if fallback := defaultsByID[roomRPSStakeGiftID(normalized)]; fallback != nil {
			if normalized.GiftName == "" {
				normalized.GiftName = fallback.GetGiftName()
			}
			if normalized.GiftIconUrl == "" {
				normalized.GiftIconUrl = fallback.GetGiftIconUrl()
			}
			if normalized.GiftPriceCoin <= 0 {
				normalized.GiftPriceCoin = fallback.GetGiftPriceCoin()
			}
		}
		if normalized.GiftPriceCoin <= 0 {
			// 运行时必须知道当前档位折算金币，后续接钱包托管也以这个金额作为订单事实。
			normalized.GiftPriceCoin = int64(normalized.GetSortOrder()) * 100
		}
	}
	// 猜拳礼物同样遵循后台统一权重语义；档位身份由礼物和金额表达，不能再把小排序值当作优先级。
	sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() > gifts[j].GetSortOrder() })
	return &gamev1.RoomRPSConfig{
		AppCode:            app,
		GameId:             GameID,
		Status:             status,
		ChallengeTimeoutMs: timeoutMS,
		RevealCountdownMs:  revealMS,
		StakeGifts:         gifts,
	}, nil
}

func (s *Service) enrichConfigGifts(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, bool, error) {
	if config == nil {
		return nil, false, nil
	}
	changed, err := s.enrichStakeGifts(ctx, app, config.GetStakeGifts())
	if err != nil {
		return nil, false, err
	}
	return config, changed, nil
}

func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gamev1.RoomRPSStakeGift) (bool, error) {
	if len(gifts) == 0 {
		return false, nil
	}
	needGiftIDs := make([]string, 0, len(gifts))
	seen := make(map[string]struct{}, len(gifts))
	for _, gift := range gifts {
		giftID := roomRPSStakeGiftID(gift)
		if giftID == "" {
			continue
		}
		if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftAnimationUrl() != "" && gift.GetGiftPriceCoin() > 0 && !roomRPSURLLooksAnimated(gift.GetGiftIconUrl()) {
			continue
		}
		if _, ok := seen[giftID]; ok {
			continue
		}
		// 只查询缺字段的档位，避免每次读配置都把 wallet-service 当成全量礼物列表使用。
		seen[giftID] = struct{}{}
		needGiftIDs = append(needGiftIDs, giftID)
	}
	if len(needGiftIDs) == 0 {
		return false, nil
	}
	catalog, err := s.roomRPSGiftCatalog(ctx, app, needGiftIDs)
	if err != nil {
		return false, err
	}
	changed := false
	for _, gift := range gifts {
		if gift == nil {
			continue
		}
		item := catalog[roomRPSStakeGiftID(gift)]
		if item == nil {
			continue
		}
		if gift.GiftName == "" {
			if name := roomRPSGiftDisplayName(item); name != "" {
				// 后台保存 room-rps 时只提交 gift_id；运行时展示名必须来自 wallet-service 礼物配置，不能让前端猜。
				gift.GiftName = name
				changed = true
			}
		}
		if gift.GiftIconUrl == "" || roomRPSURLLooksAnimated(gift.GiftIconUrl) {
			if iconURL := roomRPSGiftIconURL(item); iconURL != "" {
				// gift_icon_url 只给静态封面；历史配置曾把 animation_url 写进这里，读配置时要用 wallet 资源重新纠正。
				gift.GiftIconUrl = iconURL
				changed = true
			}
		}
		if gift.GiftAnimationUrl == "" {
			if animationURL := roomRPSGiftAnimationURL(item); animationURL != "" {
				// 动效单独返回给客户端按需播放，不能再混入 icon 字段，否则列表和选择面板会把 mp4 当图片加载。
				gift.GiftAnimationUrl = animationURL
				changed = true
			}
		}
		if gift.GiftPriceCoin <= 0 && item.GetCoinPrice() > 0 {
			// 创建挑战会把 stake_coin 固化到对局事实，缺币价时必须使用 wallet-service 当前生效礼物价格补齐。
			gift.GiftPriceCoin = item.GetCoinPrice()
			changed = true
		}
	}
	return changed, nil
}

func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs []string) (map[string]*walletv1.GiftConfig, error) {
	result := make(map[string]*walletv1.GiftConfig, len(giftIDs))
	if s.gifts == nil {
		return result, nil
	}
	app = appcode.Normalize(app)
	for _, giftID := range giftIDs {
		giftID = strings.TrimSpace(giftID)
		if giftID == "" {
			continue
		}
		for page := int32(1); ; page++ {
			resp, err := s.gifts.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{
				RequestId: fmt.Sprintf("room-rps-config:%s:%s:%d", app, giftID, page),
				AppCode:   app,
				Keyword:   giftID,
				Page:      page,
				PageSize:  100,
			})
			if err != nil {
				return nil, xerr.New(xerr.Unavailable, "room rps gift config lookup failed")
			}
			for _, item := range resp.GetGifts() {
				if strings.TrimSpace(item.GetGiftId()) != giftID {
					continue
				}
				result[giftID] = item
				break
			}
			if result[giftID] != nil {
				break
			}
			if len(resp.GetGifts()) < 100 {
				break
			}
			if total := resp.GetTotal(); total > 0 && int64(page)*100 >= total {
				break
			}
		}
	}
	return result, nil
}

func (s *Service) settleChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) {
	if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetChallenger().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "room rps settlement challenge is incomplete")
	}
	settled := cloneChallenge(challenge)
	app := settled.GetAppCode()
	challengeID := settled.GetChallengeId()
	roomID := settled.GetRoomId()
	stakeCoin := settled.GetStakeCoin()

	if settled.GetInitiator().GetResult() == ResultDraw && settled.GetChallenger().GetResult() == ResultDraw {
		// 平局没有赢家，也不应产生任何礼物流；双方前置扣款都按原金额退款，客户端只展示 refunded 事实。
		initiatorBalance, initiatorErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund)
		if initiatorErr == nil {
			settled.Initiator.BalanceAfter = initiatorBalance
		}
		challengerBalance, challengerErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund)
		if challengerErr == nil {
			settled.Challenger.BalanceAfter = challengerBalance
		}
		if initiatorErr != nil {
			return settled, initiatorErr
		}
		if challengerErr != nil {
			return settled, challengerErr
		}
		settled.SettlementStatus = SettlementRefunded
		return settled, nil
	}

	switch settled.GetWinnerUserId() {
	case settled.GetInitiator().GetUserId():
		// 胜者本金必须退回；败者在点击 PK 时已经扣款，这笔扣款保留为败者成本，不再追加第二次扣费。
		balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund)
		if err != nil {
			return settled, err
		}
		settled.Initiator.BalanceAfter = balanceAfter
		if err := s.applySettledGiftToRoom(ctx, requestID, settled, settled.GetChallenger().GetUserId(), settled.GetInitiator().GetUserId()); err != nil {
			return settled, err
		}
		settled.SettlementStatus = SettlementSettled
		return settled, nil
	case settled.GetChallenger().GetUserId():
		// 只退赢家本金；输家的余额保持前置扣款后的余额，避免“输了但金币没少”的账务错误。
		balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund)
		if err != nil {
			return settled, err
		}
		settled.Challenger.BalanceAfter = balanceAfter
		if err := s.applySettledGiftToRoom(ctx, requestID, settled, settled.GetInitiator().GetUserId(), settled.GetChallenger().GetUserId()); err != nil {
			return settled, err
		}
		settled.SettlementStatus = SettlementSettled
		return settled, nil
	default:
		return settled, xerr.New(xerr.InvalidArgument, "room rps winner is invalid")
	}
}

func (s *Service) applySettledGiftToRoom(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge, loserUserID int64, winnerUserID int64) error {
	if s.room == nil {
		// 仅保留给不装配跨服务依赖的隔离单测；生产 App.New 始终注入 room client，缺地址会在启动阶段失败。
		return nil
	}
	if challenge == nil || loserUserID <= 0 || winnerUserID <= 0 || challenge.GetStakeCoin() <= 0 {
		return xerr.New(xerr.InvalidArgument, "room rps settled gift is incomplete")
	}
	// challenge_id 对应一局最多一份真实礼物；赢家退款重放后再次调用也只会命中 Room Cell command log。
	_, err := s.room.ApplyRoomRPSGift(ctx, &roomv1.ApplyRoomRPSGiftRequest{
		Meta: &roomv1.RequestMeta{
			AppCode:     challenge.GetAppCode(),
			RequestId:   strings.TrimSpace(requestID),
			CommandId:   "room_rps_gift_" + challenge.GetChallengeId(),
			ActorUserId: loserUserID,
			RoomId:      challenge.GetRoomId(),
		},
		RoomId:       challenge.GetRoomId(),
		SenderUserId: loserUserID,
		TargetUserId: winnerUserID,
		GiftId:       roomRPSChallengeGiftID(challenge),
		HeatValue:    challenge.GetStakeCoin(),
	})
	return err
}

func (s *Service) refundPendingChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) {
	if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "room rps pending refund challenge is incomplete")
	}
	refunded := cloneChallenge(challenge)
	// 无人应战或已超时的 pending 单只存在发起人前置扣款；过期时退回发起人，不产生礼物或赢家收益。
	balanceAfter, err := s.applyCoinChange(ctx, requestID, refunded.GetAppCode(), refunded.GetChallengeId(), refunded.GetRoomId(), refunded.GetInitiator().GetUserId(), refunded.GetStakeCoin(), roomRPSOpRefund)
	if err != nil {
		return refunded, err
	}
	refunded.Initiator.BalanceAfter = balanceAfter
	refunded.SettlementStatus = SettlementRefunded
	refunded.FailureReason = ""
	refunded.UpdatedAtMs = s.now().UnixMilli()
	return refunded, nil
}

func (s *Service) replaceChallengeSnapshot(app string, challengeID string, snapshot *gamev1.RoomRPSChallenge) {
	if s == nil || snapshot == nil {
		return
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	current := s.challenges[strings.TrimSpace(challengeID)]
	if current == nil || current.GetAppCode() != appcode.Normalize(app) {
		return
	}
	s.challenges[strings.TrimSpace(challengeID)] = cloneChallenge(snapshot)
}

func (s *Service) markSettlementFailed(app string, challengeID string, cause error) {
	if s == nil || cause == nil {
		return
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	current := s.challenges[strings.TrimSpace(challengeID)]
	if current == nil || current.GetAppCode() != appcode.Normalize(app) {
		return
	}
	current.Status = StatusSettlementFailed
	current.SettlementStatus = SettlementFailed
	current.FailureReason = cause.Error()
	current.UpdatedAtMs = s.now().UnixMilli()
}

func (s *Service) applyCoinChange(ctx context.Context, requestID string, app string, challengeID string, roomID string, userID int64, amount int64, opType string) (int64, error) {
	if amount <= 0 || userID <= 0 || strings.TrimSpace(challengeID) == "" {
		return 0, xerr.New(xerr.InvalidArgument, "room rps coin command is incomplete")
	}
	if s.wallet == nil {
		// 钱包不可用时必须拒绝进入 PK；否则房间状态会成功但 COIN 事实无法同步落账。
		return 0, xerr.New(xerr.Unavailable, "room rps wallet client is not configured")
	}
	app = appcode.Normalize(app)
	opType = strings.TrimSpace(opType)
	providerOrderID := fmt.Sprintf("room_rps:%s:%d:%s", strings.TrimSpace(challengeID), userID, opType)
	requestHash := roomRPSStableHash(fmt.Sprintf("%s|%s|%s|%s|%d|%d", app, strings.TrimSpace(challengeID), strings.TrimSpace(roomID), opType, userID, amount))
	resp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
		RequestId:       strings.TrimSpace(requestID),
		AppCode:         app,
		CommandId:       "game:" + providerOrderID,
		UserId:          userID,
		PlatformCode:    roomRPSWalletPlatform,
		GameId:          GameID,
		ProviderOrderId: providerOrderID,
		ProviderRoundId: strings.TrimSpace(challengeID),
		OpType:          opType,
		CoinAmount:      amount,
		RoomId:          strings.TrimSpace(roomID),
		RequestHash:     requestHash,
	})
	if err != nil {
		return 0, err
	}
	return resp.GetBalanceAfter(), nil
}

func roomRPSStableHash(value string) string {
	sum := sha256.Sum256([]byte(value))
	return hex.EncodeToString(sum[:])
}

func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string {
	if gift == nil {
		return ""
	}
	if name := strings.TrimSpace(gift.GetName()); name != "" {
		return name
	}
	return strings.TrimSpace(gift.GetResource().GetName())
}

func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string {
	if gift == nil || gift.GetResource() == nil {
		return ""
	}
	candidates := []string{
		gift.GetResource().GetAssetUrl(),
		gift.GetResource().GetPreviewUrl(),
	}
	for _, candidate := range candidates {
		if value := strings.TrimSpace(candidate); value != "" && !roomRPSURLLooksAnimated(value) {
			return value
		}
	}
	return ""
}

func roomRPSGiftAnimationURL(gift *walletv1.GiftConfig) string {
	if gift == nil || gift.GetResource() == nil {
		return ""
	}
	return strings.TrimSpace(gift.GetResource().GetAnimationUrl())
}

func roomRPSURLLooksAnimated(value string) bool {
	value = strings.ToLower(strings.TrimSpace(value))
	if value == "" {
		return false
	}
	for _, suffix := range []string{".mp4", ".svga", ".pag", ".webm", ".mov"} {
		if strings.Contains(value, suffix) {
			return true
		}
	}
	return false
}

func (s *Service) publishChallengeEvent(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) error {
	if s.pub == nil || challenge == nil {
		return nil
	}
	// 每次发业务 IM 前都幂等建群；真实腾讯云已存在返回成功，临时房间或测试群也不需要额外预置。
	if err := s.pub.EnsureGroup(ctx, tencentim.GroupSpec{
		GroupID:         challenge.GetRoomId(),
		Name:            challenge.GetRoomId(),
		Type:            tencentim.DefaultGroupType,
		ApplyJoinOption: "FreeAccess",
	}); err != nil {
		return xerr.New(xerr.Unavailable, "room rps im group ensure failed")
	}
	payload, err := s.challengeEventPayload(ctx, eventType, challenge)
	if err != nil {
		return err
	}
	eventID := fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), s.now().UnixNano())
	if err := s.pub.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
		GroupID:     challenge.GetRoomId(),
		EventID:     eventID,
		Desc:        eventType,
		Ext:         "room_rps",
		PayloadJSON: payload,
	}); err != nil {
		return xerr.New(xerr.Unavailable, "room rps im publish failed")
	}
	return nil
}

func (s *Service) challengeEventPayload(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) (json.RawMessage, error) {
	nowMS := s.now().UnixMilli()
	payload := map[string]any{
		"event_id":       fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), nowMS),
		"event_type":     eventType,
		"room_id":        challenge.GetRoomId(),
		"challenge_id":   challenge.GetChallengeId(),
		"server_time_ms": nowMS,
		"challenge":      s.challengeMap(ctx, challenge),
	}
	raw, err := json.Marshal(payload)
	if err != nil {
		return nil, xerr.New(xerr.Internal, "room rps im payload marshal failed")
	}
	return raw, nil
}

func (s *Service) challengeMap(ctx context.Context, challenge *gamev1.RoomRPSChallenge) map[string]any {
	return map[string]any{
		"app_code":             challenge.GetAppCode(),
		"challenge_id":         challenge.GetChallengeId(),
		"room_id":              challenge.GetRoomId(),
		"region_id":            challenge.GetRegionId(),
		"status":               challenge.GetStatus(),
		"stake_gift_id":        roomRPSChallengeGiftID(challenge),
		"stake_gift_id_number": challenge.GetStakeGiftId(),
		"stake_coin":           challenge.GetStakeCoin(),
		"initiator":            s.playerMap(ctx, challenge.GetAppCode(), challenge.GetInitiator()),
		"challenger":           s.playerMap(ctx, challenge.GetAppCode(), challenge.GetChallenger()),
		"winner_user_id":       strconv.FormatInt(challenge.GetWinnerUserId(), 10),
		"settlement_status":    challenge.GetSettlementStatus(),
		"failure_reason":       challenge.GetFailureReason(),
		"timeout_at_ms":        challenge.GetTimeoutAtMs(),
		"reveal_at_ms":         challenge.GetRevealAtMs(),
		"created_at_ms":        challenge.GetCreatedAtMs(),
		"matched_at_ms":        challenge.GetMatchedAtMs(),
		"settled_at_ms":        challenge.GetSettledAtMs(),
		"updated_at_ms":        challenge.GetUpdatedAtMs(),
	}
}

func (s *Service) playerMap(ctx context.Context, app string, player *gamev1.RoomRPSPlayer) map[string]any {
	if player == nil || player.GetUserId() <= 0 {
		return map[string]any{}
	}
	user := s.displayUser(ctx, app, player.GetUserId())
	return map[string]any{
		"user_id":        strconv.FormatInt(player.GetUserId(), 10),
		"user_id_number": player.GetUserId(),
		"gesture":        player.GetGesture(),
		"result":         player.GetResult(),
		"balance_after":  player.GetBalanceAfter(),
		"joined_at_ms":   player.GetJoinedAtMs(),
		"user":           user,
	}
}

func (s *Service) displayUser(ctx context.Context, app string, userID int64) map[string]any {
	user := map[string]any{
		"user_id": strconv.FormatInt(userID, 10),
	}
	if s.user == nil || userID <= 0 {
		return user
	}
	resp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
		Meta:   &userv1.RequestMeta{AppCode: appcode.Normalize(app), Caller: "game-service-room-rps", SentAtMs: s.now().UnixMilli()},
		UserId: userID,
	})
	if err != nil || resp.GetUser() == nil {
		// IM 资料补齐不能反向改挑战事实；失败时保留 user_id，HTTP 详情仍可由 gateway 再查一次资料。
		return user
	}
	user["display_user_id"] = resp.GetUser().GetDisplayUserId()
	user["nickname"] = resp.GetUser().GetUsername()
	user["avatar"] = resp.GetUser().GetAvatar()
	return user
}

func defaultConfig(app string, timeout time.Duration, reveal time.Duration, nowMS int64) *gamev1.RoomRPSConfig {
	return &gamev1.RoomRPSConfig{
		AppCode:            app,
		GameId:             GameID,
		Status:             "active",
		ChallengeTimeoutMs: int64(timeout / time.Millisecond),
		RevealCountdownMs:  int64(reveal / time.Millisecond),
		StakeGifts:         defaultGifts(),
		CreatedAtMs:        nowMS,
		UpdatedAtMs:        nowMS,
	}
}

func defaultGifts() []*gamev1.RoomRPSStakeGift {
	return []*gamev1.RoomRPSStakeGift{
		{GiftId: 10001, GiftIdText: "10001", GiftName: "RPS 100", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-100.png", GiftPriceCoin: 100, Enabled: true, SortOrder: 1},
		{GiftId: 10002, GiftIdText: "10002", GiftName: "RPS 300", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-300.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 2},
		{GiftId: 10003, GiftIdText: "10003", GiftName: "RPS 500", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-500.png", GiftPriceCoin: 500, Enabled: true, SortOrder: 3},
		{GiftId: 10004, GiftIdText: "10004", GiftName: "RPS 1000", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-1000.png", GiftPriceCoin: 1000, Enabled: true, SortOrder: 4},
	}
}

func defaultGiftMap() map[string]*gamev1.RoomRPSStakeGift {
	out := make(map[string]*gamev1.RoomRPSStakeGift, 4)
	for _, gift := range defaultGifts() {
		out[roomRPSStakeGiftID(gift)] = gift
	}
	return out
}

func stakeGiftByID(config *gamev1.RoomRPSConfig, giftID string) (*gamev1.RoomRPSStakeGift, bool) {
	giftID = strings.TrimSpace(giftID)
	for _, gift := range config.GetStakeGifts() {
		if roomRPSStakeGiftID(gift) == giftID {
			return gift, true
		}
	}
	return nil, false
}

func roomRPSStakeGiftID(gift *gamev1.RoomRPSStakeGift) string {
	if gift == nil {
		return ""
	}
	if text := strings.TrimSpace(gift.GetGiftIdText()); text != "" {
		return text
	}
	if gift.GetGiftId() > 0 {
		return strconv.FormatInt(gift.GetGiftId(), 10)
	}
	return ""
}

func roomRPSRequestGiftID(req *gamev1.CreateRoomRPSChallengeRequest) string {
	if req == nil {
		return ""
	}
	if text := strings.TrimSpace(req.GetGiftIdText()); text != "" {
		return text
	}
	if req.GetGiftId() > 0 {
		return strconv.FormatInt(req.GetGiftId(), 10)
	}
	return ""
}

func roomRPSChallengeGiftID(challenge *gamev1.RoomRPSChallenge) string {
	if challenge == nil {
		return ""
	}
	if text := strings.TrimSpace(challenge.GetStakeGiftIdText()); text != "" {
		return text
	}
	if challenge.GetStakeGiftId() > 0 {
		return strconv.FormatInt(challenge.GetStakeGiftId(), 10)
	}
	return ""
}

func legacyRoomRPSGiftID(giftID string) int64 {
	value, err := strconv.ParseInt(strings.TrimSpace(giftID), 10, 64)
	if err != nil || value <= 0 {
		return 0
	}
	return value
}

func applyResult(challenge *gamev1.RoomRPSChallenge) {
	initiatorGesture := challenge.GetInitiator().GetGesture()
	challengerGesture := challenge.GetChallenger().GetGesture()
	switch {
	case initiatorGesture == challengerGesture:
		challenge.Initiator.Result = ResultDraw
		challenge.Challenger.Result = ResultDraw
		challenge.WinnerUserId = 0
		challenge.SettlementStatus = SettlementRefunded
	case beats(initiatorGesture, challengerGesture):
		challenge.Initiator.Result = ResultWin
		challenge.Challenger.Result = ResultLose
		challenge.WinnerUserId = challenge.GetInitiator().GetUserId()
		challenge.SettlementStatus = SettlementSettled
	default:
		challenge.Initiator.Result = ResultLose
		challenge.Challenger.Result = ResultWin
		challenge.WinnerUserId = challenge.GetChallenger().GetUserId()
		challenge.SettlementStatus = SettlementSettled
	}
}

func beats(left string, right string) bool {
	return (left == GestureRock && right == GestureScissors) ||
		(left == GesturePaper && right == GestureRock) ||
		(left == GestureScissors && right == GesturePaper)
}

func normalizeGesture(value string) (string, bool) {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case GestureRock:
		return GestureRock, true
	case GesturePaper:
		return GesturePaper, true
	case GestureScissors:
		return GestureScissors, true
	default:
		return "", false
	}
}

func normalizeStatus(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", "all":
		return ""
	case StatusPending, StatusMatched, StatusRevealing, StatusFinished, StatusTimeout, StatusSettlementFailed, StatusCancelled, "active", "disabled":
		return strings.ToLower(strings.TrimSpace(value))
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func cloneConfig(config *gamev1.RoomRPSConfig) *gamev1.RoomRPSConfig {
	if config == nil {
		return nil
	}
	// protobuf message 内部带锁状态，不能用结构体赋值浅拷贝；proto.Clone 会复制业务字段并重建 message state。
	cloned, ok := proto.Clone(config).(*gamev1.RoomRPSConfig)
	if !ok {
		return nil
	}
	return cloned
}

func cloneChallenge(challenge *gamev1.RoomRPSChallenge) *gamev1.RoomRPSChallenge {
	if challenge == nil {
		return nil
	}
	// 对局快照同样必须走 protobuf 深拷贝，避免 vet copylocks 和嵌套玩家资料被调用方意外共享。
	cloned, ok := proto.Clone(challenge).(*gamev1.RoomRPSChallenge)
	if !ok {
		return nil
	}
	return cloned
}

func newChallengeID(app string) string {
	var entropy [8]byte
	if _, err := rand.Read(entropy[:]); err != nil {
		return fmt.Sprintf("rps_%s_%d", appcode.Normalize(app), time.Now().UnixNano())
	}
	return fmt.Sprintf("rps_%s_%d_%s", appcode.Normalize(app), time.Now().UnixMilli(), hex.EncodeToString(entropy[:]))
}
