package grpc

import (
	"context"
	"strings"
	"time"

	gamev1 "hyapp.local/api/proto/game/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	dicedomain "hyapp/services/game-service/internal/domain/dice"
	gamedomain "hyapp/services/game-service/internal/domain/game"
	selfgame "hyapp/services/game-service/internal/domain/selfgame"
	diceservice "hyapp/services/game-service/internal/service/dice"
	gameservice "hyapp/services/game-service/internal/service/game"
	roomrpsservice "hyapp/services/game-service/internal/service/roomrps"
)

// Server 把 game-service 用例层适配为 gRPC 契约。
type Server struct {
	gamev1.UnimplementedGameAppServiceServer
	gamev1.UnimplementedGameCallbackServiceServer
	gamev1.UnimplementedGameAdminServiceServer
	gamev1.UnimplementedGameCronServiceServer

	svc        *gameservice.Service
	diceSvc    *diceservice.Service
	roomRPSSvc *roomrpsservice.Service
}

func NewServer(svc *gameservice.Service, extras ...any) *Server {
	// extras 用类型分派保留旧测试构造方式；生产 app 会同时传入骰子服务和房内猜拳服务。
	var (
		diceSvc    *diceservice.Service
		roomRPSSvc *roomrpsservice.Service
	)
	for _, extra := range extras {
		switch typed := extra.(type) {
		case *diceservice.Service:
			diceSvc = typed
		case *roomrpsservice.Service:
			roomRPSSvc = typed
		}
	}
	return &Server{svc: svc, diceSvc: diceSvc, roomRPSSvc: roomRPSSvc}
}

func (s *Server) ListGames(ctx context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, serverTimeMS, err := s.svc.ListGames(ctx, gameservice.ListGamesQuery{
		AppCode:        req.GetMeta().GetAppCode(),
		UserID:         req.GetUserId(),
		Scene:          req.GetScene(),
		RoomID:         req.GetRoomId(),
		RegionID:       req.GetRegionId(),
		Language:       req.GetLanguage(),
		ClientPlatform: req.GetClientPlatform(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListGamesResponse{ServerTimeMs: serverTimeMS, Games: make([]*gamev1.AppGame, 0, len(items))}
	for _, item := range items {
		resp.Games = append(resp.Games, appGameToProto(item))
	}
	return resp, nil
}

func (s *Server) ListRecentGames(ctx context.Context, req *gamev1.ListRecentGamesRequest) (*gamev1.ListGamesResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, serverTimeMS, err := s.svc.ListRecentGames(ctx, gameservice.ListRecentGamesQuery{
		AppCode:        req.GetMeta().GetAppCode(),
		UserID:         req.GetUserId(),
		Scene:          req.GetScene(),
		RoomID:         req.GetRoomId(),
		RegionID:       req.GetRegionId(),
		Language:       req.GetLanguage(),
		ClientPlatform: req.GetClientPlatform(),
		PageSize:       req.GetPageSize(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListGamesResponse{ServerTimeMs: serverTimeMS, Games: make([]*gamev1.AppGame, 0, len(items))}
	for _, item := range items {
		resp.Games = append(resp.Games, appGameToProto(item))
	}
	return resp, nil
}

func (s *Server) ListExploreWinners(ctx context.Context, req *gamev1.ListExploreWinnersRequest) (*gamev1.ListExploreWinnersResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, serverTimeMS, err := s.diceSvc.ListExploreWinners(ctx, req.GetMeta().GetAppCode(), req.GetPageSize())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListExploreWinnersResponse{ServerTimeMs: serverTimeMS, Winners: make([]*gamev1.ExploreWinner, 0, len(items))}
	for _, item := range items {
		resp.Winners = append(resp.Winners, exploreWinnerToProto(item))
	}
	return resp, nil
}

func (s *Server) GetBridgeScript(ctx context.Context, req *gamev1.GetBridgeScriptRequest) (*gamev1.GetBridgeScriptResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	result, err := s.svc.GetBridgeScript(ctx, req.GetMeta().GetAppCode())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.GetBridgeScriptResponse{
		Config: &gamev1.GameBridgeScriptConfig{
			BridgeScriptUrl:     result.URL,
			BridgeScriptVersion: result.Version,
			BridgeScriptSha256:  result.SHA256,
		},
		ServerTimeMs: result.ServerTimeMS,
	}, nil
}

func (s *Server) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest) (*gamev1.LaunchGameResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	result, err := s.svc.LaunchGame(ctx, gameservice.LaunchCommand{
		AppCode:       req.GetMeta().GetAppCode(),
		RequestID:     req.GetMeta().GetRequestId(),
		UserID:        req.GetUserId(),
		DisplayUserID: req.GetDisplayUserId(),
		GameID:        req.GetGameId(),
		Scene:         req.GetScene(),
		RoomID:        req.GetRoomId(),
		CountryID:     req.GetCountryId(),
		AccessToken:   req.GetAccessToken(),
		ProviderToken: req.GetProviderToken(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.LaunchGameResponse{
		SessionId:    result.SessionID,
		LaunchUrl:    result.LaunchURL,
		ExpiresAtMs:  result.ExpiresAtMS,
		Orientation:  result.Orientation,
		SafeHeight:   result.SafeHeight,
		ServerTimeMs: result.ServerTimeMS,
	}, nil
}

func (s *Server) GetDiceConfig(ctx context.Context, req *gamev1.GetDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	config, serverTimeMS, err := s.diceSvc.GetConfig(ctx, req.GetMeta().GetAppCode(), req.GetGameId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceConfigResponse{Config: diceConfigToProto(config), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) MatchDice(ctx context.Context, req *gamev1.MatchDiceRequest) (*gamev1.DiceMatchResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	match, serverTimeMS, err := s.diceSvc.Match(ctx, diceservice.MatchCommand{
		AppCode:    req.GetMeta().GetAppCode(),
		RequestID:  req.GetMeta().GetRequestId(),
		UserID:     req.GetUserId(),
		GameID:     req.GetGameId(),
		RoomID:     req.GetRoomId(),
		RegionID:   req.GetRegionId(),
		CountryID:  req.GetCountryId(),
		StakeCoin:  req.GetStakeCoin(),
		RPSGesture: req.GetRpsGesture(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) CreateDiceMatch(ctx context.Context, req *gamev1.CreateDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	// gRPC 层只把 proto 字段转成 command；人数、金额、游戏状态和钱包逻辑都留给 service 层验证。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	match, serverTimeMS, err := s.diceSvc.CreateMatch(ctx, diceservice.CreateMatchCommand{
		AppCode:    req.GetMeta().GetAppCode(),
		RequestID:  req.GetMeta().GetRequestId(),
		UserID:     req.GetUserId(),
		GameID:     req.GetGameId(),
		RoomID:     req.GetRoomId(),
		RegionID:   req.GetRegionId(),
		CountryID:  req.GetCountryId(),
		StakeCoin:  req.GetStakeCoin(),
		MinPlayers: req.GetMinPlayers(),
		MaxPlayers: req.GetMaxPlayers(),
		RPSGesture: req.GetRpsGesture(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) JoinDiceMatch(ctx context.Context, req *gamev1.JoinDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	// user_id 由 gateway 从登录态注入；transport 不允许用 actor_user_id 推导业务用户，避免调用方混淆身份字段。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	match, serverTimeMS, err := s.diceSvc.JoinMatch(ctx, diceservice.JoinMatchCommand{
		AppCode:    req.GetMeta().GetAppCode(),
		RequestID:  req.GetMeta().GetRequestId(),
		UserID:     req.GetUserId(),
		MatchID:    req.GetMatchId(),
		GameID:     req.GetGameId(),
		RPSGesture: req.GetRpsGesture(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) GetDiceMatch(ctx context.Context, req *gamev1.GetDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	// 查询接口仍要求 user_id，后续如果要做观战/隐私控制，可以在 service 层基于同一 command 加规则。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	match, serverTimeMS, err := s.diceSvc.GetMatch(ctx, diceservice.GetMatchCommand{
		AppCode: req.GetMeta().GetAppCode(),
		UserID:  req.GetUserId(),
		MatchID: req.GetMatchId(),
		GameID:  req.GetGameId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) RollDiceMatch(ctx context.Context, req *gamev1.RollDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	// roll 是唯一会触发钱包改账的骰子 RPC；gRPC 层不拆分扣款/派奖，避免客户端绕过局状态机。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	match, serverTimeMS, err := s.diceSvc.RollMatch(ctx, diceservice.RollMatchCommand{
		AppCode:    req.GetMeta().GetAppCode(),
		RequestID:  req.GetMeta().GetRequestId(),
		UserID:     req.GetUserId(),
		MatchID:    req.GetMatchId(),
		GameID:     req.GetGameId(),
		RPSGesture: req.GetRpsGesture(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) CancelDiceMatch(ctx context.Context, req *gamev1.CancelDiceMatchRequest) (*gamev1.DiceMatchResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	match, serverTimeMS, err := s.diceSvc.CancelMatch(ctx, diceservice.CancelMatchCommand{
		AppCode:   req.GetMeta().GetAppCode(),
		RequestID: req.GetMeta().GetRequestId(),
		UserID:    req.GetUserId(),
		MatchID:   req.GetMatchId(),
		GameID:    req.GetGameId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceMatchResponse{Match: diceMatchToProtoAt(match, serverTimeMS), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) GetRoomRPSConfig(ctx context.Context, req *gamev1.GetRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
	if s == nil || s.roomRPSSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
	}
	// 配置只按 meta.app_code 读取；App/Admin 都不能在 body 里传 app_code 读写其它租户配置。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	config, serverTimeMS, err := s.roomRPSSvc.GetConfig(ctx, req.GetMeta().GetAppCode())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.RoomRPSConfigResponse{Config: config, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) ListRoomRPSChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) (*gamev1.ListRoomRPSChallengesResponse, error) {
	if s == nil || s.roomRPSSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
	}
	// 列表参数完整透传给 service；room_id、状态、用户筛选、时间窗和游标必须在同一个事实源内判断。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, nextCursor, pageSize, serverTimeMS, err := s.roomRPSSvc.ListChallenges(ctx, req)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.ListRoomRPSChallengesResponse{Challenges: items, NextCursor: nextCursor, PageSize: pageSize, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) CreateRoomRPSChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
	if s == nil || s.roomRPSSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
	}
	// 发起人身份、房间、礼物和手势全部交给 service 原子校验；transport 不在这里复制状态规则。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	challenge, serverTimeMS, err := s.roomRPSSvc.CreateChallenge(ctx, req)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) AcceptRoomRPSChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
	if s == nil || s.roomRPSSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
	}
	// 应战入口只做 proto 到 service 的调用；pending 锁定、本人不能应战、超时收敛和胜负结算都在 service 内完成。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	challenge, serverTimeMS, err := s.roomRPSSvc.AcceptChallenge(ctx, req)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) GetRoomRPSChallenge(ctx context.Context, req *gamev1.GetRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
	if s == nil || s.roomRPSSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
	}
	// 详情是 IM 事件后的权威补拉接口；transport 不根据 user_id 推断展示内容，统一交给 service 返回事实。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	challenge, serverTimeMS, err := s.roomRPSSvc.GetChallenge(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) UpdateRoomRPSConfig(ctx context.Context, req *gamev1.UpdateRoomRPSConfigRequest) (*gamev1.RoomRPSConfigResponse, error) {
	if s == nil || s.roomRPSSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
	}
	// 后台写配置只落 room_rps 独立配置域；固定四档礼物、默认开启和倒计时默认值由 service 统一归一。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	config, serverTimeMS, err := s.roomRPSSvc.UpdateConfig(ctx, req.GetMeta().GetAppCode(), req.GetConfig())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.RoomRPSConfigResponse{Config: config, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) RetryRoomRPSSettlement(ctx context.Context, req *gamev1.RetryRoomRPSSettlementRequest) (*gamev1.RoomRPSChallengeResponse, error) {
	if s == nil || s.roomRPSSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
	}
	// 结算重试只能从 settlement_failed 进入；transport 不允许 admin 传目标状态，避免后台绕过状态机。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	challenge, serverTimeMS, err := s.roomRPSSvc.RetrySettlement(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) ExpireRoomRPSChallenge(ctx context.Context, req *gamev1.ExpireRoomRPSChallengeRequest) (*gamev1.RoomRPSChallengeResponse, error) {
	if s == nil || s.roomRPSSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room rps service is not configured"))
	}
	// 手动过期只允许 still-pending 且无人应战的挑战；服务端重查事实，不能相信后台按钮状态。
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	challenge, serverTimeMS, err := s.roomRPSSvc.ExpireChallenge(ctx, req.GetMeta().GetAppCode(), req.GetChallengeId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.RoomRPSChallengeResponse{Challenge: challenge, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	raw, contentType, err := s.svc.HandleCallback(ctx, req)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.CallbackResponse{RawBody: raw, ContentType: contentType}, nil
}

// ProcessLevelEventOutboxBatch 只由 cron-service 调用，批量接管并投递游戏消耗等级事件。
func (s *Server) ProcessLevelEventOutboxBatch(ctx context.Context, req *gamev1.CronBatchRequest) (*gamev1.CronBatchResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	claimed, processed, success, failure, hasMore, err := s.svc.ProcessLevelEventOutboxBatch(ctx, req.GetRunId(), req.GetWorkerId(), int(req.GetBatchSize()), durationFromMillis(req.GetLockTtlMs()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.CronBatchResponse{
		ClaimedCount:   claimed,
		ProcessedCount: processed,
		SuccessCount:   success,
		FailureCount:   failure,
		HasMore:        hasMore,
	}, nil
}

func (s *Server) ListPlatforms(ctx context.Context, req *gamev1.ListPlatformsRequest) (*gamev1.ListPlatformsResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, err := s.svcRepository().ListPlatforms(ctx, req.GetMeta().GetAppCode(), req.GetStatus())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListPlatformsResponse{ServerTimeMs: time.Now().UnixMilli(), Platforms: make([]*gamev1.GamePlatform, 0, len(items))}
	for _, item := range items {
		resp.Platforms = append(resp.Platforms, platformToProto(item))
	}
	return resp, nil
}

func (s *Server) UpsertPlatform(ctx context.Context, req *gamev1.UpsertPlatformRequest) (*gamev1.PlatformResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	platform := platformFromProto(req.GetPlatform())
	platform.AppCode = req.GetMeta().GetAppCode()
	normalized, err := normalizePlatform(platform)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	saved, err := s.svcRepository().UpsertPlatform(ctx, normalized)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.PlatformResponse{Platform: platformToProto(saved), ServerTimeMs: time.Now().UnixMilli()}, nil
}

func (s *Server) ListCatalog(ctx context.Context, req *gamev1.ListCatalogRequest) (*gamev1.ListCatalogResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, next, err := s.svcRepository().ListCatalog(ctx, gameservice.ListCatalogQuery{
		AppCode:      req.GetMeta().GetAppCode(),
		PlatformCode: req.GetPlatformCode(),
		Status:       req.GetStatus(),
		PageSize:     req.GetPageSize(),
		Cursor:       req.GetCursor(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListCatalogResponse{NextCursor: next, ServerTimeMs: time.Now().UnixMilli(), Games: make([]*gamev1.GameCatalogItem, 0, len(items))}
	for _, item := range items {
		resp.Games = append(resp.Games, catalogToProto(item))
	}
	return resp, nil
}

func (s *Server) UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	item := catalogFromProto(req.GetGame())
	item.AppCode = req.GetMeta().GetAppCode()
	normalized, err := normalizeCatalog(item)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	saved, err := s.svcRepository().UpsertCatalog(ctx, normalized)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.CatalogResponse{Game: catalogToProto(saved), ServerTimeMs: time.Now().UnixMilli()}, nil
}

func (s *Server) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	status := normalizeStatus(req.GetStatus())
	if status == "" {
		return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "status is invalid"))
	}
	item, err := s.svcRepository().SetGameStatus(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), status, time.Now().UnixMilli())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.CatalogResponse{Game: catalogToProto(item), ServerTimeMs: time.Now().UnixMilli()}, nil
}

func (s *Server) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	if err := s.svcRepository().DeleteCatalog(ctx, req.GetMeta().GetAppCode(), req.GetGameId()); err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DeleteCatalogResponse{ServerTimeMs: time.Now().UnixMilli()}, nil
}

func (s *Server) SetGameWhitelistEnabled(ctx context.Context, req *gamev1.SetGameWhitelistEnabledRequest) (*gamev1.CatalogResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	item, err := s.svcRepository().SetGameWhitelistEnabled(
		ctx,
		req.GetMeta().GetAppCode(),
		strings.TrimSpace(req.GetGameId()),
		req.GetEnabled(),
		time.Now().UnixMilli(),
	)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.CatalogResponse{Game: catalogToProto(item), ServerTimeMs: time.Now().UnixMilli()}, nil
}

func (s *Server) ListGameWhitelistUsers(ctx context.Context, req *gamev1.ListGameWhitelistUsersRequest) (*gamev1.ListGameWhitelistUsersResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, err := s.svcRepository().ListGameWhitelistUsers(ctx, req.GetMeta().GetAppCode(), strings.TrimSpace(req.GetGameId()), req.GetPageSize())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListGameWhitelistUsersResponse{Users: make([]*gamev1.GameWhitelistUser, 0, len(items)), ServerTimeMs: time.Now().UnixMilli()}
	for _, item := range items {
		resp.Users = append(resp.Users, whitelistUserToProto(item))
	}
	return resp, nil
}

func (s *Server) AddGameWhitelistUser(ctx context.Context, req *gamev1.AddGameWhitelistUserRequest) (*gamev1.GameWhitelistUserResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	item, err := s.svcRepository().AddGameWhitelistUser(ctx, gamedomain.WhitelistUser{
		AppCode:          req.GetMeta().GetAppCode(),
		GameID:           strings.TrimSpace(req.GetGameId()),
		UserID:           req.GetUserId(),
		CreatedByAdminID: req.GetMeta().GetActorUserId(),
		CreatedAtMS:      time.Now().UnixMilli(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.GameWhitelistUserResponse{User: whitelistUserToProto(item), ServerTimeMs: time.Now().UnixMilli()}, nil
}

func (s *Server) DeleteGameWhitelistUser(ctx context.Context, req *gamev1.DeleteGameWhitelistUserRequest) (*gamev1.DeleteGameWhitelistUserResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	if err := s.svcRepository().DeleteGameWhitelistUser(ctx, req.GetMeta().GetAppCode(), strings.TrimSpace(req.GetGameId()), req.GetUserId()); err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DeleteGameWhitelistUserResponse{ServerTimeMs: time.Now().UnixMilli()}, nil
}

func (s *Server) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, serverTimeMS, err := s.diceSvc.ListSelfGames(ctx, req.GetMeta().GetAppCode())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListSelfGamesResponse{ServerTimeMs: serverTimeMS, Games: make([]*gamev1.DiceConfig, 0, len(items))}
	for _, item := range items {
		resp.Games = append(resp.Games, diceConfigToProto(item))
	}
	return resp, nil
}

func (s *Server) UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	config := diceConfigFromProto(req.GetConfig())
	config.AppCode = req.GetMeta().GetAppCode()
	saved, serverTimeMS, err := s.diceSvc.UpdateConfig(ctx, config)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceConfigResponse{Config: diceConfigToProto(saved), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) GetSelfGameNewUserPolicy(ctx context.Context, req *gamev1.GetSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	policy, serverTimeMS, err := s.diceSvc.GetSelfGameNewUserPolicy(ctx, req.GetMeta().GetAppCode(), req.GetGameId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.SelfGameNewUserPolicyResponse{Policy: selfGameNewUserPolicyToProto(policy), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) UpdateSelfGameNewUserPolicy(ctx context.Context, req *gamev1.UpdateSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	policy := selfGameNewUserPolicyFromProto(req.GetPolicy())
	policy.AppCode = req.GetMeta().GetAppCode()
	saved, serverTimeMS, err := s.diceSvc.UpdateSelfGameNewUserPolicy(ctx, policy)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.SelfGameNewUserPolicyResponse{Policy: selfGameNewUserPolicyToProto(saved), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) ListSelfGameStakePools(ctx context.Context, req *gamev1.ListSelfGameStakePoolsRequest) (*gamev1.ListSelfGameStakePoolsResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	pools, serverTimeMS, err := s.diceSvc.ListSelfGameStakePools(ctx, req.GetMeta().GetAppCode(), req.GetGameId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListSelfGameStakePoolsResponse{ServerTimeMs: serverTimeMS, Pools: make([]*gamev1.SelfGameStakePool, 0, len(pools))}
	for _, pool := range pools {
		resp.Pools = append(resp.Pools, selfGameStakePoolToProto(pool))
	}
	return resp, nil
}

func (s *Server) UpdateSelfGameStakePool(ctx context.Context, req *gamev1.UpdateSelfGameStakePoolRequest) (*gamev1.SelfGameStakePoolResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	pool := selfGameStakePoolFromProto(req.GetPool())
	pool.AppCode = req.GetMeta().GetAppCode()
	saved, serverTimeMS, err := s.diceSvc.UpdateSelfGameStakePool(ctx, pool)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.SelfGameStakePoolResponse{Pool: selfGameStakePoolToProto(saved), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) AdjustSelfGameStakePool(ctx context.Context, req *gamev1.AdjustSelfGameStakePoolRequest) (*gamev1.AdjustSelfGameStakePoolResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	adjustment, config, serverTimeMS, err := s.diceSvc.AdjustPool(ctx, dicedomain.PoolAdjustment{
		AppCode:    req.GetMeta().GetAppCode(),
		GameID:     req.GetGameId(),
		StakeCoin:  req.GetStakeCoin(),
		Direction:  req.GetDirection(),
		AmountCoin: req.GetAmountCoin(),
		Reason:     req.GetReason(),
		CreatedBy:  req.GetMeta().GetActorUserId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.AdjustSelfGameStakePoolResponse{Adjustment: dicePoolAdjustmentToProto(adjustment), Config: diceConfigToProto(config), ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, next, serverTimeMS, err := s.diceSvc.ListRobots(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetStatus(), req.GetPageSize(), req.GetCursor())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.ListDiceRobotsResponse{NextCursor: next, ServerTimeMs: serverTimeMS, Robots: make([]*gamev1.DiceRobot, 0, len(items))}
	for _, item := range items {
		resp.Robots = append(resp.Robots, diceRobotToProto(item))
	}
	return resp, nil
}

func (s *Server) RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	items, serverTimeMS, err := s.diceSvc.RegisterRobots(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserIds(), req.GetMeta().GetActorUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &gamev1.RegisterDiceRobotsResponse{ServerTimeMs: serverTimeMS, Robots: make([]*gamev1.DiceRobot, 0, len(items))}
	for _, item := range items {
		resp.Robots = append(resp.Robots, diceRobotToProto(item))
	}
	return resp, nil
}

func (s *Server) SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	item, serverTimeMS, err := s.diceSvc.SetRobotStatus(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserId(), req.GetStatus())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DiceRobotResponse{Robot: diceRobotToProto(item), ServerTimeMs: serverTimeMS}, nil
}

// DeleteDiceRobot 只对管理端暴露机器人池删除能力，删除范围由 app_code、game_id、user_id 三元组限定。
func (s *Server) DeleteDiceRobot(ctx context.Context, req *gamev1.DeleteDiceRobotRequest) (*gamev1.DeleteDiceRobotResponse, error) {
	if s == nil || s.diceSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "dice service is not configured"))
	}
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	serverTimeMS, err := s.diceSvc.DeleteRobot(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), req.GetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &gamev1.DeleteDiceRobotResponse{Deleted: true, ServerTimeMs: serverTimeMS}, nil
}

func (s *Server) svcRepository() gameservice.Repository {
	if s == nil || s.svc == nil || s.svc.Repository() == nil {
		return unavailableRepository{}
	}
	return s.svc.Repository()
}

type unavailableRepository struct{}

func (unavailableRepository) Ping(context.Context) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListGames(context.Context, gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
	return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListRecentGames(context.Context, gameservice.ListRecentGamesQuery) ([]gamedomain.AppGame, error) {
	return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
	return gamedomain.LaunchableGame{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) GetPlatform(context.Context, string, string) (gamedomain.Platform, error) {
	return gamedomain.Platform{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CreateLaunchSession(context.Context, gamedomain.LaunchSession) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) GetLaunchSessionByToken(context.Context, string, string) (gamedomain.LaunchSession, error) {
	return gamedomain.LaunchSession{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) GetLaunchSessionByTokenScope(context.Context, string, string, string, string) (gamedomain.LaunchSession, error) {
	return gamedomain.LaunchSession{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CreateGameOrder(context.Context, gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
	return gamedomain.GameOrder{}, false, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkOrderSucceeded(context.Context, string, string, string, int64, int64) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkOrderFailed(context.Context, string, string, string, string, string, int64) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CreateRepairOrder(context.Context, gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) {
	return gamedomain.RepairOrder{}, false, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ClaimPendingLevelEvents(context.Context, string, int64, time.Duration, int) ([]gamedomain.LevelEventOutbox, error) {
	return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkLevelEventDelivered(context.Context, string, int64) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkLevelEventFailed(context.Context, string, string, int64, int64) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListPlatforms(context.Context, string, string) ([]gamedomain.Platform, error) {
	return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) UpsertPlatform(context.Context, gamedomain.Platform) (gamedomain.Platform, error) {
	return gamedomain.Platform{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListCatalog(context.Context, gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
	return nil, "", xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) UpsertCatalog(context.Context, gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
	return gamedomain.CatalogItem{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) SetGameStatus(context.Context, string, string, string, int64) (gamedomain.CatalogItem, error) {
	return gamedomain.CatalogItem{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) DeleteCatalog(context.Context, string, string) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CanUserAccessGame(context.Context, string, string, int64) (bool, error) {
	return false, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) SetGameWhitelistEnabled(context.Context, string, string, bool, int64) (gamedomain.CatalogItem, error) {
	return gamedomain.CatalogItem{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListGameWhitelistUsers(context.Context, string, string, int32) ([]gamedomain.WhitelistUser, error) {
	return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) AddGameWhitelistUser(context.Context, gamedomain.WhitelistUser) (gamedomain.WhitelistUser, error) {
	return gamedomain.WhitelistUser{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) DeleteGameWhitelistUser(context.Context, string, string, int64) error {
	return xerr.New(xerr.Unavailable, "game repository is not configured")
}

func appGameToProto(item gamedomain.AppGame) *gamev1.AppGame {
	return &gamev1.AppGame{
		GameId:       item.GameID,
		PlatformCode: item.PlatformCode,
		NameKey:      item.NameKey,
		Name:         item.Name,
		IconUrl:      item.IconURL,
		CoverUrl:     item.CoverURL,
		Category:     item.Category,
		LaunchMode:   item.LaunchMode,
		Orientation:  item.Orientation,
		SafeHeight:   item.SafeHeight,
		MinCoin:      item.MinCoin,
		Enabled:      item.Enabled,
		Maintenance:  item.Maintenance,
		SortOrder:    item.SortOrder,
	}
}

func diceMatchToProto(match dicedomain.Match) *gamev1.DiceMatch {
	return diceMatchToProtoAt(match, time.Now().UnixMilli())
}

func diceMatchToProtoAt(match dicedomain.Match, nowMS int64) *gamev1.DiceMatch {
	participants := make([]*gamev1.DiceParticipant, 0, len(match.Participants))
	for _, participant := range match.Participants {
		participants = append(participants, diceParticipantToProto(participant))
	}
	phase, phaseDeadlineMS := dicePhase(match, nowMS)
	// proto 只暴露客户端需要的局事实；订单 ID 和 provider 快照留在 MySQL，避免把账务内部键泄漏到 H5。
	return &gamev1.DiceMatch{
		AppCode:         match.AppCode,
		MatchId:         match.MatchID,
		GameId:          match.GameID,
		RoomId:          match.RoomID,
		RegionId:        match.RegionID,
		CountryId:       match.CountryID,
		MinPlayers:      match.MinPlayers,
		MaxPlayers:      match.MaxPlayers,
		CurrentPlayers:  match.CurrentPlayers,
		StakeCoin:       match.StakeCoin,
		RoundNo:         match.RoundNo,
		Status:          match.Status,
		Result:          match.Result,
		Participants:    participants,
		JoinDeadlineMs:  match.JoinDeadlineMS,
		CreatedAtMs:     match.CreatedAtMS,
		UpdatedAtMs:     match.UpdatedAtMS,
		SettledAtMs:     match.SettledAtMS,
		Phase:           phase,
		PhaseDeadlineMs: phaseDeadlineMS,
		FeeBps:          match.FeeBPS,
		PoolBps:         match.PoolBPS,
		MatchMode:       match.MatchMode,
		ForcedResult:    match.ForcedResult,
		ReadyAtMs:       match.ReadyAtMS,
		CanceledAtMs:    match.CanceledAtMS,
		PoolDeltaCoin:   match.PoolDeltaCoin,
	}
}

func diceParticipantToProto(participant dicedomain.Participant) *gamev1.DiceParticipant {
	// balance_after 是最近一次钱包操作后的快照，供 H5 结算弹层刷新余额；不是局内可计算字段。
	return &gamev1.DiceParticipant{
		UserId:          participant.UserID,
		SeatNo:          participant.SeatNo,
		Status:          participant.Status,
		StakeCoin:       participant.StakeCoin,
		DicePoints:      participant.DicePoints,
		Result:          participant.Result,
		PayoutCoin:      participant.PayoutCoin,
		BalanceAfter:    participant.BalanceAfter,
		JoinedAtMs:      participant.JoinedAtMS,
		UpdatedAtMs:     participant.UpdatedAtMS,
		ParticipantType: participant.ParticipantType,
		IsRobot:         participant.ParticipantType == dicedomain.ParticipantTypeRobot,
		RpsGesture:      participant.RPSGesture,
	}
}

func diceConfigToProto(config dicedomain.Config) *gamev1.DiceConfig {
	options := make([]*gamev1.DiceStakeOption, 0, len(config.StakeOptions))
	for _, option := range config.StakeOptions {
		options = append(options, &gamev1.DiceStakeOption{StakeCoin: option.StakeCoin, Enabled: option.Enabled, SortOrder: option.SortOrder})
	}
	return &gamev1.DiceConfig{
		AppCode:          config.AppCode,
		GameId:           config.GameID,
		Status:           config.Status,
		StakeOptions:     options,
		FeeBps:           config.FeeBPS,
		PoolBps:          config.PoolBPS,
		MinPlayers:       config.MinPlayers,
		MaxPlayers:       config.MaxPlayers,
		RobotEnabled:     config.RobotEnabled,
		RobotMatchWaitMs: config.RobotMatchWaitMS,
		PoolBalanceCoin:  config.PoolBalanceCoin,
		CreatedAtMs:      config.CreatedAtMS,
		UpdatedAtMs:      config.UpdatedAtMS,
	}
}

func diceConfigFromProto(config *gamev1.DiceConfig) dicedomain.Config {
	if config == nil {
		return dicedomain.Config{}
	}
	options := make([]dicedomain.StakeOption, 0, len(config.GetStakeOptions()))
	for _, option := range config.GetStakeOptions() {
		options = append(options, dicedomain.StakeOption{StakeCoin: option.GetStakeCoin(), Enabled: option.GetEnabled(), SortOrder: option.GetSortOrder()})
	}
	return dicedomain.Config{
		AppCode:          config.GetAppCode(),
		GameID:           config.GetGameId(),
		Status:           config.GetStatus(),
		StakeOptions:     options,
		FeeBPS:           config.GetFeeBps(),
		PoolBPS:          config.GetPoolBps(),
		MinPlayers:       config.GetMinPlayers(),
		MaxPlayers:       config.GetMaxPlayers(),
		RobotEnabled:     config.GetRobotEnabled(),
		RobotMatchWaitMS: config.GetRobotMatchWaitMs(),
	}
}

func selfGameNewUserPolicyToProto(policy selfgame.NewUserPolicy) *gamev1.SelfGameNewUserPolicy {
	return &gamev1.SelfGameNewUserPolicy{
		AppCode:                         policy.AppCode,
		GameId:                          policy.GameID,
		Enabled:                         policy.Enabled,
		ProtectionRounds:                policy.ProtectionRounds,
		ProtectionHours:                 policy.ProtectionHours,
		MaxStakeCoin:                    policy.MaxStakeCoin,
		LifetimeSubsidyQuotaCoin:        policy.LifetimeSubsidyQuotaCoin,
		Day1SubsidyQuotaCoin:            policy.Day1SubsidyQuotaCoin,
		SingleRoundSubsidyCapCoin:       policy.SingleRoundSubsidyCap,
		StrategyLevel:                   policy.StrategyLevel,
		LoseStreakProtectionEnabled:     policy.LoseStreakProtectionEnabled,
		LoseStreakTrigger:               policy.LoseStreakTrigger,
		NormalPhaseTargetWinRatePercent: policy.NormalPhaseTargetWinRatePercent,
		BlackPoolRobotForceWinEnabled:   policy.BlackPoolRobotForceWinEnabled,
		CreatedAtMs:                     policy.CreatedAtMS,
		UpdatedAtMs:                     policy.UpdatedAtMS,
	}
}

func selfGameNewUserPolicyFromProto(policy *gamev1.SelfGameNewUserPolicy) selfgame.NewUserPolicy {
	if policy == nil {
		return selfgame.NewUserPolicy{}
	}
	return selfgame.NewUserPolicy{
		AppCode:                         policy.GetAppCode(),
		GameID:                          policy.GetGameId(),
		Enabled:                         policy.GetEnabled(),
		ProtectionRounds:                policy.GetProtectionRounds(),
		ProtectionHours:                 policy.GetProtectionHours(),
		MaxStakeCoin:                    policy.GetMaxStakeCoin(),
		LifetimeSubsidyQuotaCoin:        policy.GetLifetimeSubsidyQuotaCoin(),
		Day1SubsidyQuotaCoin:            policy.GetDay1SubsidyQuotaCoin(),
		SingleRoundSubsidyCap:           policy.GetSingleRoundSubsidyCapCoin(),
		StrategyLevel:                   policy.GetStrategyLevel(),
		LoseStreakProtectionEnabled:     policy.GetLoseStreakProtectionEnabled(),
		LoseStreakTrigger:               policy.GetLoseStreakTrigger(),
		NormalPhaseTargetWinRatePercent: policy.GetNormalPhaseTargetWinRatePercent(),
		BlackPoolRobotForceWinEnabled:   policy.GetBlackPoolRobotForceWinEnabled(),
		CreatedAtMS:                     policy.GetCreatedAtMs(),
		UpdatedAtMS:                     policy.GetUpdatedAtMs(),
	}
}

func selfGameStakePoolToProto(view selfgame.StakePoolView) *gamev1.SelfGameStakePool {
	return &gamev1.SelfGameStakePool{
		AppCode:            view.AppCode,
		GameId:             view.GameID,
		StakeCoin:          view.StakeCoin,
		BalanceCoin:        view.BalanceCoin,
		ReservedCoin:       view.ReservedCoin,
		AvailableCoin:      view.AvailableCoin(),
		TargetBalanceCoin:  view.TargetBalanceCoin,
		SafeBalanceCoin:    view.SafeBalanceCoin,
		WarningBalanceCoin: view.WarningBalanceCoin,
		DangerBalanceCoin:  view.DangerBalanceCoin,
		Status:             view.Status,
		PoolLevel:          view.PoolLevel,
		RequiredPoolCoin:   view.RequiredPoolCoin,
		HumanWinCapacity:   view.HumanWinCapacity,
		BotMatchEnabled:    view.BotMatchEnabled,
		BlackReason:        view.BlackReason,
		CreatedAtMs:        view.CreatedAtMS,
		UpdatedAtMs:        view.UpdatedAtMS,
	}
}

func selfGameStakePoolFromProto(pool *gamev1.SelfGameStakePool) selfgame.StakePool {
	if pool == nil {
		return selfgame.StakePool{}
	}
	return selfgame.StakePool{
		AppCode:            pool.GetAppCode(),
		GameID:             pool.GetGameId(),
		StakeCoin:          pool.GetStakeCoin(),
		BalanceCoin:        pool.GetBalanceCoin(),
		ReservedCoin:       pool.GetReservedCoin(),
		TargetBalanceCoin:  pool.GetTargetBalanceCoin(),
		SafeBalanceCoin:    pool.GetSafeBalanceCoin(),
		WarningBalanceCoin: pool.GetWarningBalanceCoin(),
		DangerBalanceCoin:  pool.GetDangerBalanceCoin(),
		Status:             pool.GetStatus(),
		CreatedAtMS:        pool.GetCreatedAtMs(),
		UpdatedAtMS:        pool.GetUpdatedAtMs(),
	}
}

func dicePoolAdjustmentToProto(adjustment dicedomain.PoolAdjustment) *gamev1.DicePoolAdjustment {
	return &gamev1.DicePoolAdjustment{
		AdjustmentId: adjustment.AdjustmentID,
		GameId:       adjustment.GameID,
		AmountCoin:   adjustment.AmountCoin,
		Direction:    adjustment.Direction,
		Reason:       adjustment.Reason,
		BalanceAfter: adjustment.BalanceAfter,
		CreatedAtMs:  adjustment.CreatedAtMS,
		StakeCoin:    adjustment.StakeCoin,
	}
}

func diceRobotToProto(robot dicedomain.Robot) *gamev1.DiceRobot {
	return &gamev1.DiceRobot{
		AppCode:          robot.AppCode,
		GameId:           robot.GameID,
		UserId:           robot.UserID,
		Status:           robot.Status,
		CreatedByAdminId: robot.CreatedByAdminID,
		CreatedAtMs:      robot.CreatedAtMS,
		UpdatedAtMs:      robot.UpdatedAtMS,
	}
}

func exploreWinnerToProto(item dicedomain.ExploreWinner) *gamev1.ExploreWinner {
	return &gamev1.ExploreWinner{
		WinId:       item.WinID,
		GameCode:    item.GameCode,
		GameId:      item.GameID,
		UserId:      item.UserID,
		DisplayName: item.DisplayName,
		AvatarUrl:   item.AvatarURL,
		CoinAmount:  item.CoinAmount,
		WonAtMs:     item.WonAtMS,
	}
}

func dicePhase(match dicedomain.Match, nowMS int64) (string, int64) {
	switch match.Status {
	case dicedomain.MatchStatusCanceled:
		return dicedomain.MatchPhaseCanceled, match.CanceledAtMS
	case dicedomain.MatchStatusFailed:
		return dicedomain.MatchPhaseFailed, match.UpdatedAtMS
	case dicedomain.MatchStatusSettled:
		return dicedomain.MatchPhaseSettled, match.SettledAtMS + dicedomain.SettlementShowMillis
	}
	if match.Result == dicedomain.ParticipantResultDraw {
		// 和局不是终局，只给客户端一个 2 秒比点展示窗口，窗口结束后 H5 可以继续调用 roll。
		return dicedomain.MatchPhaseComparing, match.UpdatedAtMS + dicedomain.DrawRerollMillis
	}
	if match.ReadyAtMS <= 0 {
		return dicedomain.MatchPhaseWaiting, match.JoinDeadlineMS
	}
	countdownEnd := match.ReadyAtMS + dicedomain.CountdownMillis
	rollingEnd := countdownEnd + dicedomain.RollingMillis
	comparingEnd := rollingEnd + dicedomain.ComparingMillis
	if nowMS < countdownEnd {
		return dicedomain.MatchPhaseCountdown, countdownEnd
	}
	if nowMS < rollingEnd {
		return dicedomain.MatchPhaseRolling, rollingEnd
	}
	return dicedomain.MatchPhaseComparing, comparingEnd
}

func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform {
	return &gamev1.GamePlatform{
		AppCode:      item.AppCode,
		PlatformCode: item.PlatformCode,
		PlatformName: item.PlatformName,
		Status:       item.Status,
		ApiBaseUrl:   item.APIBaseURL,
		SortOrder:    item.SortOrder,
		CreatedAtMs:  item.CreatedAtMS,
		UpdatedAtMs:  item.UpdatedAtMS,
		AdapterType:  item.AdapterType,
		// admin 后台要把当前厂商 key 提供给运营/对接人员核对；更新接口仍以空值表示保留旧密钥。
		CallbackSecret:      item.CallbackSecretCiphertext,
		CallbackSecretSet:   item.CallbackSecretConfigured,
		CallbackIpWhitelist: item.CallbackIPWhitelist,
		AdapterConfigJson:   item.AdapterConfigJSON,
	}
}

func platformFromProto(item *gamev1.GamePlatform) gamedomain.Platform {
	if item == nil {
		return gamedomain.Platform{}
	}
	return gamedomain.Platform{
		AppCode:      item.GetAppCode(),
		PlatformCode: item.GetPlatformCode(),
		PlatformName: item.GetPlatformName(),
		Status:       item.GetStatus(),
		APIBaseURL:   item.GetApiBaseUrl(),
		AdapterType:  item.GetAdapterType(),
		// 更新接口只有在 callback_secret 非空时才会覆盖旧密钥，空值表示保留原密钥。
		CallbackSecretCiphertext: item.GetCallbackSecret(),
		CallbackSecretConfigured: item.GetCallbackSecretSet(),
		CallbackIPWhitelist:      item.GetCallbackIpWhitelist(),
		AdapterConfigJSON:        item.GetAdapterConfigJson(),
		SortOrder:                item.GetSortOrder(),
	}
}

func catalogToProto(item gamedomain.CatalogItem) *gamev1.GameCatalogItem {
	return &gamev1.GameCatalogItem{
		AppCode:          item.AppCode,
		GameId:           item.GameID,
		PlatformCode:     item.PlatformCode,
		ProviderGameId:   item.ProviderGameID,
		GameName:         item.GameName,
		Category:         item.Category,
		IconUrl:          item.IconURL,
		CoverUrl:         item.CoverURL,
		LaunchMode:       item.LaunchMode,
		Orientation:      item.Orientation,
		SafeHeight:       item.SafeHeight,
		MinCoin:          item.MinCoin,
		Status:           item.Status,
		SortOrder:        item.SortOrder,
		Tags:             item.Tags,
		CreatedAtMs:      item.CreatedAtMS,
		UpdatedAtMs:      item.UpdatedAtMS,
		WhitelistEnabled: item.WhitelistEnabled,
	}
}

func whitelistUserToProto(item gamedomain.WhitelistUser) *gamev1.GameWhitelistUser {
	return &gamev1.GameWhitelistUser{
		AppCode:          item.AppCode,
		GameId:           item.GameID,
		UserId:           item.UserID,
		CreatedByAdminId: item.CreatedByAdminID,
		CreatedAtMs:      item.CreatedAtMS,
	}
}

func catalogFromProto(item *gamev1.GameCatalogItem) gamedomain.CatalogItem {
	if item == nil {
		return gamedomain.CatalogItem{}
	}
	return gamedomain.CatalogItem{
		AppCode:          item.GetAppCode(),
		GameID:           item.GetGameId(),
		PlatformCode:     item.GetPlatformCode(),
		ProviderGameID:   item.GetProviderGameId(),
		GameName:         item.GetGameName(),
		Category:         item.GetCategory(),
		IconURL:          item.GetIconUrl(),
		CoverURL:         item.GetCoverUrl(),
		LaunchMode:       item.GetLaunchMode(),
		Orientation:      item.GetOrientation(),
		SafeHeight:       item.GetSafeHeight(),
		MinCoin:          item.GetMinCoin(),
		Status:           item.GetStatus(),
		SortOrder:        item.GetSortOrder(),
		Tags:             item.GetTags(),
		WhitelistEnabled: item.GetWhitelistEnabled(),
	}
}

func normalizePlatform(item gamedomain.Platform) (gamedomain.Platform, error) {
	// gRPC 边界只做通用字段清洗，厂商 JSON 的具体语义留给 adapter 解析。
	item.PlatformCode = strings.TrimSpace(item.PlatformCode)
	item.PlatformName = strings.TrimSpace(item.PlatformName)
	item.Status = normalizeStatus(item.Status)
	item.APIBaseURL = strings.TrimSpace(item.APIBaseURL)
	item.AdapterType = normalizeAdapterType(item.AdapterType)
	item.CallbackSecretCiphertext = strings.TrimSpace(item.CallbackSecretCiphertext)
	item.CallbackIPWhitelist = compactStrings(item.CallbackIPWhitelist)
	item.AdapterConfigJSON = strings.TrimSpace(item.AdapterConfigJSON)
	if item.AdapterConfigJSON == "" {
		item.AdapterConfigJSON = "{}"
	}
	if item.PlatformCode == "" || item.PlatformName == "" || item.Status == "" {
		return gamedomain.Platform{}, xerr.New(xerr.InvalidArgument, "platform is incomplete")
	}
	if item.AdapterType == "" {
		return gamedomain.Platform{}, xerr.New(xerr.InvalidArgument, "platform adapter_type is invalid")
	}
	return item, nil
}

func normalizeCatalog(item gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
	item.GameID = strings.TrimSpace(item.GameID)
	item.PlatformCode = strings.TrimSpace(item.PlatformCode)
	item.ProviderGameID = strings.TrimSpace(item.ProviderGameID)
	item.GameName = strings.TrimSpace(item.GameName)
	item.Status = normalizeStatus(item.Status)
	if item.LaunchMode == "" {
		item.LaunchMode = gamedomain.LaunchModeFullScreen
	}
	if item.Orientation == "" {
		item.Orientation = "portrait"
	}
	if item.SafeHeight < 0 {
		item.SafeHeight = 0
	}
	if item.GameID == "" || item.PlatformCode == "" || item.ProviderGameID == "" || item.GameName == "" || item.Status == "" {
		return gamedomain.CatalogItem{}, xerr.New(xerr.InvalidArgument, "game is incomplete")
	}
	return item, nil
}

func normalizeStatus(status string) string {
	switch strings.ToLower(strings.TrimSpace(status)) {
	case gamedomain.StatusActive:
		return gamedomain.StatusActive
	case gamedomain.StatusMaintenance:
		return gamedomain.StatusMaintenance
	case gamedomain.StatusDisabled:
		return gamedomain.StatusDisabled
	default:
		return ""
	}
}

func normalizeAdapterType(adapterType string) string {
	// 允许的 adapter 必须在这里登记，后台传错值时直接拒绝，避免落库后回调走错协议。
	switch strings.ToLower(strings.TrimSpace(adapterType)) {
	case gamedomain.AdapterYomiV4:
		return gamedomain.AdapterYomiV4
	case gamedomain.AdapterLeaderCCV1:
		return gamedomain.AdapterLeaderCCV1
	case gamedomain.AdapterZeeOneV1:
		return gamedomain.AdapterZeeOneV1
	case gamedomain.AdapterBaishunV1:
		return gamedomain.AdapterBaishunV1
	case gamedomain.AdapterVivaGamesV1:
		return gamedomain.AdapterVivaGamesV1
	case gamedomain.AdapterReyouV1:
		return gamedomain.AdapterReyouV1
	case gamedomain.AdapterZGameV1:
		return gamedomain.AdapterZGameV1
	case gamedomain.AdapterAMGV1:
		return gamedomain.AdapterAMGV1
	default:
		return ""
	}
}

func compactStrings(values []string) []string {
	out := make([]string, 0, len(values))
	seen := make(map[string]struct{}, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}

func durationFromMillis(value int64) time.Duration {
	if value <= 0 {
		return 0
	}
	return time.Duration(value) * time.Millisecond
}
