package dice

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"math/big"
	"sort"
	"strings"
	"time"

	walletv1 "hyapp.local/api/proto/wallet/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"
)

// Repository 是骰子用例层需要的持久化边界；局表只保存游戏事实，金币流水仍复用 game_orders。
type Repository interface {
	GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error)
	CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error)
	MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error
	MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error

	CreateDiceMatch(ctx context.Context, match dicedomain.Match) (dicedomain.Match, error)
	JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, rpsGesture string, nowMs int64) (dicedomain.Match, error)
	GetDiceMatch(ctx context.Context, appCode string, matchID string) (dicedomain.Match, error)
	ClaimDiceMatchForRoll(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error)
	SaveDiceRolls(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error)
	SaveDiceDrawForReroll(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error)
	MarkDiceParticipantDebitSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
	MarkDiceParticipantDebitFailed(ctx context.Context, appCode string, matchID string, userID int64, orderID string, nowMs int64) error
	MarkDiceParticipantPayoutSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
	MarkDiceParticipantRefundSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
	MarkDiceMatchSettled(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error
	MarkDiceMatchFailed(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error
	GetDiceConfig(ctx context.Context, appCode string, gameID string) (dicedomain.Config, error)
	ListSelfGameConfigs(ctx context.Context, appCode string) ([]dicedomain.Config, error)
	UpsertDiceConfig(ctx context.Context, config dicedomain.Config, nowMs int64) (dicedomain.Config, error)
	FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, rpsGesture string, nowMs int64) (dicedomain.Match, error)
	JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, rpsGesture string, nowMs int64) (dicedomain.Match, error)
	CancelDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error)
	PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, nowMs int64) (dicedomain.Robot, error)
	AdjustSelfGameStakePool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error)
	GetSelfGameStakePool(ctx context.Context, appCode string, gameID string, stakeCoin int64, nowMs int64) (selfgame.StakePool, error)
	ListSelfGameStakePools(ctx context.Context, appCode string, gameID string, nowMs int64) ([]selfgame.StakePool, error)
	UpsertSelfGameStakePoolConfig(ctx context.Context, pool selfgame.StakePool, nowMs int64) (selfgame.StakePool, error)
	GetSelfGameNewUserPolicy(ctx context.Context, appCode string, gameID string, nowMs int64) (selfgame.NewUserPolicy, error)
	UpsertSelfGameNewUserPolicy(ctx context.Context, policy selfgame.NewUserPolicy, nowMs int64) (selfgame.NewUserPolicy, error)
	GetSelfGameProtectionState(ctx context.Context, appCode string, gameID string, userID int64, nowMs int64) (selfgame.ProtectionState, error)
	GetSelfGameRiskResult(ctx context.Context, appCode string, gameID string, userID int64, nowMs int64) (selfgame.RiskResult, error)
	GetSelfGameUserStats(ctx context.Context, appCode string, gameID string, userID int64, nowMs int64) (selfgame.UserStats, error)
	RecordSelfGameStrategyLog(ctx context.Context, log selfgame.StrategyLog) error
	ConsumeSelfGameProtection(ctx context.Context, event selfgame.ProtectionEvent) (selfgame.ProtectionState, bool, error)
	ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error)
	RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error)
	SetDiceRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMs int64) (dicedomain.Robot, error)
	DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error
	RecordExploreWinner(ctx context.Context, winner dicedomain.ExploreWinner) error
	ListExploreWinners(ctx context.Context, appCode string, pageSize int32) ([]dicedomain.ExploreWinner, error)
}

// RobotRegistry 是骰子匹配依赖的机器人池边界；生产环境由 robot-service 客户端实现，单测仍可用仓储假实现保持结算逻辑聚焦。
type RobotRegistry interface {
	PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, excludeUserIDs []int64, nowMs int64) (dicedomain.Robot, error)
	ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error)
	RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error)
	SetDiceRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMs int64) (dicedomain.Robot, error)
	DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error
}

// WalletClient 是 game-service 调 wallet-service 的游戏改账入口。
type WalletClient interface {
	ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
	GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
}

type Config struct {
	JoinTTL    time.Duration
	FeeBPS     int64
	MaxPlayers int32
}

type Service struct {
	config     Config
	repository Repository
	robots     RobotRegistry
	wallet     WalletClient
	// now/randomInt 作为依赖注入点，保证测试能锁定时间和骰子点数，不需要碰全局随机源。
	now       func() time.Time
	randomInt func(max int) (int, error)
}

type Option func(*Service)

type legacyRobotRegistry interface {
	PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, nowMs int64) (dicedomain.Robot, error)
	ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error)
	RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error)
	SetDiceRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMs int64) (dicedomain.Robot, error)
	DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error
}

type legacyRobotRegistryAdapter struct {
	registry legacyRobotRegistry
}

func (a legacyRobotRegistryAdapter) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, _ []int64, nowMs int64) (dicedomain.Robot, error) {
	return a.registry.PickActiveDiceRobot(ctx, appCode, gameID, matchID, nowMs)
}

func (a legacyRobotRegistryAdapter) ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error) {
	return a.registry.ListDiceRobots(ctx, appCode, gameID, status, pageSize, cursor)
}

func (a legacyRobotRegistryAdapter) RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error) {
	return a.registry.RegisterDiceRobots(ctx, appCode, gameID, userIDs, adminID, nowMs)
}

func (a legacyRobotRegistryAdapter) SetDiceRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMs int64) (dicedomain.Robot, error) {
	return a.registry.SetDiceRobotStatus(ctx, appCode, gameID, userID, status, nowMs)
}

func (a legacyRobotRegistryAdapter) DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error {
	return a.registry.DeleteDiceRobot(ctx, appCode, gameID, userID)
}

// WithRobotRegistry 把机器人池从 game-service 仓储切到 robot-service 客户端；未传时默认复用 repository，保证旧单测和本地最小构造可继续运行。
func WithRobotRegistry(registry RobotRegistry) Option {
	return func(s *Service) {
		if registry != nil {
			s.robots = registry
		}
	}
}

type CreateMatchCommand struct {
	AppCode    string
	RequestID  string
	UserID     int64
	GameID     string
	RoomID     string
	RegionID   int64
	CountryID  int64
	StakeCoin  int64
	MinPlayers int32
	MaxPlayers int32
	RPSGesture string
}

type JoinMatchCommand struct {
	AppCode    string
	RequestID  string
	UserID     int64
	MatchID    string
	GameID     string
	RPSGesture string
}

type GetMatchCommand struct {
	AppCode string
	UserID  int64
	MatchID string
	GameID  string
}

type RollMatchCommand struct {
	AppCode    string
	RequestID  string
	UserID     int64
	MatchID    string
	GameID     string
	RPSGesture string
}

type MatchCommand struct {
	AppCode    string
	RequestID  string
	UserID     int64
	GameID     string
	RoomID     string
	RegionID   int64
	CountryID  int64
	StakeCoin  int64
	RPSGesture string
}

type CancelMatchCommand struct {
	AppCode   string
	RequestID string
	UserID    int64
	MatchID   string
	GameID    string
}

// New 创建骰子游戏用例层；随机数、时间和费率都集中在这里，便于测试和后续按房间配置扩展。
func New(config Config, repository Repository, wallet WalletClient, options ...Option) *Service {
	// 默认值在构造阶段一次性收口，后面的业务分支只看规范化后的配置，避免每个入口重复判断。
	if config.JoinTTL <= 0 {
		config.JoinTTL = time.Duration(dicedomain.DefaultJoinTTLMillis) * time.Millisecond
	}
	if config.FeeBPS <= 0 {
		config.FeeBPS = dicedomain.DefaultFeeBPS
	}
	if config.MaxPlayers <= 0 || config.MaxPlayers > dicedomain.HardMaxPlayers {
		config.MaxPlayers = dicedomain.HardMaxPlayers
	}
	svc := &Service{
		config:     config,
		repository: repository,
		wallet:     wallet,
		now:        time.Now,
		randomInt:  cryptoRandomInt,
	}
	if repository != nil {
		svc.robots = legacyRobotRegistryAdapter{registry: repository}
	}
	for _, option := range options {
		if option != nil {
			option(svc)
		}
	}
	return svc
}

// CreateMatch 只创建局和首个参与者，不提前扣金币；真正扣款在 roll 阶段统一串到钱包和 outbox。
func (s *Service) CreateMatch(ctx context.Context, command CreateMatchCommand) (dicedomain.Match, int64, error) {
	// 创建局必须先校验 game_catalog/platform 当前可用，防止下架或维护中的游戏继续生成可结算局。
	if s.repository == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" || command.StakeCoin <= 0 {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice match command is incomplete")
	}
	minPlayers, maxPlayers, err := dicedomain.NormalizePlayerBounds(command.MinPlayers, command.MaxPlayers)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if maxPlayers > s.config.MaxPlayers {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice max_players is too large")
	}
	app := appcode.Normalize(command.AppCode)
	ctx = appcode.WithContext(ctx, app)

	game, err := s.repository.GetLaunchableGame(ctx, app, strings.TrimSpace(command.GameID))
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
		return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game is not active")
	}
	config, err := s.configForMatch(ctx, app, game.GameID)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	rpsGesture, err := normalizeRPSGestureForGame(game.GameID, command.RPSGesture, true)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if config.Status != dicedomain.ConfigStatusActive {
		return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game config is disabled")
	}
	if !dicedomain.StakeEnabled(config, command.StakeCoin) {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice stake_coin is not configured")
	}
	if command.MinPlayers <= 0 {
		command.MinPlayers = config.MinPlayers
	}
	if command.MaxPlayers <= 0 {
		command.MaxPlayers = config.MaxPlayers
	}
	minPlayers, maxPlayers, err = dicedomain.NormalizePlayerBounds(command.MinPlayers, command.MaxPlayers)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if maxPlayers > s.config.MaxPlayers {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice max_players is too large")
	}

	now := s.now()
	nowMS := now.UnixMilli()
	// match_id 用 app/user/time/request 混合生成；真正防重靠数据库主键，hash 只负责长度稳定和不暴露业务输入。
	matchID := dicedomain.MatchIDPrefix + "_" + stableHash(fmt.Sprintf("%s|%d|%d|%s", app, command.UserID, now.UnixNano(), command.RequestID))[:20]
	match := dicedomain.Match{
		AppCode:        app,
		MatchID:        matchID,
		GameID:         game.GameID,
		PlatformCode:   strings.TrimSpace(game.PlatformCode),
		ProviderGameID: strings.TrimSpace(game.ProviderGameID),
		RoomID:         strings.TrimSpace(command.RoomID),
		RegionID:       command.RegionID,
		CountryID:      command.CountryID,
		MinPlayers:     minPlayers,
		MaxPlayers:     maxPlayers,
		CurrentPlayers: 1,
		StakeCoin:      command.StakeCoin,
		RoundNo:        1,
		Status:         dicedomain.MatchStatusCreated,
		FeeBPS:         config.FeeBPS,
		PoolBPS:        config.PoolBPS,
		MatchMode:      dicedomain.MatchModeHuman,
		JoinDeadlineMS: now.Add(s.config.JoinTTL).UnixMilli(),
		CreatedAtMS:    nowMS,
		UpdatedAtMS:    nowMS,
		Participants: []dicedomain.Participant{{
			// 创建人天然占 1 号座位；后续 join 在仓储行锁内按 max(seat_no)+1 分配，避免并发抢座冲突。
			AppCode:         app,
			MatchID:         matchID,
			UserID:          command.UserID,
			ParticipantType: dicedomain.ParticipantTypeUser,
			SeatNo:          1,
			Status:          dicedomain.ParticipantStatusJoined,
			StakeCoin:       command.StakeCoin,
			RPSGesture:      rpsGesture,
			JoinedAtMS:      nowMS,
			UpdatedAtMS:     nowMS,
		}},
	}
	if match.PlatformCode == "" {
		// 自研骰子理论上仍应在 catalog 配好平台；这里兜底是为了订单唯一键和统计 outbox 字段始终非空。
		match.PlatformCode = dicedomain.DefaultPlatformCode
	}
	if match.ProviderGameID == "" {
		match.ProviderGameID = dicedomain.DefaultProviderGameID
	}
	saved, err := s.repository.CreateDiceMatch(ctx, match)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	return saved, nowMS, nil
}

// GetConfig 返回 H5 大厅需要的骰子配置；没有后台配置时返回内置默认，不阻断新环境联调。
func (s *Service) GetConfig(ctx context.Context, appCode string, gameID string) (dicedomain.Config, int64, error) {
	if s.repository == nil {
		return dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	app := appcode.Normalize(appCode)
	ctx = appcode.WithContext(ctx, app)
	config, err := s.configForMatch(ctx, app, strings.TrimSpace(gameID))
	if err != nil {
		return dicedomain.Config{}, 0, err
	}
	return config, s.now().UnixMilli(), nil
}

func (s *Service) ListSelfGames(ctx context.Context, appCode string) ([]dicedomain.Config, int64, error) {
	if s.repository == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	app := appcode.Normalize(appCode)
	ctx = appcode.WithContext(ctx, app)
	configs, err := s.repository.ListSelfGameConfigs(ctx, app)
	if err != nil {
		return nil, 0, err
	}
	if len(configs) == 0 {
		config, err := s.configForMatch(ctx, app, dicedomain.DefaultGameID)
		if err != nil {
			return nil, 0, err
		}
		configs = append(configs, config)
	}
	return configs, s.now().UnixMilli(), nil
}

func (s *Service) UpdateConfig(ctx context.Context, config dicedomain.Config) (dicedomain.Config, int64, error) {
	if s.repository == nil {
		return dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	config.AppCode = appcode.Normalize(config.AppCode)
	ctx = appcode.WithContext(ctx, config.AppCode)
	nowMS := s.now().UnixMilli()
	saved, err := s.repository.UpsertDiceConfig(ctx, config, nowMS)
	return saved, nowMS, err
}

func (s *Service) GetSelfGameNewUserPolicy(ctx context.Context, appCode string, gameID string) (selfgame.NewUserPolicy, int64, error) {
	if s.repository == nil {
		return selfgame.NewUserPolicy{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	app := appcode.Normalize(appCode)
	gameID = strings.TrimSpace(gameID)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	policy, err := s.repository.GetSelfGameNewUserPolicy(ctx, app, gameID, nowMS)
	return policy, nowMS, err
}

func (s *Service) UpdateSelfGameNewUserPolicy(ctx context.Context, policy selfgame.NewUserPolicy) (selfgame.NewUserPolicy, int64, error) {
	if s.repository == nil {
		return selfgame.NewUserPolicy{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	policy.AppCode = appcode.Normalize(policy.AppCode)
	policy.GameID = strings.TrimSpace(policy.GameID)
	if policy.GameID == "" {
		return selfgame.NewUserPolicy{}, 0, xerr.New(xerr.InvalidArgument, "game id is required")
	}
	if policy.NormalPhaseTargetWinRatePercent != 0 && (policy.NormalPhaseTargetWinRatePercent < 50 || policy.NormalPhaseTargetWinRatePercent > 100) {
		return selfgame.NewUserPolicy{}, 0, xerr.New(xerr.InvalidArgument, "normal phase target win rate must be between 50 and 100")
	}
	ctx = appcode.WithContext(ctx, policy.AppCode)
	nowMS := s.now().UnixMilli()
	// 策略配置同时覆盖新手保护和成熟期目标胜率，但成熟期只允许向上补正用户赢，不提供强制用户输。
	// 保存前统一归一化数值默认值，确保 admin-server、脚本和未来运营工具写入时得到同一套运行时语义。
	saved, err := s.repository.UpsertSelfGameNewUserPolicy(ctx, policy, nowMS)
	return saved, nowMS, err
}

func (s *Service) ListSelfGameStakePools(ctx context.Context, appCode string, gameID string) ([]selfgame.StakePoolView, int64, error) {
	if s.repository == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	app := appcode.Normalize(appCode)
	gameID = strings.TrimSpace(gameID)
	if gameID == "" {
		gameID = dicedomain.DefaultGameID
	}
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	config, err := s.configForMatch(ctx, app, gameID)
	if err != nil {
		return nil, 0, err
	}
	policy, err := s.repository.GetSelfGameNewUserPolicy(ctx, app, gameID, nowMS)
	if err != nil {
		return nil, 0, err
	}
	persisted, err := s.repository.ListSelfGameStakePools(ctx, app, gameID, nowMS)
	if err != nil {
		return nil, 0, err
	}
	byStake := make(map[int64]selfgame.StakePool, len(persisted))
	for _, pool := range persisted {
		byStake[pool.StakeCoin] = pool
	}

	views := make([]selfgame.StakePoolView, 0, len(config.StakeOptions)+len(persisted))
	seen := make(map[int64]struct{}, len(config.StakeOptions)+len(persisted))
	for _, option := range config.StakeOptions {
		if option.StakeCoin <= 0 {
			continue
		}
		pool, ok := byStake[option.StakeCoin]
		if !ok {
			// 后台列表以“当前可下注档位”为主，即使新表尚未插入该档，也返回一条默认阈值快照。
			// 运营保存或加扣时才真正落库，避免只浏览页面就产生无意义写入。
			pool = selfgame.DefaultStakePool(app, gameID, option.StakeCoin, nowMS)
		}
		views = append(views, s.stakePoolView(config, pool, option.Enabled, policy.BlackPoolRobotForceWinEnabled))
		seen[option.StakeCoin] = struct{}{}
	}
	for _, pool := range persisted {
		if _, ok := seen[pool.StakeCoin]; ok {
			continue
		}
		// 旧配置遗留档位仍展示出来，方便运营把非下注档位的余额迁出或停用。
		views = append(views, s.stakePoolView(config, pool, false, policy.BlackPoolRobotForceWinEnabled))
	}
	sort.SliceStable(views, func(left int, right int) bool {
		return views[left].StakeCoin < views[right].StakeCoin
	})
	return views, nowMS, nil
}

func (s *Service) UpdateSelfGameStakePool(ctx context.Context, pool selfgame.StakePool) (selfgame.StakePoolView, int64, error) {
	if s.repository == nil {
		return selfgame.StakePoolView{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	pool.AppCode = appcode.Normalize(pool.AppCode)
	pool.GameID = strings.TrimSpace(pool.GameID)
	if pool.GameID == "" {
		return selfgame.StakePoolView{}, 0, xerr.New(xerr.InvalidArgument, "game id is required")
	}
	if pool.StakeCoin <= 0 {
		return selfgame.StakePoolView{}, 0, xerr.New(xerr.InvalidArgument, "stake coin is required")
	}
	pool.Status = strings.TrimSpace(pool.Status)
	if pool.Status == "" {
		pool.Status = "active"
	}
	if pool.Status != "active" && pool.Status != "disabled" {
		return selfgame.StakePoolView{}, 0, xerr.New(xerr.InvalidArgument, "stake pool status is invalid")
	}
	if pool.DangerBalanceCoin < 0 || pool.WarningBalanceCoin < 0 || pool.SafeBalanceCoin < 0 || pool.TargetBalanceCoin < 0 {
		return selfgame.StakePoolView{}, 0, xerr.New(xerr.InvalidArgument, "stake pool threshold cannot be negative")
	}
	if pool.DangerBalanceCoin > pool.WarningBalanceCoin || pool.WarningBalanceCoin > pool.SafeBalanceCoin {
		return selfgame.StakePoolView{}, 0, xerr.New(xerr.InvalidArgument, "stake pool thresholds must satisfy danger <= warning <= safe")
	}
	if pool.TargetBalanceCoin > 0 && pool.TargetBalanceCoin < pool.SafeBalanceCoin {
		return selfgame.StakePoolView{}, 0, xerr.New(xerr.InvalidArgument, "stake pool target must be greater than or equal to safe threshold")
	}
	ctx = appcode.WithContext(ctx, pool.AppCode)
	nowMS := s.now().UnixMilli()
	config, err := s.configForMatch(ctx, pool.AppCode, pool.GameID)
	if err != nil {
		return selfgame.StakePoolView{}, 0, err
	}
	policy, err := s.repository.GetSelfGameNewUserPolicy(ctx, pool.AppCode, pool.GameID, nowMS)
	if err != nil {
		return selfgame.StakePoolView{}, 0, err
	}
	enabled := false
	for _, option := range config.StakeOptions {
		if option.StakeCoin == pool.StakeCoin {
			enabled = option.Enabled
			break
		}
	}
	// 档位奖池是资金边界，允许配置非启用档位，便于运营迁移历史余额；但运行时只会对启用下注档触发匹配。
	saved, err := s.repository.UpsertSelfGameStakePoolConfig(ctx, pool, nowMS)
	if err != nil {
		return selfgame.StakePoolView{}, 0, err
	}
	return s.stakePoolView(config, saved, enabled, policy.BlackPoolRobotForceWinEnabled), nowMS, nil
}

func (s *Service) AdjustPool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, dicedomain.Config, int64, error) {
	if s.repository == nil {
		return dicedomain.PoolAdjustment{}, dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	if adjustment.StakeCoin <= 0 {
		return dicedomain.PoolAdjustment{}, dicedomain.Config{}, 0, xerr.New(xerr.InvalidArgument, "self game stake coin is required")
	}
	adjustment.AppCode = appcode.Normalize(adjustment.AppCode)
	ctx = appcode.WithContext(ctx, adjustment.AppCode)
	nowMS := s.now().UnixMilli()
	adjustment.CreatedAtMS = nowMS
	if adjustment.AdjustmentID == "" {
		adjustment.AdjustmentID = "dpool_admin_" + stableHash(fmt.Sprintf("%s|%s|%d|%s|%d|%d|%s", adjustment.AppCode, adjustment.GameID, adjustment.StakeCoin, adjustment.Direction, adjustment.AmountCoin, adjustment.CreatedBy, adjustment.Reason))[:20]
	}
	saved, err := s.repository.AdjustSelfGameStakePool(ctx, adjustment)
	if err != nil {
		return dicedomain.PoolAdjustment{}, dicedomain.Config{}, 0, err
	}
	config, err := s.configForMatch(ctx, adjustment.AppCode, adjustment.GameID)
	return saved, config, nowMS, err
}

func (s *Service) ListRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, int64, error) {
	if s.robots == nil {
		return nil, "", 0, xerr.New(xerr.Unavailable, "game robot registry is not configured")
	}
	app := appcode.Normalize(appCode)
	ctx = appcode.WithContext(ctx, app)
	items, next, err := s.robots.ListDiceRobots(ctx, app, gameID, status, pageSize, cursor)
	return items, next, s.now().UnixMilli(), err
}

// ListExploreWinners 给 Explore Square 提供真实获胜播报列表；读接口只读已有结算事实，不生成默认 seed 数据。
func (s *Service) ListExploreWinners(ctx context.Context, appCode string, pageSize int32) ([]dicedomain.ExploreWinner, int64, error) {
	if s.repository == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	app := appcode.Normalize(appCode)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	if pageSize <= 0 {
		pageSize = dicedomain.DefaultExploreWinnerPageSize
	}
	if pageSize > 50 {
		pageSize = 50
	}
	items, err := s.repository.ListExploreWinners(ctx, app, pageSize)
	return items, nowMS, err
}

func (s *Service) RegisterRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64) ([]dicedomain.Robot, int64, error) {
	if s.robots == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "game robot registry is not configured")
	}
	app := appcode.Normalize(appCode)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	items, err := s.robots.RegisterDiceRobots(ctx, app, gameID, userIDs, adminID, nowMS)
	return items, nowMS, err
}

func (s *Service) SetRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string) (dicedomain.Robot, int64, error) {
	if s.robots == nil {
		return dicedomain.Robot{}, 0, xerr.New(xerr.Unavailable, "game robot registry is not configured")
	}
	app := appcode.Normalize(appCode)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	item, err := s.robots.SetDiceRobotStatus(ctx, app, gameID, userID, status, nowMS)
	return item, nowMS, err
}

// DeleteRobot 删除当前 app 和游戏下的机器人登记，真实用户资料仍由 user-service 保留用于历史数据追溯。
func (s *Service) DeleteRobot(ctx context.Context, appCode string, gameID string, userID int64) (int64, error) {
	if s.robots == nil {
		return 0, xerr.New(xerr.Unavailable, "game robot registry is not configured")
	}
	if userID <= 0 {
		return 0, xerr.New(xerr.InvalidArgument, "dice robot user id is invalid")
	}
	app := appcode.Normalize(appCode)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	err := s.robots.DeleteDiceRobot(ctx, app, gameID, userID)
	return nowMS, err
}

// Match 按全服 app_code + game_id + stake_coin 匹配；没有等待真人时创建等待局，不提前扣金币。
func (s *Service) Match(ctx context.Context, command MatchCommand) (dicedomain.Match, int64, error) {
	if s.repository == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	if s.wallet == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "wallet client is not configured")
	}
	if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" || command.StakeCoin <= 0 {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice match command is incomplete")
	}
	app := appcode.Normalize(command.AppCode)
	ctx = appcode.WithContext(ctx, app)
	config, err := s.configForMatch(ctx, app, strings.TrimSpace(command.GameID))
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if config.Status != dicedomain.ConfigStatusActive {
		return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game config is disabled")
	}
	if !dicedomain.StakeEnabled(config, command.StakeCoin) {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice stake_coin is not configured")
	}
	rpsGesture, err := normalizeRPSGestureForGame(config.GameID, command.RPSGesture, true)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if err := s.ensureCoinBalance(ctx, command.RequestID, app, command.UserID, command.StakeCoin); err != nil {
		return dicedomain.Match{}, 0, err
	}
	nowMS := s.now().UnixMilli()
	match, err := s.repository.FindAndJoinWaitingDiceMatch(ctx, app, config.GameID, command.UserID, command.StakeCoin, rpsGesture, nowMS)
	if err == nil {
		return s.attachRobotIfDue(ctx, command.RequestID, match, nowMS)
	}
	if !xerr.IsCode(err, xerr.NotFound) {
		return dicedomain.Match{}, 0, err
	}
	// 没有可加入的真人局时创建新局；room_id 仅落字段预留，首版全服匹配强制不按房间隔离。
	return s.CreateMatch(ctx, CreateMatchCommand{
		AppCode:    app,
		RequestID:  command.RequestID,
		UserID:     command.UserID,
		GameID:     config.GameID,
		RoomID:     "",
		RegionID:   command.RegionID,
		CountryID:  command.CountryID,
		StakeCoin:  command.StakeCoin,
		MinPlayers: config.MinPlayers,
		MaxPlayers: config.MaxPlayers,
		RPSGesture: rpsGesture,
	})
}

// JoinMatch 在 match 行锁内分配座位；钱包不在这里扣款，避免未开局匹配占用用户余额。
func (s *Service) JoinMatch(ctx context.Context, command JoinMatchCommand) (dicedomain.Match, int64, error) {
	if s.repository == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice join command is incomplete")
	}
	app := appcode.Normalize(command.AppCode)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	matchForJoin, err := s.expectedMatch(ctx, app, command.MatchID, command.GameID)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	rpsGesture, err := normalizeRPSGestureForGame(matchForJoin.GameID, command.RPSGesture, true)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if err := validateRPSParticipantGesture(matchForJoin, command.UserID, rpsGesture); err != nil {
		return dicedomain.Match{}, 0, err
	}
	// 加入只修改参与者事实；如果用户重复点击 join，仓储按唯一参与者返回已有局快照。
	match, err := s.repository.JoinDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, rpsGesture, nowMS)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	return match, nowMS, nil
}

// GetMatch 只读取 MySQL 当前事实；如果后续加 Redis，也只能作为这个结果的旁路缓存。
func (s *Service) GetMatch(ctx context.Context, command GetMatchCommand) (dicedomain.Match, int64, error) {
	if s.repository == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice get command is incomplete")
	}
	app := appcode.Normalize(command.AppCode)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	match, err := s.repository.GetDiceMatch(ctx, app, strings.TrimSpace(command.MatchID))
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if err := expectedGameIDMatches(command.GameID, match); err != nil {
		return dicedomain.Match{}, 0, err
	}
	return s.attachRobotIfDue(ctx, "", match, nowMS)
}

// CancelMatch 只允许等待中的创建人撤销局；没有扣款，因此取消不会触发钱包补偿。
func (s *Service) CancelMatch(ctx context.Context, command CancelMatchCommand) (dicedomain.Match, int64, error) {
	if s.repository == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice cancel command is incomplete")
	}
	app := appcode.Normalize(command.AppCode)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	if err := s.ensureMatchGame(ctx, app, command.MatchID, command.GameID); err != nil {
		return dicedomain.Match{}, 0, err
	}
	match, err := s.repository.CancelDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
	return match, nowMS, err
}

// RollMatch 先用 MySQL 抢占结算权，再串行调用钱包；钱包幂等键保证重试不会重复扣款或派奖。
func (s *Service) RollMatch(ctx context.Context, command RollMatchCommand) (dicedomain.Match, int64, error) {
	if s.repository == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	if s.wallet == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "wallet client is not configured")
	}
	if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
		return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice roll command is incomplete")
	}
	app := appcode.Normalize(command.AppCode)
	ctx = appcode.WithContext(ctx, app)
	nowMS := s.now().UnixMilli()
	matchForRoll, err := s.expectedMatch(ctx, app, command.MatchID, command.GameID)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	rpsGesture, err := normalizeRPSGestureForGame(matchForRoll.GameID, command.RPSGesture, false)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if err := validateOptionalRPSRollGesture(matchForRoll, command.UserID, rpsGesture); err != nil {
		return dicedomain.Match{}, 0, err
	}

	// ClaimDiceMatchForRoll 用 match 行锁把局切到 settling；锁提交后才调用钱包，减少数据库锁持有时间。
	match, err := s.repository.ClaimDiceMatchForRoll(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if match.Status == dicedomain.MatchStatusSettled {
		// roll 重试命中已结算局时直接返回结果，客户端可以安全重复请求。
		return match, nowMS, nil
	}
	if len(match.Participants) < int(match.MinPlayers) {
		return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice match does not have enough participants")
	}
	if err := validateOptionalRPSRollGesture(match, command.UserID, rpsGesture); err != nil {
		return dicedomain.Match{}, 0, err
	}
	normalizeMatchEconomicSnapshot(&match)

	for index := range match.Participants {
		if match.Participants[index].ParticipantType == dicedomain.ParticipantTypeRobot {
			// 机器人是真实 App 用户资料，但对局资金来自共享奖池；不能对机器人用户钱包扣本金。
			match.Participants[index].Status = dicedomain.ParticipantStatusDebitSucceeded
			continue
		}
		if alreadyDebited(match.Participants[index].Status) {
			// 补偿或重试进入这里时，已扣款/已派奖/已退款的参与者不能再次扣本金。
			continue
		}
		// 每个参与者独立生成 provider_order_id；wallet-service 用 command_id/request_hash 做最终账务幂等。
		order, balanceAfter, err := s.applyGameCoinChange(ctx, command.RequestID, match, match.Participants[index], "debit", match.Participants[index].StakeCoin)
		if err != nil {
			// 任一扣款失败，本局不能继续开奖；已扣成功的参与者尽量立即退款，失败细节留在 game_orders 和 match failed 状态里。
			_ = s.repository.MarkDiceParticipantDebitFailed(ctx, app, match.MatchID, match.Participants[index].UserID, order.OrderID, nowMS)
			_ = s.refundDebitedParticipants(ctx, command.RequestID, match, nowMS)
			_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "debit_failed", nowMS)
			return dicedomain.Match{}, 0, err
		}
		match.Participants[index].Status = dicedomain.ParticipantStatusDebitSucceeded
		match.Participants[index].DebitOrderID = order.OrderID
		match.Participants[index].BalanceAfter = balanceAfter
		if err := s.repository.MarkDiceParticipantDebitSucceeded(ctx, app, match.MatchID, match.Participants[index].UserID, order.OrderID, balanceAfter, nowMS); err != nil {
			return dicedomain.Match{}, 0, err
		}
	}

	if dicedomain.IsRPSGameID(match.GameID) {
		if !dicedomain.HasRolls(match.Participants) {
			// rock 的胜负只来自 match/create/join 阶段已经保存的 rps_gesture；roll 阶段只负责扣款后把固定结果落库。
			settledParticipants, result, err := dicedomain.SettleRPSByGestureWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
			if err != nil {
				return dicedomain.Match{}, 0, err
			}
			match.Participants = settledParticipants
			match.Result = result
			match, err = s.repository.SaveDiceRolls(ctx, match, nowMS)
			if err != nil {
				return dicedomain.Match{}, 0, err
			}
		} else if match.Result == "" {
			// 兼容 rps 结果点数已写入但 match.result 空的半完成状态，仍按手势重新推导同一份派奖/退款金额。
			settledParticipants, result, err := dicedomain.SettleRPSByGestureWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
			if err != nil {
				return dicedomain.Match{}, 0, err
			}
			match.Participants = settledParticipants
			match.Result = result
		}
	} else if !dicedomain.HasRolls(match.Participants) {
		// 只有首次结算可以随机点数；点数一旦落库，所有后续重试必须复用旧点数，避免改变输赢结果。
		if err := s.rollParticipants(match.Participants); err != nil {
			return dicedomain.Match{}, 0, err
		}
		if match.ForcedResult == dicedomain.ForcedResultPlayerLose {
			// 奖池不足的机器人局在匹配完成时已经冻结强制结果；roll 阶段只负责生成与结果一致的点数。
			if err := s.forceHumanLose(match.Participants); err != nil {
				return dicedomain.Match{}, 0, err
			}
		} else if match.ForcedResult == dicedomain.ForcedResultPlayerWin || match.ForcedResult == dicedomain.ForcedResultPlayerWinWithoutProtection {
			// 新策略保护的机器人局在匹配完成时已经确认奖池够付净赢额；roll 阶段只改点数，不绕过统一结算和奖池扣减。
			if err := s.forceHumanWin(match.Participants); err != nil {
				return dicedomain.Match{}, 0, err
			}
		}
		settledParticipants, result, err := dicedomain.SettleByHighestScoreWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
		if err != nil {
			return dicedomain.Match{}, 0, err
		}
		if result == dicedomain.ParticipantResultDraw {
			// 和局不退本金也不派奖；保存本轮点数给 H5 展示，局保持 ready，2 秒后客户端继续调用 roll。
			match.Participants = settledParticipants
			match.Result = result
			saved, err := s.repository.SaveDiceDrawForReroll(ctx, match, nowMS)
			return saved, nowMS, err
		}
		// SaveDiceRolls 原子写入每个参与者点数、结果和 payout_coin，同时把局推进到 payout_applying。
		match.Participants = settledParticipants
		match.Result = result
		match, err = s.repository.SaveDiceRolls(ctx, match, nowMS)
		if err != nil {
			return dicedomain.Match{}, 0, err
		}
	} else if match.Result == "" {
		// 兼容“点数已落库但 match.result 未写入”的半完成状态，按旧点数重新推导派彩金额。
		settledParticipants, result, err := dicedomain.SettleByHighestScoreWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
		if err != nil {
			return dicedomain.Match{}, 0, err
		}
		match.Participants = settledParticipants
		match.Result = result
	}

	for index := range match.Participants {
		participant := match.Participants[index]
		if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
			// 机器人输赢只影响奖池，不走用户钱包 credit/refund。
			continue
		}
		if participant.PayoutCoin <= 0 || participant.Status == dicedomain.ParticipantStatusPayoutSucceeded || participant.Status == dicedomain.ParticipantStatusRefundSucceeded || participant.Status == dicedomain.ParticipantStatusSettled {
			// 输家 payout_coin 为 0；已处理过的赢家或和局用户不重复调用钱包。
			continue
		}
		opType := "credit"
		if participant.Result == dicedomain.ParticipantResultDraw {
			// 和局走 refund 而不是 credit，账务语义上明确这是返还本金，不算中奖派奖。
			opType = "refund"
		}
		order, balanceAfter, err := s.applyGameCoinChange(ctx, command.RequestID, match, participant, opType, participant.PayoutCoin)
		if err != nil {
			// 点数已经固定后派奖失败不能重投，只标记 failed 交给后续补偿继续按同一 provider_order_id 处理。
			_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "payout_failed", nowMS)
			return dicedomain.Match{}, 0, err
		}
		match.Participants[index].BalanceAfter = balanceAfter
		if opType == "refund" {
			match.Participants[index].Status = dicedomain.ParticipantStatusRefundSucceeded
			if err := s.repository.MarkDiceParticipantRefundSucceeded(ctx, app, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil {
				return dicedomain.Match{}, 0, err
			}
			continue
		}
		match.Participants[index].Status = dicedomain.ParticipantStatusPayoutSucceeded
		if err := s.repository.MarkDiceParticipantPayoutSucceeded(ctx, app, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil {
			return dicedomain.Match{}, 0, err
		}
	}

	if err := s.applyPoolDelta(ctx, command.RequestID, match, nowMS); err != nil {
		// 钱包账务已经按固定点数推进后，奖池流水失败不能重投；把局留在 failed 便于人工按 match_id 对账补偿。
		_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "pool_failed", nowMS)
		return dicedomain.Match{}, 0, err
	}

	// 所有应付账务都成功后才把 match 和 participants 统一标记 settled，客户端看到 settled 即可认为结果不可变。
	if err := s.repository.MarkDiceMatchSettled(ctx, app, match.MatchID, match.Result, nowMS); err != nil {
		return dicedomain.Match{}, 0, err
	}
	// Square 播报是结算后的附属展示事实，只记录真人赢家且用 match_id+user_id 做幂等键；写失败不能让已经完成的钱包结算回滚成失败局。
	_ = s.recordExploreWinners(ctx, match, nowMS)
	saved, err := s.repository.GetDiceMatch(ctx, app, match.MatchID)
	return saved, nowMS, err
}

func (s *Service) recordExploreWinners(ctx context.Context, match dicedomain.Match, nowMS int64) error {
	gameCode := exploreGameCodeForMatch(match.GameID)
	if gameCode == "" {
		return nil
	}
	for _, participant := range match.Participants {
		if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
			// 机器人只是匹配补位和奖池对手，不应该出现在 Explore Square 的“用户获胜”播报里。
			continue
		}
		if participant.Result != dicedomain.ParticipantResultWin || participant.PayoutCoin <= 0 {
			// 输家、和局和没有派奖金额的半完成状态都不能生成获胜播报，避免客户端展示假的中奖金额。
			continue
		}
		winner := dicedomain.ExploreWinner{
			AppCode:     match.AppCode,
			WinID:       fmt.Sprintf("self_%s_%d", match.MatchID, participant.UserID),
			GameCode:    gameCode,
			GameID:      strings.TrimSpace(match.GameID),
			UserID:      participant.UserID,
			CoinAmount:  participant.PayoutCoin,
			WonAtMS:     nowMS,
			CreatedAtMS: nowMS,
			UpdatedAtMS: nowMS,
		}
		if err := s.repository.RecordExploreWinner(ctx, winner); err != nil {
			return err
		}
	}
	return nil
}

func exploreGameCodeForMatch(gameID string) string {
	switch strings.TrimSpace(gameID) {
	case "", dicedomain.DefaultGameID:
		return dicedomain.ExploreGameCodeDice
	case dicedomain.SelfGameIDRock:
		return dicedomain.ExploreGameCodeRock
	default:
		return ""
	}
}

func (s *Service) ensureMatchGame(ctx context.Context, appCode string, matchID string, expectedGameID string) error {
	_, err := s.expectedMatch(ctx, appCode, matchID, expectedGameID)
	return err
}

func (s *Service) expectedMatch(ctx context.Context, appCode string, matchID string, expectedGameID string) (dicedomain.Match, error) {
	expectedGameID = strings.TrimSpace(expectedGameID)
	if expectedGameID == "" {
		return s.repository.GetDiceMatch(ctx, appCode, strings.TrimSpace(matchID))
	}
	match, err := s.repository.GetDiceMatch(ctx, appCode, strings.TrimSpace(matchID))
	if err != nil {
		return dicedomain.Match{}, err
	}
	if err := expectedGameIDMatches(expectedGameID, match); err != nil {
		return dicedomain.Match{}, err
	}
	return match, nil
}

func expectedGameIDMatches(expectedGameID string, match dicedomain.Match) error {
	expectedGameID = strings.TrimSpace(expectedGameID)
	if expectedGameID == "" {
		return nil
	}
	if strings.TrimSpace(match.GameID) == expectedGameID {
		return nil
	}
	// dice 和 rps 共用自研 match 表，HTTP 路径必须用 game_id 再切一刀；返回 not found，避免向客户端泄露其它游戏局存在。
	return xerr.New(xerr.NotFound, "self game match not found")
}

func normalizeRPSGestureForGame(gameID string, rawGesture string, required bool) (string, error) {
	if !dicedomain.IsRPSGameID(gameID) {
		return "", nil
	}
	if strings.TrimSpace(rawGesture) == "" && !required {
		return "", nil
	}
	gesture, ok := dicedomain.NormalizeRPSGesture(rawGesture)
	if !ok {
		return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
	}
	return gesture, nil
}

func validateRPSParticipantGesture(match dicedomain.Match, userID int64, gesture string) error {
	if !dicedomain.IsRPSGameID(match.GameID) {
		return nil
	}
	if strings.TrimSpace(gesture) == "" {
		return xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
	}
	for _, participant := range match.Participants {
		if participant.UserID != userID {
			continue
		}
		selected, ok := dicedomain.NormalizeRPSGesture(participant.RPSGesture)
		if !ok {
			return xerr.New(xerr.Conflict, "rps selected gesture is missing")
		}
		if selected != gesture {
			// 同一个用户重复 join/roll 只能确认原手势，不能通过重试把已经排队的拳换掉。
			return xerr.New(xerr.Conflict, "rps gesture does not match selected gesture")
		}
		return nil
	}
	return nil
}

func validateOptionalRPSRollGesture(match dicedomain.Match, userID int64, gesture string) error {
	if !dicedomain.IsRPSGameID(match.GameID) || strings.TrimSpace(gesture) == "" {
		return nil
	}
	return validateRPSParticipantGesture(match, userID, gesture)
}

func (s *Service) configForMatch(ctx context.Context, appCode string, gameID string) (dicedomain.Config, error) {
	config, err := s.repository.GetDiceConfig(ctx, appCode, strings.TrimSpace(gameID))
	if err == nil {
		return config, nil
	}
	if !xerr.IsCode(err, xerr.NotFound) {
		return dicedomain.Config{}, err
	}
	// 没有后台配置时把默认配置写成 MySQL 事实，再重新读取；这样 H5、大厅后台和机器人奖池判断都使用同一份配置/奖池快照。
	normalized, err := dicedomain.NormalizeConfig(dicedomain.Config{
		AppCode:          appcode.Normalize(appCode),
		GameID:           strings.TrimSpace(gameID),
		Status:           dicedomain.ConfigStatusActive,
		FeeBPS:           int32(dicedomain.DefaultFeeBPS),
		PoolBPS:          int32(dicedomain.DefaultPoolBPS),
		MinPlayers:       dicedomain.DefaultMinPlayers,
		MaxPlayers:       dicedomain.DefaultMaxPlayers,
		RobotEnabled:     true,
		RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS,
	})
	if err != nil {
		return dicedomain.Config{}, err
	}
	return s.repository.UpsertDiceConfig(ctx, normalized, s.now().UnixMilli())
}

func (s *Service) ensureCoinBalance(ctx context.Context, requestID string, appCode string, userID int64, stakeCoin int64) error {
	resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
		RequestId:  strings.TrimSpace(requestID),
		UserId:     userID,
		AppCode:    appCode,
		AssetTypes: []string{"COIN"},
	})
	if err != nil {
		return err
	}
	for _, balance := range resp.GetBalances() {
		if strings.EqualFold(balance.GetAssetType(), "COIN") && balance.GetAvailableAmount() >= stakeCoin {
			return nil
		}
	}
	return xerr.New(xerr.InsufficientBalance, "coin balance is insufficient")
}

func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) (dicedomain.Match, int64, error) {
	if match.Status != dicedomain.MatchStatusCreated && match.Status != dicedomain.MatchStatusJoining {
		return match, nowMS, nil
	}
	if match.CurrentPlayers >= match.MinPlayers {
		return match, nowMS, nil
	}
	config, err := s.configForMatch(ctx, match.AppCode, match.GameID)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if !config.RobotEnabled || nowMS < match.CreatedAtMS+config.RobotMatchWaitMS {
		return match, nowMS, nil
	}
	if s.robots == nil {
		return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game robot registry is not configured")
	}
	human, ok := singleWaitingHuman(match)
	if !ok {
		// 自研机器人补位策略只处理“一个真人等待一个机器人”的局；多人等待局无法准确落到单个用户的新手保护额度，
		// 因此继续等待真人加入，避免把保护次数、补贴额度和策略日志错误记到某一个真人身上。
		return match, nowMS, nil
	}
	requiredPool := robotHumanWinRequiredPoolCoin(match)
	stakePool, err := s.repository.GetSelfGameStakePool(ctx, match.AppCode, match.GameID, match.StakeCoin, nowMS)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	newUserPolicy, err := s.repository.GetSelfGameNewUserPolicy(ctx, match.AppCode, match.GameID, nowMS)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	poolLevel := selfgame.PoolLevelFor(stakePool, requiredPool)
	poolStatus := strings.TrimSpace(stakePool.Status)
	poolDisabled := poolStatus != "" && poolStatus != "active"
	if poolLevel == selfgame.PoolLevelBlack && (poolDisabled || !newUserPolicy.BlackPoolRobotForceWinEnabled) {
		// 黑色水位代表当前档位可用奖池已经覆盖不了本局真人赢机器人时的最大净支出。
		// 默认不开机器人补位，用户保持等待真人；只有策略配置显式开启，并且档位不是手动停用时，下面才会继续抽机器人强制其获胜。
		decision, err := selfgame.EvaluateRobotStrategy(selfgame.StrategyInput{
			AppCode:          match.AppCode,
			GameID:           match.GameID,
			MatchID:          match.MatchID,
			UserID:           human.UserID,
			StakeCoin:        match.StakeCoin,
			NowMS:            nowMS,
			RequiredPoolCoin: requiredPool,
			AllowsDraw:       dicedomain.IsRPSGameID(match.GameID),
			StakePool:        stakePool,
			NewUserPolicy:    newUserPolicy,
			ProtectionState:  selfgame.ProtectionState{AppCode: match.AppCode, GameID: match.GameID, UserID: human.UserID, Status: "active"},
			RiskResult:       selfgame.RiskResult{},
		}, s.randomInt)
		if err != nil {
			return dicedomain.Match{}, 0, err
		}
		if err := s.recordSelfGameStrategyLog(ctx, match, human.UserID, stakePool, decision, nowMS); err != nil {
			return dicedomain.Match{}, 0, err
		}
		return match, nowMS, nil
	}
	robot, err := s.robots.PickActiveDiceRobot(ctx, match.AppCode, match.GameID, match.MatchID, matchParticipantUserIDs(match), nowMS)
	if err != nil {
		if xerr.IsCode(err, xerr.NotFound) {
			return match, nowMS, nil
		}
		return dicedomain.Match{}, 0, err
	}
	protectionState, err := s.repository.GetSelfGameProtectionState(ctx, match.AppCode, match.GameID, human.UserID, nowMS)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	riskResult, err := s.repository.GetSelfGameRiskResult(ctx, match.AppCode, match.GameID, human.UserID, nowMS)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	userStats, err := s.repository.GetSelfGameUserStats(ctx, match.AppCode, match.GameID, human.UserID, nowMS)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	decision, err := selfgame.EvaluateRobotStrategy(selfgame.StrategyInput{
		AppCode:          match.AppCode,
		GameID:           match.GameID,
		MatchID:          match.MatchID,
		UserID:           human.UserID,
		StakeCoin:        match.StakeCoin,
		NowMS:            nowMS,
		RequiredPoolCoin: requiredPool,
		AllowsDraw:       dicedomain.IsRPSGameID(match.GameID),
		StakePool:        stakePool,
		NewUserPolicy:    newUserPolicy,
		ProtectionState:  protectionState,
		RiskResult:       riskResult,
		UserStats:        userStats,
	}, s.randomInt)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	if err := s.recordSelfGameStrategyLog(ctx, match, human.UserID, stakePool, decision, nowMS); err != nil {
		return dicedomain.Match{}, 0, err
	}
	forcedResult := decision.ForceResult
	robotRPSGesture := ""
	if dicedomain.IsRPSGameID(match.GameID) {
		robotRPSGesture, err = s.robotRPSGestureForMatch(match, forcedResult)
		if err != nil {
			return dicedomain.Match{}, 0, err
		}
	}
	joined, err := s.repository.JoinDiceRobot(ctx, match.AppCode, match.MatchID, robot.UserID, forcedResult, robotRPSGesture, nowMS)
	if err != nil {
		return dicedomain.Match{}, 0, err
	}
	return joined, nowMS, nil
}

func matchParticipantUserIDs(match dicedomain.Match) []int64 {
	ids := make([]int64, 0, len(match.Participants))
	for _, participant := range match.Participants {
		if participant.UserID > 0 {
			ids = append(ids, participant.UserID)
		}
	}
	return ids
}

func singleWaitingHuman(match dicedomain.Match) (dicedomain.Participant, bool) {
	var human dicedomain.Participant
	for _, participant := range match.Participants {
		if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
			continue
		}
		if participant.UserID <= 0 {
			continue
		}
		if human.UserID != 0 {
			return dicedomain.Participant{}, false
		}
		human = participant
	}
	return human, human.UserID > 0
}

func (s *Service) recordSelfGameStrategyLog(ctx context.Context, match dicedomain.Match, userID int64, stakePool selfgame.StakePool, decision selfgame.StrategyDecision, nowMS int64) error {
	log := selfgame.StrategyLog{
		AppCode:         match.AppCode,
		LogID:           "sgst_" + stableHash(fmt.Sprintf("%s|%s|%d|%s", match.AppCode, match.MatchID, userID, decision.ReasonCode))[:24],
		GameID:          match.GameID,
		StakeCoin:       match.StakeCoin,
		MatchID:         match.MatchID,
		UserID:          userID,
		StrategyCode:    decision.StrategyCode,
		Decision:        decision.Decision,
		ForceResult:     decision.ForceResult,
		ReasonCode:      decision.ReasonCode,
		ReasonJSON:      decision.ReasonJSON,
		PoolBalanceCoin: stakePool.BalanceCoin,
		PoolLevel:       decision.PoolLevel,
		CreatedAtMS:     nowMS,
	}
	return s.repository.RecordSelfGameStrategyLog(ctx, log)
}

func requiredPoolCoinForStake(stakeCoin int64, feeBPS int32, poolBPS int32) int64 {
	if stakeCoin <= 0 {
		return 0
	}
	totalStake := stakeCoin * 2
	payoutPool := totalStake * (10_000 - int64(feeBPS) - int64(poolBPS)) / 10_000
	required := payoutPool - stakeCoin
	if required < 0 {
		return 0
	}
	return required
}

func (s *Service) stakePoolView(config dicedomain.Config, pool selfgame.StakePool, stakeEnabled bool, blackPoolRobotForceWinEnabled bool) selfgame.StakePoolView {
	pool = selfgame.NormalizeStakePool(pool)
	required := requiredPoolCoinForStake(pool.StakeCoin, config.FeeBPS, config.PoolBPS)
	level := selfgame.PoolLevelFor(pool, required)
	capacity := int64(0)
	if required > 0 {
		capacity = pool.AvailableCoin() / required
	}
	poolDisabled := pool.Status != "" && pool.Status != "active"
	blackReason := ""
	if poolDisabled {
		blackReason = "status_disabled"
	} else if level == selfgame.PoolLevelBlack {
		blackReason = "available_below_required_pool_coin"
	}
	// botMatchEnabled 是后台给运营看的当前结论：档位配置关闭、奖池状态停用、或可用余额不足时都不能补机器人。
	// 黄色和红色仍允许补位；黑色只有在策略显式开启机器人强制获胜时允许补位，手动停用始终优先熔断。
	return selfgame.StakePoolView{
		StakePool:        pool,
		PoolLevel:        level,
		RequiredPoolCoin: required,
		HumanWinCapacity: capacity,
		BotMatchEnabled:  stakeEnabled && !poolDisabled && (level != selfgame.PoolLevelBlack || blackPoolRobotForceWinEnabled),
		BlackReason:      blackReason,
	}
}

func (s *Service) robotRPSGestureForMatch(match dicedomain.Match, forcedResult string) (string, error) {
	var humanGesture string
	for _, participant := range match.Participants {
		if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
			continue
		}
		gesture, ok := dicedomain.NormalizeRPSGesture(participant.RPSGesture)
		if !ok {
			return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
		}
		humanGesture = gesture
		break
	}
	if humanGesture == "" {
		return "", xerr.New(xerr.Conflict, "rps match does not have human participant")
	}
	if forcedResult == dicedomain.ForcedResultPlayerLose {
		// 奖池不足时不能随机出玩家赢的结果；机器人只选择能击败真人预选手势的那一拳。
		gesture, ok := dicedomain.RPSWinningGestureFor(humanGesture)
		if !ok {
			return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
		}
		return gesture, nil
	}
	if forcedResult == dicedomain.ForcedResultPlayerWin || forcedResult == dicedomain.ForcedResultPlayerWinWithoutProtection {
		// 用户赢的策略命中时，机器人只选择会输给真人预选手势的那一拳；新手保护和成熟期目标胜率共用手势反推。
		gesture, ok := dicedomain.RPSLosingGestureFor(humanGesture)
		if !ok {
			return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
		}
		return gesture, nil
	}
	index, err := s.randomInt(3)
	if err != nil {
		return "", err
	}
	gestures := []string{dicedomain.RPSGestureRock, dicedomain.RPSGesturePaper, dicedomain.RPSGestureScissors}
	if index < 0 || index >= len(gestures) {
		return "", xerr.New(xerr.InvalidArgument, "random rps gesture is invalid")
	}
	return gestures[index], nil
}

func (s *Service) forceHumanWin(participants []dicedomain.Participant) error {
	winningPairs := [][2]int32{
		{2, 1},
		{3, 1}, {3, 2},
		{4, 1}, {4, 2}, {4, 3},
		{5, 1}, {5, 2}, {5, 3}, {5, 4},
		{6, 2}, {6, 3}, {6, 4}, {6, 5},
	}
	pairIndex, err := s.randomInt(len(winningPairs))
	if err != nil {
		return err
	}
	if pairIndex < 0 || pairIndex >= len(winningPairs) {
		return xerr.New(xerr.InvalidArgument, "random forced human win dice pair is invalid")
	}
	humanPoint := winningPairs[pairIndex][0]
	robotPoint := winningPairs[pairIndex][1]

	for index := range participants {
		if participants[index].ParticipantType == dicedomain.ParticipantTypeRobot {
			// 强制赢只控制“真人点数必须大于机器人点数”这个结算方向，不再固定成 6:1。
			// 点数组合从合法赢法中随机抽取，并刻意排除历史固定样式 6:1，避免 H5 继续展示同一组假点数。
			participants[index].DicePoints = []int32{robotPoint}
			continue
		}
		participants[index].DicePoints = []int32{humanPoint}
	}
	return nil
}

func (s *Service) forceHumanLose(participants []dicedomain.Participant) error {
	losingPairs := [][2]int32{
		{1, 2}, {1, 3}, {1, 4}, {1, 5},
		{2, 3}, {2, 4}, {2, 5}, {2, 6},
		{3, 4}, {3, 5}, {3, 6},
		{4, 5}, {4, 6},
		{5, 6},
	}
	pairIndex, err := s.randomInt(len(losingPairs))
	if err != nil {
		return err
	}
	if pairIndex < 0 || pairIndex >= len(losingPairs) {
		return xerr.New(xerr.InvalidArgument, "random forced human lose dice pair is invalid")
	}
	humanPoint := losingPairs[pairIndex][0]
	robotPoint := losingPairs[pairIndex][1]

	for index := range participants {
		if participants[index].ParticipantType == dicedomain.ParticipantTypeRobot {
			// 强制输只控制“真人点数必须小于机器人点数”这个结算方向，不再固定成 1:6。
			// 点数组合从合法输法中随机抽取，并刻意排除历史固定样式 1:6，避免输赢两侧都出现机械展示。
			participants[index].DicePoints = []int32{robotPoint}
			continue
		}
		participants[index].DicePoints = []int32{humanPoint}
	}
	return nil
}

func (s *Service) applyPoolDelta(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) error {
	direction, amount, userID, stakeCoin := dicePoolDelta(match)
	if amount <= 0 {
		return nil
	}
	_, err := s.repository.AdjustSelfGameStakePool(ctx, dicedomain.PoolAdjustment{
		AdjustmentID: "dpool_" + stableHash(fmt.Sprintf("%s|%s|%s|%d|%s", match.AppCode, match.MatchID, direction, amount, requestID))[:24],
		AppCode:      match.AppCode,
		GameID:       match.GameID,
		StakeCoin:    stakeCoin,
		MatchID:      match.MatchID,
		UserID:       userID,
		Direction:    direction,
		AmountCoin:   amount,
		Reason:       "dice_settlement",
		CreatedAtMS:  nowMS,
	})
	if err != nil {
		return err
	}
	return s.consumeProtectionIfNeeded(ctx, match, direction, amount, userID, nowMS)
}

func normalizeMatchEconomicSnapshot(match *dicedomain.Match) {
	if match.FeeBPS == 0 {
		match.FeeBPS = int32(dicedomain.DefaultFeeBPS)
	}
	if match.PoolBPS == 0 {
		match.PoolBPS = int32(dicedomain.DefaultPoolBPS)
	}
	for index := range match.Participants {
		if strings.TrimSpace(match.Participants[index].ParticipantType) == "" {
			match.Participants[index].ParticipantType = dicedomain.ParticipantTypeUser
		}
	}
}

func robotHumanWinRequiredPoolCoin(match dicedomain.Match) int64 {
	normalizeMatchEconomicSnapshot(&match)
	var totalStake int64
	var firstHumanStake int64
	for _, participant := range match.Participants {
		if participant.StakeCoin > 0 {
			totalStake += participant.StakeCoin
		}
		if participant.ParticipantType != dicedomain.ParticipantTypeRobot && firstHumanStake == 0 {
			firstHumanStake = participant.StakeCoin
		}
	}
	if firstHumanStake <= 0 {
		return 0
	}
	// 机器人不真实扣本金；真人赢时奖池只承担“派奖额 - 真人已扣本金”的净支出，和 dicePoolDelta 的 out 口径保持一致。
	// 这里运行在机器人入座前，所以总奖池需要把即将补位的机器人 stake 也算进去，才能得到本局真实派奖额。
	totalStake += match.StakeCoin
	payoutPool := totalStake * (10_000 - int64(match.FeeBPS) - int64(match.PoolBPS)) / 10_000
	required := payoutPool - firstHumanStake
	if required < 0 {
		return 0
	}
	return required
}

func (s *Service) consumeProtectionIfNeeded(ctx context.Context, match dicedomain.Match, direction string, amount int64, userID int64, nowMS int64) error {
	if match.MatchMode != dicedomain.MatchModeRobot || match.ForcedResult != dicedomain.ForcedResultPlayerWin {
		return nil
	}
	if direction != dicedomain.PoolDirectionOut || amount <= 0 || userID <= 0 {
		return nil
	}
	if match.Result == dicedomain.ParticipantResultDraw {
		// rock 的和局只退还本金，没有给用户形成收益，也没有奖池补贴支出；产品口径明确不计有效机器人局、不消耗保护额度。
		return nil
	}
	human := participantByUserID(match.Participants, userID)
	if human.UserID == 0 || human.ParticipantType == dicedomain.ParticipantTypeRobot || human.Result != dicedomain.ParticipantResultWin {
		return nil
	}
	_, _, err := s.repository.ConsumeSelfGameProtection(ctx, selfgame.ProtectionEvent{
		AppCode:                  match.AppCode,
		GameID:                   match.GameID,
		MatchID:                  match.MatchID,
		UserID:                   userID,
		UsedRoundDelta:           1,
		LifetimeSubsidyCoinDelta: amount,
		Day1SubsidyCoinDelta:     amount,
		CreatedAtMS:              nowMS,
	})
	return err
}

func dicePoolDelta(match dicedomain.Match) (string, int64, int64, int64) {
	normalizeMatchEconomicSnapshot(&match)
	var totalStake int64
	var firstHuman dicedomain.Participant
	for _, participant := range match.Participants {
		totalStake += participant.StakeCoin
		if participant.ParticipantType != dicedomain.ParticipantTypeRobot && firstHuman.UserID == 0 {
			firstHuman = participant
		}
	}
	if match.Result == dicedomain.ParticipantResultDraw {
		return "", 0, firstHuman.UserID, match.StakeCoin
	}
	if match.MatchMode != dicedomain.MatchModeRobot {
		amount := totalStake * int64(match.PoolBPS) / 10_000
		return dicedomain.PoolDirectionIn, amount, firstHuman.UserID, match.StakeCoin
	}
	if firstHuman.Result == dicedomain.ParticipantResultWin {
		netOut := firstHuman.PayoutCoin - firstHuman.StakeCoin
		if netOut <= 0 {
			return "", 0, firstHuman.UserID, match.StakeCoin
		}
		return dicedomain.PoolDirectionOut, netOut, firstHuman.UserID, match.StakeCoin
	}
	if firstHuman.Result == dicedomain.ParticipantResultLose {
		amount := firstHuman.StakeCoin * (10_000 - int64(match.FeeBPS)) / 10_000
		return dicedomain.PoolDirectionIn, amount, firstHuman.UserID, match.StakeCoin
	}
	return "", 0, firstHuman.UserID, match.StakeCoin
}

func participantByUserID(participants []dicedomain.Participant, userID int64) dicedomain.Participant {
	for _, participant := range participants {
		if participant.UserID == userID {
			return participant
		}
	}
	return dicedomain.Participant{}
}

func (s *Service) rollParticipants(participants []dicedomain.Participant) error {
	for index := range participants {
		points := make([]int32, 0, dicedomain.PointsPerParticipant)
		for roll := 0; roll < dicedomain.PointsPerParticipant; roll++ {
			// crypto/rand 返回 0-5，这里加 1 映射成真实骰子点数 1-6。
			value, err := s.randomInt(6)
			if err != nil {
				return err
			}
			points = append(points, int32(value+1))
		}
		participants[index].DicePoints = points
	}
	return nil
}

func (s *Service) refundDebitedParticipants(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) error {
	var firstErr error
	for _, participant := range match.Participants {
		if !alreadyDebited(participant.Status) {
			continue
		}
		// 扣款阶段失败时的退款同样走 game_orders + wallet 幂等，重复补偿不会多退。
		order, balanceAfter, err := s.applyGameCoinChange(ctx, requestID, match, participant, "refund", participant.StakeCoin)
		if err != nil {
			if firstErr == nil {
				firstErr = err
			}
			continue
		}
		if err := s.repository.MarkDiceParticipantRefundSucceeded(ctx, match.AppCode, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	return firstErr
}

func (s *Service) applyGameCoinChange(ctx context.Context, requestID string, match dicedomain.Match, participant dicedomain.Participant, opType string, amount int64) (gamedomain.GameOrder, int64, error) {
	if amount <= 0 {
		return gamedomain.GameOrder{}, participant.BalanceAfter, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
	}
	// provider_order_id 按 match/user/op 固定，确保同一参与者同一动作在 game_orders 和 wallet-service 两边都可幂等重放。
	providerOrderID := diceProviderOrderID(match.MatchID, participant.UserID, opType)
	// request_hash 覆盖会影响账务结果的字段；同一个 provider_order_id 携带不同金额或方向会被订单层拒绝。
	requestHash := stableHash(fmt.Sprintf("%s|%s|%s|%d|%d|%d", match.AppCode, match.MatchID, opType, participant.UserID, amount, match.RoundNo))
	order := gamedomain.GameOrder{
		AppCode:         match.AppCode,
		OrderID:         "gord_" + stableHash(match.AppCode+"|"+match.PlatformCode+"|"+providerOrderID),
		PlatformCode:    match.PlatformCode,
		ProviderOrderID: providerOrderID,
		ProviderRoundID: match.MatchID,
		GameID:          match.GameID,
		ProviderGameID:  match.ProviderGameID,
		UserID:          participant.UserID,
		RoomID:          match.RoomID,
		RegionID:        match.RegionID,
		CountryID:       match.CountryID,
		OpType:          opType,
		CoinAmount:      amount,
		Status:          gamedomain.OrderStatusWalletApplying,
		RequestHash:     requestHash,
	}
	order, exists, err := s.repository.CreateGameOrder(ctx, order)
	if err != nil {
		return gamedomain.GameOrder{}, 0, err
	}
	if exists && order.Status == gamedomain.OrderStatusSucceeded {
		// game_orders 已成功说明 wallet 改账和 outbox 都已经落库；直接返回旧账后余额，避免再次触碰钱包。
		return order, order.WalletBalanceAfter, nil
	}

	// 钱包是 COIN owner；game-service 只提交游戏改账命令，余额版本、资产账户和账本分录都由 wallet-service 保证。
	walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
		RequestId:       strings.TrimSpace(requestID),
		AppCode:         match.AppCode,
		CommandId:       "game:" + providerOrderID,
		UserId:          participant.UserID,
		PlatformCode:    match.PlatformCode,
		GameId:          match.GameID,
		ProviderOrderId: providerOrderID,
		ProviderRoundId: match.MatchID,
		OpType:          opType,
		CoinAmount:      amount,
		RoomId:          match.RoomID,
		RequestHash:     requestHash,
	})
	nowMS := s.now().UnixMilli()
	if err != nil {
		status := gamedomain.OrderStatusFailed
		if xerr.IsCode(err, xerr.IdempotencyConflict) {
			// 钱包侧幂等冲突代表 command_id 重放但 payload 不一致，订单状态必须标 conflict 便于排查。
			status = gamedomain.OrderStatusConflict
		}
		_ = s.repository.MarkOrderFailed(ctx, match.AppCode, order.OrderID, status, walletFailureCode(err), err.Error(), nowMS)
		return order, 0, err
	}
	// MarkOrderSucceeded 会同步写 game_outbox，并且 debit 订单额外写 game_level_event_outbox。
	if err := s.repository.MarkOrderSucceeded(ctx, match.AppCode, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMS); err != nil {
		return order, 0, err
	}
	order.WalletTransactionID = walletResp.GetWalletTransactionId()
	order.WalletBalanceAfter = walletResp.GetBalanceAfter()
	order.Status = gamedomain.OrderStatusSucceeded
	return order, walletResp.GetBalanceAfter(), nil
}

func alreadyDebited(status string) bool {
	switch status {
	case dicedomain.ParticipantStatusDebitSucceeded, dicedomain.ParticipantStatusPayoutSucceeded, dicedomain.ParticipantStatusRefundSucceeded, dicedomain.ParticipantStatusSettled:
		return true
	default:
		return false
	}
}

func diceProviderOrderID(matchID string, userID int64, opType string) string {
	return fmt.Sprintf("%s:%s:%d:%s", dicedomain.ProviderOrderPrefix, strings.TrimSpace(matchID), userID, strings.TrimSpace(opType))
}

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

func cryptoRandomInt(max int) (int, error) {
	if max <= 0 {
		return 0, xerr.New(xerr.InvalidArgument, "random max is invalid")
	}
	value, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
	if err != nil {
		return 0, err
	}
	return int(value.Int64()), nil
}

func walletFailureCode(err error) string {
	if code := xerr.CodeOf(err); code != "" {
		return string(code)
	}
	return string(xerr.ReasonFromGRPC(err))
}
