package mysql

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

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

type diceQuerier interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

// CreateDiceMatch 在同一个事务内写入 match 和首个参与者，保证创建后不会出现空局。
func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match) (dicedomain.Match, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return dicedomain.Match{}, err
	}
	defer func() { _ = tx.Rollback() }()

	match.AppCode = appcode.Normalize(match.AppCode)
	// match 和首个 participant 必须同事务提交；否则创建成功但参与者缺失会导致后续 roll 永远人数不足。
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO game_dice_matches (
		   app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, country_id,
		   min_players, max_players, current_players, stake_coin, round_no, status, result,
		   join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
		   fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		match.AppCode, match.MatchID, match.GameID, match.PlatformCode, match.ProviderGameID, match.RoomID, match.RegionID, match.CountryID,
		match.MinPlayers, match.MaxPlayers, match.CurrentPlayers, match.StakeCoin, match.RoundNo, match.Status, match.Result,
		match.JoinDeadlineMS, match.ReadyAtMS, match.CreatedAtMS, match.UpdatedAtMS, match.SettledAtMS, match.CanceledAtMS,
		match.FeeBPS, match.PoolBPS, match.MatchMode, match.ForcedResult, match.PoolDeltaCoin,
	); err != nil {
		return dicedomain.Match{}, err
	}
	for _, participant := range match.Participants {
		// 创建阶段只保存用户的座位和预选手势；dice_points_json 仍等到 roll/settlement 阶段才写入不可变结果标记。
		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), ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			match.AppCode, match.MatchID, participant.UserID, normalizeParticipantType(participant.ParticipantType), participant.SeatNo, participant.Status, participant.StakeCoin,
			dicePointsJSON(participant.DicePoints), strings.TrimSpace(participant.RPSGesture), participant.Result, participant.PayoutCoin, participant.DebitOrderID,
			participant.PayoutOrderID, participant.RefundOrderID, participant.BalanceAfter, participant.JoinedAtMS, participant.UpdatedAtMS,
		); err != nil {
			return dicedomain.Match{}, err
		}
	}
	if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchCreated, match, match.CreatedAtMS); err != nil {
		return dicedomain.Match{}, err
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Match{}, err
	}
	// 提交后重新读取，返回值使用数据库里的排序、默认值和 JSON 解析结果，不信任入参原对象。
	return r.GetDiceMatch(ctx, match.AppCode, match.MatchID)
}

// JoinDiceMatch 只在 match 行锁内分配座位和增加人数；外部钱包调用不会持有这把锁。
func (r *Repository) JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID 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)
	// 锁 match 主行后再检查状态和人数，保证并发 join 时只有一个事务能看到并更新同一份 current_players。
	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.JoinDeadlineMS > 0 && nowMs > match.JoinDeadlineMS {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match join window is closed")
	}

	var existing int
	if err := tx.QueryRowContext(ctx,
		`SELECT COUNT(*)
		   FROM game_dice_participants
		  WHERE app_code = ? AND match_id = ? AND user_id = ?`,
		app, match.MatchID, userID,
	).Scan(&existing); err != nil {
		return dicedomain.Match{}, err
	}
	if existing > 0 {
		// 重复点击 join 直接返回当前局快照，不制造第二个座位，也不把重复请求当错误打断客户端刷新。
		if err := tx.Commit(); err != nil {
			return dicedomain.Match{}, err
		}
		return r.GetDiceMatch(ctx, app, match.MatchID)
	}
	if match.CurrentPlayers >= match.MaxPlayers {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is full")
	}

	var seatNo int32
	// seat_no 在同一把 match 锁下用 max+1 分配；配合唯一索引防止未来修改锁粒度后出现重复座位。
	if err := tx.QueryRowContext(ctx,
		`SELECT COALESCE(MAX(seat_no), 0) + 1
		   FROM game_dice_participants
		  WHERE app_code = ? AND match_id = ?`,
		app, 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, ?, ?)`,
		app, match.MatchID, userID, dicedomain.ParticipantTypeUser, seatNo, dicedomain.ParticipantStatusJoined, match.StakeCoin, "[]", strings.TrimSpace(rpsGesture), nowMs, nowMs,
	); err != nil {
		return dicedomain.Match{}, err
	}
	nextPlayers := match.CurrentPlayers + 1
	nextStatus := dicedomain.MatchStatusJoining
	readyAtMS := match.ReadyAtMS
	if nextPlayers >= match.MinPlayers {
		nextStatus = dicedomain.MatchStatusReady
		if readyAtMS == 0 {
			readyAtMS = nowMs
		}
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_dice_matches
		    SET current_players = current_players + 1, status = ?, ready_at_ms = ?, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		nextStatus, readyAtMS, nowMs, app, match.MatchID,
	); err != nil {
		return dicedomain.Match{}, err
	}
	match.CurrentPlayers = nextPlayers
	match.Status = nextStatus
	match.ReadyAtMS = readyAtMS
	match.UpdatedAtMS = nowMs
	participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, false)
	if err != nil {
		return dicedomain.Match{}, err
	}
	match.Participants = participants
	if nextStatus == dicedomain.MatchStatusReady {
		if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchReady, match, nowMs); err != nil {
			return dicedomain.Match{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Match{}, err
	}
	// join 返回完整 participants，H5 可以直接刷新座位，不需要再额外查一次。
	return r.GetDiceMatch(ctx, app, match.MatchID)
}

func (r *Repository) GetDiceMatch(ctx context.Context, appCode string, matchID string) (dicedomain.Match, error) {
	app := appcode.Normalize(appCode)
	// 查询接口不加行锁，只读取当前已提交事实；所有会改变状态的入口都有单独的锁定方法。
	match, err := queryDiceMatch(ctx, r.db, app, strings.TrimSpace(matchID), false)
	if err != nil {
		return dicedomain.Match{}, err
	}
	participants, err := queryDiceParticipants(ctx, r.db, app, match.MatchID, false)
	if err != nil {
		return dicedomain.Match{}, err
	}
	match.Participants = participants
	return match, nil
}

// ClaimDiceMatchForRoll 把可结算局切到 settling；重复 roll 只能拿到同一局事实继续幂等补偿。
func (r *Repository) ClaimDiceMatchForRoll(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，再锁 participants，所有结算入口保持固定锁顺序，降低死锁概率。
	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
	}
	match.Participants = participants
	if match.Status == dicedomain.MatchStatusSettled {
		// 已结算局允许 roll 重试读取结果，但不再改变任何状态。
		if err := tx.Commit(); err != nil {
			return dicedomain.Match{}, err
		}
		return match, nil
	}
	if !diceMatchAllowsRoll(match.Status) {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not rollable")
	}
	if len(participants) < int(match.MinPlayers) {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match does not have enough participants")
	}
	if !diceMatchHasParticipant(participants, userID) {
		// 非参与者不能替别人开奖；权限判断放在锁内，避免刚 join 的并发事务未提交时误判状态。
		return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required")
	}
	if match.Result == dicedomain.ParticipantResultDraw {
		// 上一轮和局只用于 H5 展示点数；再次 roll 时必须清空旧点数和旧结果，避免 SaveDiceRolls 误判为幂等重试。
		if _, err := tx.ExecContext(ctx,
			`UPDATE game_dice_participants
			    SET dice_points_json = CAST('[]' AS JSON), result = '', payout_coin = 0, updated_at_ms = ?
			  WHERE app_code = ? AND match_id = ?`,
			nowMs, app, match.MatchID,
		); err != nil {
			return dicedomain.Match{}, err
		}
		for index := range match.Participants {
			match.Participants[index].DicePoints = nil
			match.Participants[index].Result = ""
			match.Participants[index].PayoutCoin = 0
			match.Participants[index].UpdatedAtMS = nowMs
		}
		match.Result = ""
	}
	// 状态推进到 settling 后立即提交，外部钱包调用不持有行锁；失败补偿靠订单幂等和状态字段继续推进。
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_dice_matches
		    SET status = ?, result = ?, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		dicedomain.MatchStatusSettling, match.Result, nowMs, app, match.MatchID,
	); err != nil {
		return dicedomain.Match{}, err
	}
	match.Status = dicedomain.MatchStatusSettling
	match.UpdatedAtMS = nowMs
	if err := tx.Commit(); err != nil {
		return dicedomain.Match{}, err
	}
	return match, nil
}

// SaveDiceDrawForReroll 保存本轮和局点数，但不推进派奖；match 保持 ready，让 H5 2 秒后继续调用 roll。
func (r *Repository) SaveDiceDrawForReroll(ctx context.Context, match dicedomain.Match, 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(match.AppCode)
	locked, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(match.MatchID), true)
	if err != nil {
		return dicedomain.Match{}, err
	}
	if locked.Status == dicedomain.MatchStatusSettled {
		participants, err := queryDiceParticipants(ctx, tx, app, locked.MatchID, true)
		if err != nil {
			return dicedomain.Match{}, err
		}
		locked.Participants = participants
		if err := tx.Commit(); err != nil {
			return dicedomain.Match{}, err
		}
		return locked, nil
	}
	if locked.Status != dicedomain.MatchStatusSettling {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match is not saving draw")
	}
	for _, participant := range match.Participants {
		if _, err := tx.ExecContext(ctx,
			`UPDATE game_dice_participants
			    SET dice_points_json = CAST(? AS JSON), result = ?, payout_coin = 0, updated_at_ms = ?
			  WHERE app_code = ? AND match_id = ? AND user_id = ?`,
			dicePointsJSON(participant.DicePoints), dicedomain.ParticipantResultDraw, nowMs,
			app, match.MatchID, participant.UserID,
		); err != nil {
			return dicedomain.Match{}, err
		}
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_dice_matches
		    SET status = ?, result = ?, round_no = round_no + 1, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		dicedomain.MatchStatusReady, dicedomain.ParticipantResultDraw, nowMs, app, match.MatchID,
	); err != nil {
		return dicedomain.Match{}, err
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Match{}, err
	}
	return r.GetDiceMatch(ctx, app, match.MatchID)
}

// SaveDiceRolls 保存首次随机结果；如果重试时已经有点数，直接返回旧结果，保证不会重复随机。
func (r *Repository) SaveDiceRolls(ctx context.Context, match dicedomain.Match, 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(match.AppCode)
	// 保存点数前再次锁局和参与者，防止两个补偿 worker 同时为同一局写入不同随机结果。
	locked, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(match.MatchID), true)
	if err != nil {
		return dicedomain.Match{}, err
	}
	existing, err := queryDiceParticipants(ctx, tx, app, locked.MatchID, true)
	if err != nil {
		return dicedomain.Match{}, err
	}
	if dicedomain.HasRolls(existing) {
		// 点数已经存在时只返回旧事实；这条分支是“重试不重投”的最后防线。
		locked.Participants = existing
		if err := tx.Commit(); err != nil {
			return dicedomain.Match{}, err
		}
		return locked, nil
	}

	for _, participant := range match.Participants {
		// 点数、结果和 payout_coin 同时落参与者行，补偿任务可以只靠 participants 重建派奖命令。
		if _, err := tx.ExecContext(ctx,
			`UPDATE game_dice_participants
			    SET dice_points_json = CAST(? AS JSON), result = ?, payout_coin = ?, updated_at_ms = ?
			  WHERE app_code = ? AND match_id = ? AND user_id = ?`,
			dicePointsJSON(participant.DicePoints), participant.Result, participant.PayoutCoin, nowMs,
			app, match.MatchID, participant.UserID,
		); err != nil {
			return dicedomain.Match{}, err
		}
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_dice_matches
		    SET status = ?, result = ?, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		dicedomain.MatchStatusPayoutApplying, match.Result, nowMs, app, match.MatchID,
	); err != nil {
		return dicedomain.Match{}, err
	}
	if err := tx.Commit(); err != nil {
		return dicedomain.Match{}, err
	}
	// 返回提交后的完整事实，确保 service 后续派奖使用数据库中已固定的点数和 payout。
	return r.GetDiceMatch(ctx, app, match.MatchID)
}

func (r *Repository) MarkDiceParticipantDebitSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
	return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusDebitSucceeded, "debit_order_id", orderID, balanceAfter, nowMs)
}

func (r *Repository) MarkDiceParticipantDebitFailed(ctx context.Context, appCode string, matchID string, userID int64, orderID string, nowMs int64) error {
	return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusDebitFailed, "debit_order_id", orderID, 0, nowMs)
}

func (r *Repository) MarkDiceParticipantPayoutSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
	return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusPayoutSucceeded, "payout_order_id", orderID, balanceAfter, nowMs)
}

func (r *Repository) MarkDiceParticipantRefundSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
	return r.updateDiceParticipantOrder(ctx, appCode, matchID, userID, dicedomain.ParticipantStatusRefundSucceeded, "refund_order_id", orderID, balanceAfter, nowMs)
}

func (r *Repository) updateDiceParticipantOrder(ctx context.Context, appCode string, matchID string, userID int64, status string, orderColumn string, orderID string, balanceAfter int64, nowMs int64) error {
	// orderColumn 是 SQL 标识符，不能作为参数绑定；必须用白名单避免外部输入拼进 SQL。
	switch orderColumn {
	case "debit_order_id", "payout_order_id", "refund_order_id":
	default:
		return xerr.New(xerr.InvalidArgument, "dice participant order column is invalid")
	}
	_, err := r.db.ExecContext(ctx,
		`UPDATE game_dice_participants
		    SET status = ?, `+orderColumn+` = ?, balance_after = ?, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ? AND user_id = ?`,
		status, strings.TrimSpace(orderID), balanceAfter, nowMs, appcode.Normalize(appCode), strings.TrimSpace(matchID), userID,
	)
	return err
}

func (r *Repository) MarkDiceMatchSettled(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	app := appcode.Normalize(appCode)
	// match 和 participants 在同一事务内改为 settled，客户端不会看到局已结算但参与者还停在 payout 状态。
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_dice_matches
		    SET status = ?, result = ?, updated_at_ms = ?, settled_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		dicedomain.MatchStatusSettled, strings.TrimSpace(result), nowMs, nowMs, app, strings.TrimSpace(matchID),
	); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_dice_participants
		    SET status = ?, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		dicedomain.ParticipantStatusSettled, nowMs, app, strings.TrimSpace(matchID),
	); err != nil {
		return err
	}
	match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
	if err != nil {
		return err
	}
	participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
	if err != nil {
		return err
	}
	match.Participants = participants
	if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchSettled, match, nowMs); err != nil {
		return err
	}
	return tx.Commit()
}

func (r *Repository) MarkDiceMatchFailed(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	app := appcode.Normalize(appCode)
	if _, err := tx.ExecContext(ctx,
		`UPDATE game_dice_matches
		    SET status = ?, result = ?, updated_at_ms = ?
		  WHERE app_code = ? AND match_id = ?`,
		dicedomain.MatchStatusFailed, strings.TrimSpace(result), nowMs, app, strings.TrimSpace(matchID),
	); err != nil {
		return err
	}
	match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
	if err != nil {
		return err
	}
	participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
	if err != nil {
		return err
	}
	match.Participants = participants
	if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchFailed, match, nowMs); err != nil {
		return err
	}
	return tx.Commit()
}

func insertSelfGameMatchOutbox(ctx context.Context, tx *sql.Tx, eventType string, match dicedomain.Match, nowMs int64) error {
	match.AppCode = appcode.Normalize(match.AppCode)
	match.GameID = strings.TrimSpace(match.GameID)
	if match.AppCode == "" || match.MatchID == "" || match.GameID == "" || nowMs <= 0 {
		return xerr.New(xerr.InvalidArgument, "self game match event is incomplete")
	}
	payload := selfGameMatchOutboxPayload(match, nowMs)
	raw, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	userID := int64(0)
	if len(match.Participants) > 0 {
		userID = match.Participants[0].UserID
	}
	// 自研局级事件没有单笔订单语义，order_id/op_type/coin_amount 只保留空值；统计服务按 event_type 解析 payload。
	_, err = tx.ExecContext(ctx, `
		INSERT IGNORE INTO game_outbox (
			app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
			coin_amount, payload_json, status, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, '', ?, ?, ?, '', 0, CAST(? AS JSON), 'pending', ?, ?)`,
		match.AppCode, eventType+":"+match.MatchID, eventType, userID, strings.TrimSpace(match.PlatformCode),
		match.GameID, string(raw), nowMs, nowMs,
	)
	return err
}

func selfGameMatchOutboxPayload(match dicedomain.Match, nowMs int64) map[string]any {
	participants := make([]map[string]any, 0, len(match.Participants))
	userStakeCoin, robotStakeCoin, payoutCoin, refundCoin := int64(0), int64(0), int64(0), int64(0)
	userParticipants, robotParticipants, winners, losers, draws := int64(0), int64(0), int64(0), int64(0), int64(0)
	for _, participant := range match.Participants {
		isRobot := participant.ParticipantType == dicedomain.ParticipantTypeRobot
		if isRobot {
			robotParticipants++
			robotStakeCoin += participant.StakeCoin
		} else {
			userParticipants++
			userStakeCoin += participant.StakeCoin
		}
		switch participant.Result {
		case dicedomain.ParticipantResultWin:
			winners++
		case dicedomain.ParticipantResultLose:
			losers++
		case dicedomain.ParticipantResultDraw:
			draws++
			if !isRobot {
				refundCoin += participant.PayoutCoin
			}
		}
		if !isRobot && participant.Result != dicedomain.ParticipantResultDraw {
			payoutCoin += participant.PayoutCoin
		}
		netWinCoin := participant.PayoutCoin
		if !isRobot {
			netWinCoin -= participant.StakeCoin
		}
		participants = append(participants, map[string]any{
			"user_id":          participant.UserID,
			"participant_type": participant.ParticipantType,
			"seat_no":          participant.SeatNo,
			"status":           participant.Status,
			"stake_coin":       participant.StakeCoin,
			"dice_points":      participant.DicePoints,
			"rps_gesture":      strings.TrimSpace(participant.RPSGesture),
			"result":           strings.TrimSpace(participant.Result),
			"payout_coin":      participant.PayoutCoin,
			"net_win_coin":     netWinCoin,
			"joined_at_ms":     participant.JoinedAtMS,
			"updated_at_ms":    participant.UpdatedAtMS,
		})
	}
	waitMS, durationMS := int64(0), int64(0)
	if match.ReadyAtMS > 0 && match.CreatedAtMS > 0 {
		waitMS = match.ReadyAtMS - match.CreatedAtMS
	}
	finishedAtMS := match.SettledAtMS
	if finishedAtMS == 0 {
		finishedAtMS = match.CanceledAtMS
	}
	if finishedAtMS > 0 && match.CreatedAtMS > 0 {
		durationMS = finishedAtMS - match.CreatedAtMS
	}
	return map[string]any{
		"match_id":             match.MatchID,
		"game_id":              strings.TrimSpace(match.GameID),
		"platform_code":        strings.TrimSpace(match.PlatformCode),
		"provider_game_id":     strings.TrimSpace(match.ProviderGameID),
		"room_id":              strings.TrimSpace(match.RoomID),
		"region_id":            match.RegionID,
		"country_id":           match.CountryID,
		"stake_coin":           match.StakeCoin,
		"min_players":          match.MinPlayers,
		"max_players":          match.MaxPlayers,
		"current_players":      match.CurrentPlayers,
		"status":               strings.TrimSpace(match.Status),
		"result":               strings.TrimSpace(match.Result),
		"match_mode":           strings.TrimSpace(match.MatchMode),
		"forced_result":        strings.TrimSpace(match.ForcedResult),
		"fee_bps":              match.FeeBPS,
		"pool_bps":             match.PoolBPS,
		"pool_delta_coin":      match.PoolDeltaCoin,
		"user_participants":    userParticipants,
		"robot_participants":   robotParticipants,
		"winner_count":         winners,
		"loser_count":          losers,
		"draw_count":           draws,
		"user_stake_coin":      userStakeCoin,
		"robot_stake_coin":     robotStakeCoin,
		"payout_coin":          payoutCoin,
		"refund_coin":          refundCoin,
		"platform_profit_coin": userStakeCoin - payoutCoin - refundCoin,
		"join_deadline_ms":     match.JoinDeadlineMS,
		"ready_at_ms":          match.ReadyAtMS,
		"created_at_ms":        match.CreatedAtMS,
		"updated_at_ms":        match.UpdatedAtMS,
		"settled_at_ms":        match.SettledAtMS,
		"canceled_at_ms":       match.CanceledAtMS,
		"wait_ms":              waitMS,
		"duration_ms":          durationMS,
		"participants":         participants,
		"event_recorded_at_ms": nowMs,
	}
}

func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) (dicedomain.Match, error) {
	// forUpdate 只给状态推进路径使用；读路径不加锁，避免普通刷新阻塞 join/roll。
	query := `SELECT app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, country_id,
	                 min_players, max_players, current_players, stake_coin, round_no, status, result,
	                 join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
	                 fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
	            FROM game_dice_matches
	           WHERE app_code = ? AND match_id = ?`
	if forUpdate {
		query += ` FOR UPDATE`
	}
	row := q.QueryRowContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID))
	var match dicedomain.Match
	if err := row.Scan(&match.AppCode, &match.MatchID, &match.GameID, &match.PlatformCode, &match.ProviderGameID, &match.RoomID, &match.RegionID, &match.CountryID, &match.MinPlayers, &match.MaxPlayers, &match.CurrentPlayers, &match.StakeCoin, &match.RoundNo, &match.Status, &match.Result, &match.JoinDeadlineMS, &match.ReadyAtMS, &match.CreatedAtMS, &match.UpdatedAtMS, &match.SettledAtMS, &match.CanceledAtMS, &match.FeeBPS, &match.PoolBPS, &match.MatchMode, &match.ForcedResult, &match.PoolDeltaCoin); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
		}
		return dicedomain.Match{}, err
	}
	return match, nil
}

func queryDiceParticipants(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) ([]dicedomain.Participant, error) {
	// participants 始终按座位排序，HTTP 响应和补偿重放都能得到稳定顺序。
	query := `SELECT app_code, match_id, user_id, participant_type, seat_no, status, stake_coin,
	                 COALESCE(CAST(dice_points_json AS CHAR), '[]'), rps_gesture, result, payout_coin,
	                 debit_order_id, payout_order_id, refund_order_id, balance_after, joined_at_ms, updated_at_ms
	            FROM game_dice_participants
	           WHERE app_code = ? AND match_id = ?
	           ORDER BY seat_no ASC`
	if forUpdate {
		query += ` FOR UPDATE`
	}
	rows, err := q.QueryContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID))
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	participants := []dicedomain.Participant{}
	for rows.Next() {
		var participant dicedomain.Participant
		var pointsJSON string
		if err := rows.Scan(&participant.AppCode, &participant.MatchID, &participant.UserID, &participant.ParticipantType, &participant.SeatNo, &participant.Status, &participant.StakeCoin, &pointsJSON, &participant.RPSGesture, &participant.Result, &participant.PayoutCoin, &participant.DebitOrderID, &participant.PayoutOrderID, &participant.RefundOrderID, &participant.BalanceAfter, &participant.JoinedAtMS, &participant.UpdatedAtMS); err != nil {
			return nil, err
		}
		participant.ParticipantType = normalizeParticipantType(participant.ParticipantType)
		participant.DicePoints = parseDicePoints(pointsJSON)
		participants = append(participants, participant)
	}
	return participants, rows.Err()
}

func diceMatchAllowsJoin(status string) bool {
	// 只有未进入结算的局允许加入；settling 之后人数和奖池都必须冻结。
	switch status {
	case dicedomain.MatchStatusCreated, dicedomain.MatchStatusJoining:
		return true
	default:
		return false
	}
}

func diceMatchAllowsRoll(status string) bool {
	// settling/payout_applying 允许再次进入，是为了补偿同一局未完成的钱包步骤，不代表重新开奖。
	switch status {
	case dicedomain.MatchStatusCreated, dicedomain.MatchStatusJoining, dicedomain.MatchStatusReady, dicedomain.MatchStatusSettling, dicedomain.MatchStatusPayoutApplying:
		return true
	default:
		return false
	}
}

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

func dicePointsJSON(points []int32) string {
	if len(points) == 0 {
		return "[]"
	}
	raw, err := json.Marshal(points)
	if err != nil {
		return "[]"
	}
	return string(raw)
}

func parseDicePoints(raw string) []int32 {
	var points []int32
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &points); err != nil {
		// JSON 异常按空点数处理，让上层 Score/HasRolls 决定是否能继续结算，不在扫描阶段吞掉整行。
		return nil
	}
	return points
}
