package mysql

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	"hyapp/pkg/appcode"
	"hyapp/pkg/gamemq"
	"hyapp/pkg/xerr"
	dicedomain "hyapp/services/game-service/internal/domain/dice"
	selfgame "hyapp/services/game-service/internal/domain/selfgame"
)

// GetDiceConfig 读取自研游戏配置并带上所有档位奖池余额汇总；后台列表只展示汇总，真实准入和调账必须继续按 stake_coin 隔离。
func (r *Repository) GetDiceConfig(ctx context.Context, appCode string, gameID string) (dicedomain.Config, error) {
	app := appcode.Normalize(appCode)
	gameID = normalizeDiceGameID(gameID)
	query := `SELECT c.app_code, c.game_id, c.status, COALESCE(CAST(c.stake_options_json AS CHAR), '[]'),
		                 c.fee_bps, c.pool_bps, c.min_players, c.max_players, c.robot_enabled,
		                 c.robot_match_wait_ms, c.created_at_ms, c.updated_at_ms,
		                 COALESCE(p.balance_coin, 0)
		            FROM game_self_game_configs c
		       LEFT JOIN (
		                 SELECT app_code, game_id, COALESCE(SUM(balance_coin), 0) AS balance_coin
		                   FROM game_self_game_stake_pools
		                  GROUP BY app_code, game_id
		                ) p ON p.app_code = c.app_code AND p.game_id = c.game_id
		           WHERE c.app_code = ? AND c.game_id = ?`
	var config dicedomain.Config
	var optionsJSON string
	if err := r.db.QueryRowContext(ctx, query, app, gameID).Scan(
		&config.AppCode, &config.GameID, &config.Status, &optionsJSON,
		&config.FeeBPS, &config.PoolBPS, &config.MinPlayers, &config.MaxPlayers, &config.RobotEnabled,
		&config.RobotMatchWaitMS, &config.CreatedAtMS, &config.UpdatedAtMS, &config.PoolBalanceCoin,
	); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return dicedomain.Config{}, xerr.New(xerr.NotFound, "dice config not found")
		}
		return dicedomain.Config{}, err
	}
	config.StakeOptions = dicedomain.ParseStakeOptions(optionsJSON)
	return dicedomain.NormalizeConfig(config)
}

// ListSelfGameConfigs 返回 dice、rock 等自研游戏配置；后台列表直接以配置表为准，避免依赖第三方 catalog。
func (r *Repository) ListSelfGameConfigs(ctx context.Context, appCode string) ([]dicedomain.Config, error) {
	app := appcode.Normalize(appCode)
	rows, err := r.db.QueryContext(ctx,
		`SELECT c.app_code, c.game_id, c.status, COALESCE(CAST(c.stake_options_json AS CHAR), '[]'),
		        c.fee_bps, c.pool_bps, c.min_players, c.max_players, c.robot_enabled,
		        c.robot_match_wait_ms,
			        c.created_at_ms, c.updated_at_ms, COALESCE(p.balance_coin, 0)
			   FROM game_self_game_configs c
	      LEFT JOIN (
	                SELECT app_code, game_id, COALESCE(SUM(balance_coin), 0) AS balance_coin
	                  FROM game_self_game_stake_pools
	                 GROUP BY app_code, game_id
	               ) p ON p.app_code = c.app_code AND p.game_id = c.game_id
			  WHERE c.app_code = ?
			  ORDER BY c.game_id ASC`,
		app,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	configs := []dicedomain.Config{}
	for rows.Next() {
		var config dicedomain.Config
		var optionsJSON string
		if err := rows.Scan(
			&config.AppCode, &config.GameID, &config.Status, &optionsJSON,
			&config.FeeBPS, &config.PoolBPS, &config.MinPlayers, &config.MaxPlayers, &config.RobotEnabled,
			&config.RobotMatchWaitMS, &config.CreatedAtMS, &config.UpdatedAtMS, &config.PoolBalanceCoin,
		); err != nil {
			return nil, err
		}
		config.StakeOptions = dicedomain.ParseStakeOptions(optionsJSON)
		normalized, err := dicedomain.NormalizeConfig(config)
		if err != nil {
			return nil, err
		}
		configs = append(configs, normalized)
	}
	return configs, rows.Err()
}

// UpsertDiceConfig 保存后台可热更新配置，并确保每个启用下注档位都有独立奖池配置；旧聚合奖池表不再参与新逻辑。
func (r *Repository) UpsertDiceConfig(ctx context.Context, config dicedomain.Config, nowMs int64) (dicedomain.Config, error) {
	normalized, err := dicedomain.NormalizeConfig(config)
	if err != nil {
		return dicedomain.Config{}, err
	}
	normalized.AppCode = appcode.Normalize(normalized.AppCode)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return dicedomain.Config{}, err
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO game_self_game_configs (
		   app_code, game_id, status, stake_options_json, fee_bps, pool_bps,
		   min_players, max_players, robot_enabled, robot_match_wait_ms, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE
		   status = VALUES(status),
		   stake_options_json = VALUES(stake_options_json),
		   fee_bps = VALUES(fee_bps),
		   pool_bps = VALUES(pool_bps),
		   min_players = VALUES(min_players),
		   max_players = VALUES(max_players),
		   robot_enabled = VALUES(robot_enabled),
		   robot_match_wait_ms = VALUES(robot_match_wait_ms),
		   updated_at_ms = VALUES(updated_at_ms)`,
		normalized.AppCode, normalized.GameID, normalized.Status, dicedomain.StakeOptionsJSON(normalized.StakeOptions),
		normalized.FeeBPS, normalized.PoolBPS, normalized.MinPlayers, normalized.MaxPlayers, normalized.RobotEnabled,
		normalized.RobotMatchWaitMS, nowMs, nowMs,
	); err != nil {
		return dicedomain.Config{}, err
	}
	for _, option := range normalized.StakeOptions {
		if !option.Enabled || option.StakeCoin <= 0 {
			continue
		}
		defaultPool := selfgame.DefaultStakePool(normalized.AppCode, normalized.GameID, option.StakeCoin, nowMs)
		// 配置保存时只补齐档位奖池元数据，不挪旧奖池余额；运营需要按档位单独调账后，匹配策略才会允许该档机器人补位。
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO game_self_game_stake_pools (
			   app_code, game_id, stake_coin, balance_coin, reserved_coin,
			   target_balance_coin, safe_balance_coin, warning_balance_coin, danger_balance_coin,
			   status, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, 0, 0, ?, ?, ?, ?, 'active', ?, ?)
			 ON DUPLICATE KEY UPDATE
			   target_balance_coin = IF(target_balance_coin = 0, VALUES(target_balance_coin), target_balance_coin),
			   safe_balance_coin = IF(safe_balance_coin = 0, VALUES(safe_balance_coin), safe_balance_coin),
			   warning_balance_coin = IF(warning_balance_coin = 0, VALUES(warning_balance_coin), warning_balance_coin),
			   danger_balance_coin = IF(danger_balance_coin = 0, VALUES(danger_balance_coin), danger_balance_coin),
			   updated_at_ms = VALUES(updated_at_ms)`,
			normalized.AppCode, normalized.GameID, option.StakeCoin,
			defaultPool.TargetBalanceCoin, defaultPool.SafeBalanceCoin, defaultPool.WarningBalanceCoin, defaultPool.DangerBalanceCoin,
			nowMs, nowMs,
		); err != nil {
			return dicedomain.Config{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Config{}, err
	}
	return r.GetDiceConfig(ctx, normalized.AppCode, normalized.GameID)
}

// FindAndJoinWaitingDiceMatch 在全服等待队列里原子抢一个真人局；没有命中时返回 NotFound，让 service 创建新局。
func (r *Repository) FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return dicedomain.Match{}, err
	}
	defer func() { _ = tx.Rollback() }()

	app := appcode.Normalize(appCode)
	gameID = normalizeDiceGameID(gameID)
	var matchID string
	err = tx.QueryRowContext(ctx,
		`SELECT m.match_id
		   FROM game_dice_matches m
		  WHERE m.app_code = ?
		    AND m.game_id = ?
		    AND m.room_id = ''
		    AND m.stake_coin = ?
		    AND m.status IN (?, ?)
		    AND m.current_players < m.max_players
		    AND (m.join_deadline_ms = 0 OR m.join_deadline_ms >= ?)
		    AND (m.match_mode = '' OR m.match_mode = ?)
		    AND NOT EXISTS (
		      SELECT 1 FROM game_dice_participants p
		       WHERE p.app_code = m.app_code AND p.match_id = m.match_id AND p.user_id = ?
		    )
		  ORDER BY m.created_at_ms ASC
		  LIMIT 1
		  FOR UPDATE SKIP LOCKED`,
		app, gameID, stakeCoin, dicedomain.MatchStatusCreated, dicedomain.MatchStatusJoining, nowMs, dicedomain.MatchModeHuman, userID,
	).Scan(&matchID)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found")
		}
		return dicedomain.Match{}, err
	}
	match, err := queryDiceMatch(ctx, tx, app, matchID, true)
	if err != nil {
		return dicedomain.Match{}, err
	}
	if match.MatchMode == "" {
		match.MatchMode = dicedomain.MatchModeHuman
	}
	joined, err := insertDiceParticipantLocked(ctx, tx, match, userID, dicedomain.ParticipantTypeUser, rpsGesture, nowMs)
	if err != nil {
		return dicedomain.Match{}, err
	}
	if err := updateDiceMatchAfterJoinLocked(ctx, tx, joined, match.MatchMode, match.ForcedResult, nowMs); err != nil {
		return dicedomain.Match{}, err
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Match{}, err
	}
	return r.GetDiceMatch(ctx, app, matchID)
}

// JoinDiceRobot 把一个已登记真实 App 用户作为机器人补位；机器人只参与局事实，不触发钱包扣款。
func (r *Repository) JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return dicedomain.Match{}, err
	}
	defer func() { _ = tx.Rollback() }()

	app := appcode.Normalize(appCode)
	match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
	if err != nil {
		return dicedomain.Match{}, err
	}
	if !diceMatchAllowsJoin(match.Status) {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not joinable")
	}
	if match.CurrentPlayers >= match.MaxPlayers {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is full")
	}
	joined, err := insertDiceParticipantLocked(ctx, tx, match, robotUserID, dicedomain.ParticipantTypeRobot, rpsGesture, nowMs)
	if err != nil {
		return dicedomain.Match{}, err
	}
	if err := updateDiceMatchAfterJoinLocked(ctx, tx, joined, dicedomain.MatchModeRobot, strings.TrimSpace(forcedResult), nowMs); err != nil {
		return dicedomain.Match{}, err
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Match{}, err
	}
	return r.GetDiceMatch(ctx, app, match.MatchID)
}

// CancelDiceMatch 只取消还未成局的等待局；已匹配成功后必须走正常结算或失败补偿。
func (r *Repository) CancelDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return dicedomain.Match{}, err
	}
	defer func() { _ = tx.Rollback() }()

	app := appcode.Normalize(appCode)
	match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
	if err != nil {
		return dicedomain.Match{}, err
	}
	participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
	if err != nil {
		return dicedomain.Match{}, err
	}
	if !diceMatchHasParticipant(participants, userID) {
		return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required")
	}
	if match.Status != dicedomain.MatchStatusCreated && match.Status != dicedomain.MatchStatusJoining {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not cancelable")
	}
	if len(participants) > 1 {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match already has opponent")
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_dice_matches
		    SET status = ?, canceled_at_ms = ?, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		dicedomain.MatchStatusCanceled, nowMs, nowMs, app, match.MatchID,
	); err != nil {
		return dicedomain.Match{}, err
	}
	match.Status = dicedomain.MatchStatusCanceled
	match.CanceledAtMS = nowMs
	match.UpdatedAtMS = nowMs
	match.Participants = participants
	if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchCanceled, match, nowMs); err != nil {
		return dicedomain.Match{}, err
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Match{}, err
	}
	return r.GetDiceMatch(ctx, app, match.MatchID)
}

// PickActiveDiceRobot 从当前 App 的通用机器人池里完全随机挑选一个未加入本局的机器人。
// game_self_game_robots 仍保留 game_id 字段是为了兼容旧数据和后台接口，但机器人账号本身不再按 dice/rock 隔离；
// 只要同一个 app_code 下存在 active 登记，这个真实用户就可以给任意自研游戏补位。
func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, nowMs int64) (dicedomain.Robot, error) {
	app := appcode.Normalize(appCode)
	gameID = normalizeDiceGameID(gameID)
	matchID = strings.TrimSpace(matchID)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return dicedomain.Robot{}, err
	}
	defer func() { _ = tx.Rollback() }()

	var robot dicedomain.Robot
	// 用户要求“100 个机器人就是 100 选 1”，因此候选池只过滤 app、启用状态和本局已加入机器人；
	// last_used_at_ms/used_count 只做后台观察和排查，不参与排序，避免冷却或轮转把随机池缩小成少数候选。
	// SKIP LOCKED 让并发补位跳过其它事务已经锁住的候选行，减少多个等待局同一瞬间抢到同一个机器人。
	err = tx.QueryRowContext(ctx,
		`SELECT r.app_code, ? AS game_id, r.user_id, ? AS status,
		        r.created_by_admin_id, r.created_at_ms, r.updated_at_ms,
		        r.last_used_at_ms, r.used_count
		   FROM game_self_game_robots r
		  WHERE r.app_code = ? AND r.status = ?
		    AND NOT EXISTS (
		      SELECT 1 FROM game_dice_participants p
		       WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id
		    )
		  ORDER BY RAND()
		  LIMIT 1
		  FOR UPDATE SKIP LOCKED`,
		gameID, dicedomain.RobotStatusActive, app, dicedomain.RobotStatusActive, matchID,
	).Scan(
		&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID,
		&robot.CreatedAtMS, &robot.UpdatedAtMS, &robot.LastUsedAtMS, &robot.UsedCount,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
		}
		return dicedomain.Robot{}, err
	}

	// 选中后仍记录使用水位，方便后台观察机器人命中分布；水位不反向影响下一次抽取，下一局仍从完整可用池随机。
	res, err := tx.ExecContext(ctx,
		`UPDATE game_self_game_robots
		    SET last_used_at_ms = ?, used_count = used_count + 1
		  WHERE app_code = ? AND user_id = ? AND status = ?`,
		nowMs, app, robot.UserID, dicedomain.RobotStatusActive,
	)
	if err != nil {
		return dicedomain.Robot{}, err
	}
	affected, _ := res.RowsAffected()
	if affected == 0 {
		return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Robot{}, err
	}
	robot.LastUsedAtMS = nowMs
	robot.UsedCount++
	return robot, nil
}

// AdjustSelfGameStakePool 只允许更新具体下注档位的独立奖池；旧聚合奖池表保留结构但不再作为资金来源、展示同步或兼容入口。
func (r *Repository) AdjustSelfGameStakePool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) {
	if adjustment.AmountCoin <= 0 {
		return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "dice pool amount is invalid")
	}
	if adjustment.StakeCoin <= 0 {
		return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "self game stake coin is required")
	}
	adjustment.AppCode = appcode.Normalize(adjustment.AppCode)
	adjustment.GameID = normalizeDiceGameID(adjustment.GameID)
	adjustment.Direction = strings.TrimSpace(adjustment.Direction)
	if adjustment.Direction != dicedomain.PoolDirectionIn && adjustment.Direction != dicedomain.PoolDirectionOut {
		return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "dice pool direction is invalid")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return dicedomain.PoolAdjustment{}, err
	}
	defer func() { _ = tx.Rollback() }()

	// 自研机器人策略以 app+game+stake_coin 作为资金隔离边界；后台调账和结算扣减都必须先锁住同一个档位行，
	// 再写同一事务里的档位流水。这样 1000 档奖池充足不会掩盖 100000 档奖池不足，也不会因为旧聚合余额存在而放行机器人补位。
	defaultPool := selfgame.DefaultStakePool(adjustment.AppCode, adjustment.GameID, adjustment.StakeCoin, adjustment.CreatedAtMS)
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO game_self_game_stake_pools (
		   app_code, game_id, stake_coin, balance_coin, reserved_coin,
		   target_balance_coin, safe_balance_coin, warning_balance_coin, danger_balance_coin,
		   status, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, 0, 0, ?, ?, ?, ?, 'active', ?, ?)
		 ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
		adjustment.AppCode, adjustment.GameID, adjustment.StakeCoin,
		defaultPool.TargetBalanceCoin, defaultPool.SafeBalanceCoin, defaultPool.WarningBalanceCoin, defaultPool.DangerBalanceCoin,
		adjustment.CreatedAtMS, adjustment.CreatedAtMS,
	); err != nil {
		return dicedomain.PoolAdjustment{}, err
	}
	var stakeBalance int64
	var stakeReserved int64
	if err := tx.QueryRowContext(ctx,
		`SELECT balance_coin, reserved_coin
		   FROM game_self_game_stake_pools
		  WHERE app_code = ? AND game_id = ? AND stake_coin = ?
		  FOR UPDATE`,
		adjustment.AppCode, adjustment.GameID, adjustment.StakeCoin,
	).Scan(&stakeBalance, &stakeReserved); err != nil {
		return dicedomain.PoolAdjustment{}, err
	}
	nextStakeBalance := stakeBalance + adjustment.AmountCoin
	if adjustment.Direction == dicedomain.PoolDirectionOut {
		nextStakeBalance = stakeBalance - adjustment.AmountCoin
		if nextStakeBalance < 0 {
			return dicedomain.PoolAdjustment{}, xerr.New(xerr.Conflict, "self game stake pool balance is insufficient")
		}
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_self_game_stake_pools
		    SET balance_coin = ?, updated_at_ms = ?
		  WHERE app_code = ? AND game_id = ? AND stake_coin = ?`,
		nextStakeBalance, adjustment.CreatedAtMS, adjustment.AppCode, adjustment.GameID, adjustment.StakeCoin,
	); err != nil {
		return dicedomain.PoolAdjustment{}, err
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO game_self_game_stake_pool_transactions (
		   app_code, adjustment_id, game_id, stake_coin, match_id, user_id, direction, amount_coin,
		   reserved_delta_coin, reason, balance_after, reserved_after, created_by, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`,
		adjustment.AppCode, adjustment.AdjustmentID, adjustment.GameID, adjustment.StakeCoin, adjustment.MatchID, adjustment.UserID,
		adjustment.Direction, adjustment.AmountCoin, adjustment.Reason, nextStakeBalance, stakeReserved, adjustment.CreatedBy, adjustment.CreatedAtMS,
	); err != nil {
		return dicedomain.PoolAdjustment{}, err
	}
	adjustment.BalanceAfter = nextStakeBalance
	if adjustment.MatchID != "" {
		delta := adjustment.AmountCoin
		if adjustment.Direction == dicedomain.PoolDirectionOut {
			delta = -delta
		}
		if _, err := tx.ExecContext(ctx,
			`UPDATE game_dice_matches
			    SET pool_delta_coin = pool_delta_coin + ?, updated_at_ms = ?
			  WHERE app_code = ? AND match_id = ?`,
			delta, adjustment.CreatedAtMS, adjustment.AppCode, adjustment.MatchID,
		); err != nil {
			return dicedomain.PoolAdjustment{}, err
		}
	}
	payload, err := json.Marshal(map[string]any{
		"adjustment_id": adjustment.AdjustmentID,
		"game_id":       adjustment.GameID,
		"match_id":      adjustment.MatchID,
		"user_id":       adjustment.UserID,
		"direction":     adjustment.Direction,
		"amount_coin":   adjustment.AmountCoin,
		"reason":        adjustment.Reason,
		"balance_after": adjustment.BalanceAfter,
		"created_by":    adjustment.CreatedBy,
		"created_at_ms": adjustment.CreatedAtMS,
	})
	if err != nil {
		return dicedomain.PoolAdjustment{}, err
	}
	// 奖池调整和奖池流水同事务写入 outbox；统计服务消费 owner fact 后才能同时得到入池、出池和余额快照。
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO game_outbox (
		  app_code, event_id, event_type, order_id, user_id, platform_code, game_id,
		  op_type, coin_amount, payload_json, status, worker_id, lock_until_ms,
		  retry_count, next_retry_at_ms, last_error, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, '', ?, 'self', ?, '', 0, CAST(? AS JSON), 'pending', '', 0, 0, 0, '', ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
		adjustment.AppCode, gamemq.EventTypeSelfGamePoolAdjusted+":"+adjustment.AdjustmentID, gamemq.EventTypeSelfGamePoolAdjusted,
		adjustment.UserID, adjustment.GameID, string(payload), adjustment.CreatedAtMS, adjustment.CreatedAtMS,
	); err != nil {
		return dicedomain.PoolAdjustment{}, err
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.PoolAdjustment{}, err
	}
	return adjustment, nil
}

func (r *Repository) ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error) {
	app := appcode.Normalize(appCode)
	gameID = normalizeDiceGameID(gameID)
	if pageSize <= 0 || pageSize > 200 {
		pageSize = 50
	}
	status = strings.TrimSpace(status)
	cursorID := int64(0)
	if strings.TrimSpace(cursor) != "" {
		_, _ = fmt.Sscan(strings.TrimSpace(cursor), &cursorID)
	}
	// 机器人后台展示的是“账号池”而不是“游戏池”；同一个 user_id 即使历史上登记过多个 game_id，也只展示一行。
	// 聚合状态按 active 优先，避免旧的 dice active + rock disabled 脏数据让同一个账号在列表里出现两种状态。
	args := []any{gameID, dicedomain.RobotStatusActive, dicedomain.RobotStatusActive, dicedomain.RobotStatusDisabled, app}
	query := `SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count
	            FROM (
	              SELECT app_code, ? AS game_id, user_id,
	                     IF(SUM(status = ?) > 0, ?, ?) AS status,
	                     MIN(created_by_admin_id) AS created_by_admin_id,
	                     MIN(created_at_ms) AS created_at_ms,
	                     MAX(updated_at_ms) AS updated_at_ms,
	                     MAX(last_used_at_ms) AS last_used_at_ms,
	                     MAX(used_count) AS used_count
	                FROM game_self_game_robots
	               WHERE app_code = ?`
	if cursorID > 0 {
		query += ` AND user_id > ?`
		args = append(args, cursorID)
	}
	query += ` GROUP BY app_code, user_id
	            ) robots
	           WHERE (? = '' OR status = ?)
	           ORDER BY user_id ASC LIMIT ?`
	args = append(args, status, status)
	args = append(args, int(pageSize)+1)
	rows, err := r.db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, "", err
	}
	defer rows.Close()

	robots := []dicedomain.Robot{}
	for rows.Next() {
		var robot dicedomain.Robot
		if err := rows.Scan(
			&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID,
			&robot.CreatedAtMS, &robot.UpdatedAtMS, &robot.LastUsedAtMS, &robot.UsedCount,
		); err != nil {
			return nil, "", err
		}
		robots = append(robots, robot)
	}
	if err := rows.Err(); err != nil {
		return nil, "", err
	}
	nextCursor := ""
	if len(robots) > int(pageSize) {
		nextCursor = fmt.Sprintf("%d", robots[pageSize-1].UserID)
		robots = robots[:pageSize]
	}
	return robots, nextCursor, nil
}

// RecordExploreWinner 写入真实结算后的 Square 播报事实；win_id 由 match_id+user_id 派生，重试结算只会覆盖同一条记录。
func (r *Repository) RecordExploreWinner(ctx context.Context, winner dicedomain.ExploreWinner) error {
	app := appcode.Normalize(winner.AppCode)
	winID := strings.TrimSpace(winner.WinID)
	gameCode := strings.TrimSpace(winner.GameCode)
	gameID := strings.TrimSpace(winner.GameID)
	if winID == "" || gameCode == "" || gameID == "" || winner.UserID <= 0 || winner.CoinAmount <= 0 {
		return xerr.New(xerr.InvalidArgument, "explore winner is incomplete")
	}
	nowMs := winner.UpdatedAtMS
	if nowMs <= 0 {
		nowMs = winner.WonAtMS
	}
	if nowMs <= 0 {
		nowMs = time.Now().UnixMilli()
	}
	createdAtMS := winner.CreatedAtMS
	if createdAtMS <= 0 {
		createdAtMS = nowMs
	}
	wonAtMS := winner.WonAtMS
	if wonAtMS <= 0 {
		wonAtMS = nowMs
	}
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO game_explore_winners (
		   app_code, win_id, game_code, game_id, user_id, display_name, avatar_url,
		   coin_amount, won_at_ms, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE
		   game_code = VALUES(game_code),
		   game_id = VALUES(game_id),
		   user_id = VALUES(user_id),
		   display_name = VALUES(display_name),
		   avatar_url = VALUES(avatar_url),
		   coin_amount = VALUES(coin_amount),
		   won_at_ms = VALUES(won_at_ms),
		   updated_at_ms = VALUES(updated_at_ms)`,
		app,
		winID,
		gameCode,
		gameID,
		winner.UserID,
		strings.TrimSpace(winner.DisplayName),
		strings.TrimSpace(winner.AvatarURL),
		winner.CoinAmount,
		wonAtMS,
		createdAtMS,
		nowMs,
	)
	return err
}

// ListExploreWinners 只返回当前 app 最新播报；客户端每次轮询直接用返回值替换旧列表。
func (r *Repository) ListExploreWinners(ctx context.Context, appCode string, pageSize int32) ([]dicedomain.ExploreWinner, error) {
	app := appcode.Normalize(appCode)
	if pageSize <= 0 {
		pageSize = dicedomain.DefaultExploreWinnerPageSize
	}
	if pageSize > 50 {
		pageSize = 50
	}
	rows, err := r.db.QueryContext(ctx,
		`SELECT app_code, win_id, game_code, game_id, user_id, display_name, avatar_url,
		        coin_amount, won_at_ms, created_at_ms, updated_at_ms
		   FROM game_explore_winners
		  WHERE app_code = ?
		  ORDER BY won_at_ms DESC, win_id DESC
		  LIMIT ?`,
		app, int(pageSize),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]dicedomain.ExploreWinner, 0, int(pageSize))
	for rows.Next() {
		var item dicedomain.ExploreWinner
		if err := rows.Scan(
			&item.AppCode,
			&item.WinID,
			&item.GameCode,
			&item.GameID,
			&item.UserID,
			&item.DisplayName,
			&item.AvatarURL,
			&item.CoinAmount,
			&item.WonAtMS,
			&item.CreatedAtMS,
			&item.UpdatedAtMS,
		); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return items, nil
}

func (r *Repository) RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error) {
	app := appcode.Normalize(appCode)
	// 新生成的机器人只写入默认 game_id 行；匹配和后台管理都已经按 app_code + user_id 聚合，这行只是兼容当前表主键。
	gameID = dicedomain.DefaultGameID
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()
	for _, userID := range uniquePositiveInt64(userIDs) {
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO game_self_game_robots (
			   app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms,
			   last_used_at_ms, used_count
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0)
			 ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`,
			app, gameID, userID, dicedomain.RobotStatusActive, adminID, nowMs, nowMs,
		); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	robots, _, err := r.ListDiceRobots(ctx, app, "", "", 200, "")
	return robots, err
}

func (r *Repository) SetDiceRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMs int64) (dicedomain.Robot, error) {
	app := appcode.Normalize(appCode)
	gameID = normalizeDiceGameID(gameID)
	status = strings.TrimSpace(status)
	if status != dicedomain.RobotStatusActive && status != dicedomain.RobotStatusDisabled {
		return dicedomain.Robot{}, xerr.New(xerr.InvalidArgument, "dice robot status is invalid")
	}
	res, err := r.db.ExecContext(ctx,
		`UPDATE game_self_game_robots
		    SET status = ?, updated_at_ms = ?
		  WHERE app_code = ? AND user_id = ?`,
		status, nowMs, app, userID,
	)
	if err != nil {
		return dicedomain.Robot{}, err
	}
	affected, _ := res.RowsAffected()
	if affected == 0 {
		return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
	}
	return r.getUniversalDiceRobot(ctx, app, gameID, userID)
}

// DeleteDiceRobot 只删除机器人登记关系，不删除 user-service 主账号，避免历史对局和钱包流水失去用户归属。
// 删除按 user_id 清理所有历史 game_id 行，保证后台“不区分 dice/rock”的操作不会留下另一个游戏的残余登记。
func (r *Repository) DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error {
	app := appcode.Normalize(appCode)
	res, err := r.db.ExecContext(ctx,
		`DELETE FROM game_self_game_robots
		  WHERE app_code = ? AND user_id = ?`,
		app, userID,
	)
	if err != nil {
		return err
	}
	affected, _ := res.RowsAffected()
	if affected == 0 {
		return xerr.New(xerr.NotFound, "dice robot not found")
	}
	return nil
}

func (r *Repository) getUniversalDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) (dicedomain.Robot, error) {
	app := appcode.Normalize(appCode)
	gameID = normalizeDiceGameID(gameID)
	var robot dicedomain.Robot
	err := r.db.QueryRowContext(ctx,
		`SELECT app_code, ? AS game_id, user_id,
		        IF(SUM(status = ?) > 0, ?, ?) AS status,
		        MIN(created_by_admin_id), MIN(created_at_ms), MAX(updated_at_ms),
		        MAX(last_used_at_ms), MAX(used_count)
		   FROM game_self_game_robots
		  WHERE app_code = ? AND user_id = ?
		  GROUP BY app_code, user_id`,
		gameID, dicedomain.RobotStatusActive, dicedomain.RobotStatusActive, dicedomain.RobotStatusDisabled, app, userID,
	).Scan(
		&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID,
		&robot.CreatedAtMS, &robot.UpdatedAtMS, &robot.LastUsedAtMS, &robot.UsedCount,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
		}
		return dicedomain.Robot{}, err
	}
	return robot, nil
}

func insertDiceParticipantLocked(ctx context.Context, tx *sql.Tx, match dicedomain.Match, userID int64, participantType string, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
	var seatNo int32
	if err := tx.QueryRowContext(ctx,
		`SELECT COALESCE(MAX(seat_no), 0) + 1
		   FROM game_dice_participants
		  WHERE app_code = ? AND match_id = ?`,
		match.AppCode, match.MatchID,
	).Scan(&seatNo); err != nil {
		return dicedomain.Match{}, err
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO game_dice_participants (
		   app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, rps_gesture, result,
		   payout_coin, debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, '', 0, '', '', '', 0, ?, ?)`,
		match.AppCode, match.MatchID, userID, normalizeParticipantType(participantType), seatNo,
		dicedomain.ParticipantStatusJoined, match.StakeCoin, "[]", strings.TrimSpace(rpsGesture), nowMs, nowMs,
	); err != nil {
		return dicedomain.Match{}, err
	}
	match.CurrentPlayers++
	if match.Participants == nil {
		match.Participants = []dicedomain.Participant{}
	}
	match.Participants = append(match.Participants, dicedomain.Participant{
		AppCode:         match.AppCode,
		MatchID:         match.MatchID,
		UserID:          userID,
		ParticipantType: normalizeParticipantType(participantType),
		SeatNo:          seatNo,
		Status:          dicedomain.ParticipantStatusJoined,
		StakeCoin:       match.StakeCoin,
		RPSGesture:      strings.TrimSpace(rpsGesture),
		JoinedAtMS:      nowMs,
		UpdatedAtMS:     nowMs,
	})
	return match, nil
}

func updateDiceMatchAfterJoinLocked(ctx context.Context, tx *sql.Tx, match dicedomain.Match, matchMode string, forcedResult string, nowMs int64) error {
	nextStatus := dicedomain.MatchStatusJoining
	readyAtMS := match.ReadyAtMS
	if match.CurrentPlayers >= match.MinPlayers {
		nextStatus = dicedomain.MatchStatusReady
		if readyAtMS == 0 {
			readyAtMS = nowMs
		}
	}
	_, err := tx.ExecContext(ctx,
		`UPDATE game_dice_matches
		    SET current_players = ?, status = ?, ready_at_ms = ?, match_mode = ?, forced_result = ?, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		match.CurrentPlayers, nextStatus, readyAtMS, strings.TrimSpace(matchMode), strings.TrimSpace(forcedResult), nowMs,
		match.AppCode, match.MatchID,
	)
	if err != nil {
		return err
	}
	match.Status = nextStatus
	match.ReadyAtMS = readyAtMS
	match.MatchMode = strings.TrimSpace(matchMode)
	match.ForcedResult = strings.TrimSpace(forcedResult)
	match.UpdatedAtMS = nowMs
	if nextStatus == dicedomain.MatchStatusReady {
		// ready 事件只用 INSERT IGNORE 写一次；真人 join 和机器人补位可能都走这里，幂等键保证不会重复统计匹配成功局。
		return insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchReady, match, nowMs)
	}
	return nil
}

func normalizeDiceGameID(gameID string) string {
	gameID = strings.TrimSpace(gameID)
	if gameID == "" {
		return dicedomain.DefaultGameID
	}
	return gameID
}

func normalizeParticipantType(value string) string {
	if strings.TrimSpace(value) == dicedomain.ParticipantTypeRobot {
		return dicedomain.ParticipantTypeRobot
	}
	return dicedomain.ParticipantTypeUser
}

func uniquePositiveInt64(values []int64) []int64 {
	seen := make(map[int64]struct{}, len(values))
	out := make([]int64, 0, len(values))
	for _, value := range values {
		if value <= 0 {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}
