package roomrps

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

	"google.golang.org/protobuf/proto"
	gamev1 "hyapp.local/api/proto/game/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"
)

// 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)
}

// 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
	pub    Publisher

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

// New 创建房内猜拳用例层。
// 当前阶段先把真实 HTTP、后台和 IM 事件跑通；挑战事实用进程内状态承接，后续接 MySQL 时保持同一套状态机和 RPC 契约即可。
func New(config Config, user UserClient, publisher Publisher, giftCatalog ...GiftCatalogClient) *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
	if len(giftCatalog) > 0 {
		// room-rps 运行配置只保存四个 gift_id；后台不再携带展示字段时，必须从 wallet-service 读取真实礼物快照。
		gifts = giftCatalog[0]
	}
	return &Service{
		config:     config,
		user:       user,
		gifts:      gifts,
		pub:        publisher,
		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)

	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
	}

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

	nowMS := s.now().UnixMilli()
	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")
	}

	s.mu.Lock()
	config := s.configForAppLocked(app)
	if config.GetStatus() != "active" {
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled")
	}
	gift, ok := stakeGiftByID(config, giftID)
	if !ok || !gift.GetEnabled() {
		s.mu.Unlock()
		return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled")
	}
	now := s.now()
	nowMS := now.UnixMilli()
	challenge := &gamev1.RoomRPSChallenge{
		AppCode:          app,
		ChallengeId:      newChallengeID(app),
		RoomId:           roomID,
		RegionId:         req.GetRegionId(),
		Status:           StatusPending,
		StakeGiftId:      legacyRoomRPSGiftID(giftID),
		StakeGiftIdText:  giftID,
		StakeCoin:        gift.GetGiftPriceCoin(),
		Initiator:        &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS},
		SettlementStatus: SettlementPending,
		TimeoutAtMs:      now.Add(s.config.ChallengeTimeout).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 {
		// pending 是唯一可应战状态；matched/revealing/finished/timeout 都已经有明确归属，重复点击只能返回冲突。
		s.mu.Unlock()
		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()
		_ = s.publishChallengeEvent(ctx, eventExpired, cloned)
		return nil, 0, xerr.New(xerr.Conflict, "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")
	}

	challenge.Status = StatusFinished
	challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, 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()

	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
}

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()
	challenge.Status = StatusFinished
	challenge.SettlementStatus = SettlementSettled
	challenge.FailureReason = ""
	challenge.SettledAtMs = nowMS
	challenge.UpdatedAtMs = nowMS
	cloned := cloneChallenge(challenge)
	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()

	if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil {
		return nil, 0, err
	}
	return cloned, nowMS, 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(index + 1)
		}
		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()),
			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.GetGiftPriceCoin() > 0 {
			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 == "" {
			if iconURL := roomRPSGiftIconURL(item); iconURL != "" {
				// Flutter 的 PK 面板只消费 gift_icon_url；这里把礼物资源主图折叠成该字段，保持接口契约稳定。
				gift.GiftIconUrl = iconURL
				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 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(),
		gift.GetResource().GetAnimationUrl(),
	}
	for _, candidate := range candidates {
		if value := strings.TrimSpace(candidate); value != "" {
			return value
		}
	}
	return ""
}

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[:]))
}
