package mysql

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"strings"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	gamedomain "hyapp/services/game-service/internal/domain/game"
	gameservice "hyapp/services/game-service/internal/service/game"
)

// Repository 是 game-service 的 MySQL 持久化入口。
type Repository struct {
	db *sql.DB
}

// Open 创建连接池并执行幂等迁移，保证本地已有数据库也能补齐游戏表。
func Open(ctx context.Context, dsn string) (*Repository, error) {
	if strings.TrimSpace(dsn) == "" {
		return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required")
	}
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, err
	}
	// 标准库默认不限制连接数，高并发下会打爆 MySQL max_connections，必须显式设上限。
	db.SetMaxOpenConns(20)
	db.SetMaxIdleConns(10)
	db.SetConnMaxLifetime(30 * time.Minute)
	if err := db.PingContext(ctx); err != nil {
		_ = db.Close()
		return nil, err
	}
	repo := &Repository{db: db}
	if err := repo.Migrate(ctx); err != nil {
		_ = db.Close()
		return nil, err
	}
	return repo, nil
}

func (r *Repository) Close() error {
	if r == nil || r.db == nil {
		return nil
	}
	return r.db.Close()
}

func (r *Repository) Ping(ctx context.Context) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	return r.db.PingContext(ctx)
}

// Migrate 与 initdb 保持同构；开发阶段不保留旧 schema 兼容分支。
func (r *Repository) Migrate(ctx context.Context) error {
	statements := []string{
		`CREATE TABLE IF NOT EXISTS game_platforms (
		  app_code VARCHAR(32) NOT NULL,
		  platform_code VARCHAR(64) NOT NULL,
		  platform_name VARCHAR(128) NOT NULL,
		  status VARCHAR(32) NOT NULL,
		  api_base_url VARCHAR(512) NOT NULL DEFAULT '',
		  adapter_type VARCHAR(64) NOT NULL DEFAULT '',
		  callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
		  callback_ip_whitelist JSON NULL,
		  adapter_config JSON NULL,
		  sort_order INT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, platform_code)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_catalog (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  platform_code VARCHAR(64) NOT NULL,
		  provider_game_id VARCHAR(128) NOT NULL,
		  game_name VARCHAR(128) NOT NULL,
		  category VARCHAR(64) NOT NULL DEFAULT '',
		  icon_url VARCHAR(512) NOT NULL DEFAULT '',
		  cover_url VARCHAR(512) NOT NULL DEFAULT '',
		  launch_mode VARCHAR(32) NOT NULL DEFAULT 'h5_popup',
		  orientation VARCHAR(32) NOT NULL DEFAULT 'portrait',
		  safe_height INT NOT NULL DEFAULT 0,
		  min_coin BIGINT NOT NULL DEFAULT 0,
		  status VARCHAR(32) NOT NULL,
		  whitelist_enabled TINYINT(1) NOT NULL DEFAULT 0,
		  sort_order INT NOT NULL DEFAULT 0,
		  tags JSON NULL,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id),
		  UNIQUE KEY uk_game_provider(app_code, platform_code, provider_game_id),
		  KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_user_whitelist (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  created_by_admin_id BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id, user_id),
		  KEY idx_game_whitelist_game_time(app_code, game_id, created_at_ms, user_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_display_rules (
		  app_code VARCHAR(32) NOT NULL,
		  rule_id VARCHAR(96) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  scene VARCHAR(64) NOT NULL,
		  region_id BIGINT NOT NULL DEFAULT 0,
		  language VARCHAR(32) NOT NULL DEFAULT '',
		  platform VARCHAR(16) NOT NULL DEFAULT '',
		  visible TINYINT(1) NOT NULL DEFAULT 1,
		  enabled TINYINT(1) NOT NULL DEFAULT 1,
		  sort_order INT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, rule_id),
		  KEY idx_game_display(app_code, scene, region_id, visible, sort_order)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_launch_sessions (
		  app_code VARCHAR(32) NOT NULL,
		  session_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  display_user_id VARCHAR(32) NOT NULL,
		  room_id VARCHAR(96) NOT NULL DEFAULT '',
		  region_id BIGINT NOT NULL DEFAULT 0,
		  country_id BIGINT NOT NULL DEFAULT 0,
		  scene VARCHAR(64) NOT NULL,
		  platform_code VARCHAR(64) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  provider_game_id VARCHAR(128) NOT NULL,
		  launch_token_hash VARCHAR(128) NOT NULL,
		  status VARCHAR(32) NOT NULL,
		  expires_at_ms BIGINT NOT NULL,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, session_id),
		  KEY idx_game_launch_user(app_code, user_id, created_at_ms),
		  KEY idx_game_launch_token(app_code, launch_token_hash)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_orders (
		  app_code VARCHAR(32) NOT NULL,
		  order_id VARCHAR(96) NOT NULL,
		  platform_code VARCHAR(64) NOT NULL,
		  provider_order_id VARCHAR(160) NOT NULL,
		  provider_round_id VARCHAR(160) NOT NULL DEFAULT '',
		  game_id VARCHAR(96) NOT NULL,
		  provider_game_id VARCHAR(128) NOT NULL,
		  user_id BIGINT NOT NULL,
		  room_id VARCHAR(96) NOT NULL DEFAULT '',
		  region_id BIGINT NOT NULL DEFAULT 0,
		  country_id BIGINT NOT NULL DEFAULT 0,
		  op_type VARCHAR(32) NOT NULL,
		  coin_amount BIGINT NOT NULL,
		  status VARCHAR(32) NOT NULL,
		  wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
		  wallet_balance_after BIGINT NOT NULL DEFAULT 0,
		  request_hash VARCHAR(128) NOT NULL,
		  raw_payload_ref VARCHAR(256) NOT NULL DEFAULT '',
		  failure_code VARCHAR(64) NOT NULL DEFAULT '',
		  failure_message VARCHAR(256) NOT NULL DEFAULT '',
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, order_id),
		  UNIQUE KEY uk_game_provider_order(app_code, platform_code, provider_order_id),
		  KEY idx_game_order_user_time(app_code, user_id, created_at_ms),
		  KEY idx_game_order_round(app_code, platform_code, provider_round_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_dice_matches (
		  app_code VARCHAR(32) NOT NULL,
		  match_id VARCHAR(96) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  platform_code VARCHAR(64) NOT NULL,
		  provider_game_id VARCHAR(128) NOT NULL,
		  room_id VARCHAR(96) NOT NULL DEFAULT '',
		  region_id BIGINT NOT NULL DEFAULT 0,
		  country_id BIGINT NOT NULL DEFAULT 0,
		  min_players INT NOT NULL,
		  max_players INT NOT NULL,
		  current_players INT NOT NULL DEFAULT 0,
		  stake_coin BIGINT NOT NULL,
		  round_no INT NOT NULL DEFAULT 1,
		  status VARCHAR(32) NOT NULL,
		  result VARCHAR(64) NOT NULL DEFAULT '',
		  join_deadline_ms BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  settled_at_ms BIGINT NOT NULL DEFAULT 0,
		  PRIMARY KEY(app_code, match_id),
		  KEY idx_game_dice_room_active(app_code, room_id, status, created_at_ms),
		  KEY idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_dice_participants (
		  app_code VARCHAR(32) NOT NULL,
		  match_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  seat_no INT NOT NULL,
			  status VARCHAR(32) NOT NULL,
			  stake_coin BIGINT NOT NULL,
			  dice_points_json JSON NULL,
			  rps_gesture VARCHAR(32) NOT NULL DEFAULT '',
			  result VARCHAR(32) NOT NULL DEFAULT '',
		  payout_coin BIGINT NOT NULL DEFAULT 0,
		  debit_order_id VARCHAR(96) NOT NULL DEFAULT '',
		  payout_order_id VARCHAR(96) NOT NULL DEFAULT '',
		  refund_order_id VARCHAR(96) NOT NULL DEFAULT '',
		  balance_after BIGINT NOT NULL DEFAULT 0,
		  joined_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, match_id, user_id),
		  UNIQUE KEY uk_game_dice_seat(app_code, match_id, seat_no),
		  KEY idx_game_dice_participant_user(app_code, user_id, updated_at_ms),
		  KEY idx_game_dice_participant_status(app_code, match_id, status, seat_no)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_explore_winners (
		  app_code VARCHAR(32) NOT NULL,
		  win_id VARCHAR(96) NOT NULL,
		  game_code VARCHAR(64) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  display_name VARCHAR(128) NOT NULL DEFAULT '',
		  avatar_url VARCHAR(512) NOT NULL DEFAULT '',
		  coin_amount BIGINT NOT NULL,
		  won_at_ms BIGINT NOT NULL,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, win_id),
		  KEY idx_game_explore_winners_time(app_code, won_at_ms, win_id),
		  KEY idx_game_explore_winners_game(app_code, game_code, won_at_ms)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_configs (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  status VARCHAR(32) NOT NULL DEFAULT 'active',
		  stake_options_json JSON NULL,
		  fee_bps INT NOT NULL DEFAULT 500,
		  pool_bps INT NOT NULL DEFAULT 100,
		  min_players INT NOT NULL DEFAULT 2,
		  max_players INT NOT NULL DEFAULT 2,
		  robot_enabled TINYINT(1) NOT NULL DEFAULT 1,
		  robot_match_wait_ms BIGINT NOT NULL DEFAULT 3000,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_room_rps_configs (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  status VARCHAR(32) NOT NULL DEFAULT 'active',
		  challenge_timeout_ms BIGINT NOT NULL DEFAULT 600000,
		  reveal_countdown_ms BIGINT NOT NULL DEFAULT 3000,
		  stake_gifts_json JSON NOT NULL,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_pools (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  balance_coin BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_pool_transactions (
		  app_code VARCHAR(32) NOT NULL,
		  adjustment_id VARCHAR(96) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  match_id VARCHAR(96) NOT NULL DEFAULT '',
		  user_id BIGINT NOT NULL DEFAULT 0,
		  direction VARCHAR(16) NOT NULL,
		  amount_coin BIGINT NOT NULL,
		  reason VARCHAR(128) NOT NULL DEFAULT '',
		  balance_after BIGINT NOT NULL,
		  created_by BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, adjustment_id),
		  KEY idx_game_pool_tx_game_time(app_code, game_id, created_at_ms),
		  KEY idx_game_pool_tx_match(app_code, match_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_stake_pools (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  stake_coin BIGINT NOT NULL,
		  balance_coin BIGINT NOT NULL DEFAULT 0,
		  reserved_coin BIGINT NOT NULL DEFAULT 0,
		  target_balance_coin BIGINT NOT NULL DEFAULT 0,
		  safe_balance_coin BIGINT NOT NULL DEFAULT 0,
		  warning_balance_coin BIGINT NOT NULL DEFAULT 0,
		  danger_balance_coin BIGINT NOT NULL DEFAULT 0,
		  status VARCHAR(32) NOT NULL DEFAULT 'active',
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id, stake_coin)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_stake_pool_transactions (
		  app_code VARCHAR(32) NOT NULL,
		  adjustment_id VARCHAR(96) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  stake_coin BIGINT NOT NULL,
		  match_id VARCHAR(96) NOT NULL DEFAULT '',
		  user_id BIGINT NOT NULL DEFAULT 0,
		  direction VARCHAR(16) NOT NULL,
		  amount_coin BIGINT NOT NULL,
		  reserved_delta_coin BIGINT NOT NULL DEFAULT 0,
		  reason VARCHAR(128) NOT NULL DEFAULT '',
		  balance_after BIGINT NOT NULL,
		  reserved_after BIGINT NOT NULL DEFAULT 0,
		  created_by BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, adjustment_id),
		  KEY idx_stake_pool_tx_pool_time(app_code, game_id, stake_coin, created_at_ms),
		  KEY idx_stake_pool_tx_match(app_code, match_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_new_user_policies (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  enabled TINYINT(1) NOT NULL DEFAULT 1,
		  protection_rounds INT NOT NULL DEFAULT 30,
		  protection_hours INT NOT NULL DEFAULT 168,
		  max_stake_coin BIGINT NOT NULL DEFAULT 5000,
		  lifetime_subsidy_quota_coin BIGINT NOT NULL DEFAULT 30000,
		  day1_subsidy_quota_coin BIGINT NOT NULL DEFAULT 15000,
		  single_round_subsidy_cap_coin BIGINT NOT NULL DEFAULT 5000,
		  strategy_level VARCHAR(32) NOT NULL DEFAULT 'soft_boost',
			  lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1,
			  lose_streak_trigger INT NOT NULL DEFAULT 2,
			  normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53,
			  black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0,
			  created_at_ms BIGINT NOT NULL,
			  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_user_protection_states (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  used_rounds INT NOT NULL DEFAULT 0,
		  lifetime_subsidy_coin BIGINT NOT NULL DEFAULT 0,
		  day1_subsidy_coin BIGINT NOT NULL DEFAULT 0,
		  status VARCHAR(32) NOT NULL DEFAULT 'active',
		  first_applied_at_ms BIGINT NOT NULL DEFAULT 0,
		  last_applied_at_ms BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id, user_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_user_protection_events (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  match_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  used_round_delta INT NOT NULL DEFAULT 0,
		  lifetime_subsidy_coin_delta BIGINT NOT NULL DEFAULT 0,
		  day1_subsidy_coin_delta BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id, match_id, user_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_risk_rules (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  enabled TINYINT(1) NOT NULL DEFAULT 1,
		  protection_profit_limit_coin BIGINT NOT NULL DEFAULT 0,
		  full_protection_max_score INT NOT NULL DEFAULT 29,
		  reduced_protection_max_score INT NOT NULL DEFAULT 49,
		  weak_protection_max_score INT NOT NULL DEFAULT 69,
		  disable_protection_min_score INT NOT NULL DEFAULT 70,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, game_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_strategy_logs (
		  app_code VARCHAR(32) NOT NULL,
		  log_id VARCHAR(96) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  stake_coin BIGINT NOT NULL,
		  match_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  strategy_code VARCHAR(64) NOT NULL,
		  decision VARCHAR(64) NOT NULL,
		  force_result VARCHAR(32) NOT NULL DEFAULT '',
		  reason_code VARCHAR(64) NOT NULL,
		  reason_json JSON NULL,
		  pool_balance_coin BIGINT NOT NULL DEFAULT 0,
		  pool_level VARCHAR(32) NOT NULL DEFAULT '',
		  created_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, log_id),
		  UNIQUE KEY uk_strategy_match_user(app_code, match_id, user_id),
		  KEY idx_strategy_user_time(app_code, user_id, created_at_ms),
		  KEY idx_strategy_game_time(app_code, game_id, stake_coin, created_at_ms)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_self_game_robots (
		  app_code VARCHAR(32) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  status VARCHAR(32) NOT NULL DEFAULT 'active',
		  created_by_admin_id BIGINT NOT NULL DEFAULT 0,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  last_used_at_ms BIGINT NOT NULL DEFAULT 0,
		  used_count BIGINT NOT NULL DEFAULT 0,
		  PRIMARY KEY(app_code, game_id, user_id),
		  KEY idx_game_robot_status(app_code, game_id, status, updated_at_ms, user_id),
		  KEY idx_game_robot_random_pool(app_code, status, user_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_callback_logs (
		  app_code VARCHAR(32) NOT NULL,
		  callback_id VARCHAR(96) NOT NULL,
		  platform_code VARCHAR(64) NOT NULL,
		  operation VARCHAR(64) NOT NULL,
		  request_id VARCHAR(96) NOT NULL DEFAULT '',
		  provider_request_id VARCHAR(160) NOT NULL DEFAULT '',
		  signature_valid TINYINT(1) NOT NULL DEFAULT 0,
		  request_hash VARCHAR(128) NOT NULL,
		  response_code VARCHAR(64) NOT NULL DEFAULT '',
		  response_body_hash VARCHAR(128) NOT NULL DEFAULT '',
		  status VARCHAR(32) NOT NULL,
		  created_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, callback_id),
		  KEY idx_game_callback_platform_time(app_code, platform_code, created_at_ms)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_repair_orders (
		  app_code VARCHAR(32) NOT NULL,
		  repair_id VARCHAR(96) NOT NULL,
		  platform_code VARCHAR(64) NOT NULL,
		  provider_order_id VARCHAR(160) NOT NULL,
		  reason VARCHAR(128) NOT NULL,
		  status VARCHAR(32) NOT NULL,
		  operator_admin_id BIGINT NOT NULL DEFAULT 0,
		  attempts INT NOT NULL DEFAULT 0,
		  next_run_at_ms BIGINT NOT NULL DEFAULT 0,
		  last_error_code VARCHAR(64) NOT NULL DEFAULT '',
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, repair_id),
		  UNIQUE KEY uk_game_repair_provider_order(app_code, platform_code, provider_order_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_level_event_outbox (
		  app_code VARCHAR(32) NOT NULL,
		  event_id VARCHAR(160) NOT NULL,
		  order_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  provider_order_id VARCHAR(160) NOT NULL,
		  provider_round_id VARCHAR(160) NOT NULL DEFAULT '',
		  coin_amount BIGINT NOT NULL,
		  wallet_transaction_id VARCHAR(96) NOT NULL,
		  payload_json JSON NULL,
		  status VARCHAR(32) NOT NULL,
		  attempt_count INT NOT NULL DEFAULT 0,
		  next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
		  locked_by VARCHAR(128) NOT NULL DEFAULT '',
		  locked_until_ms BIGINT NOT NULL DEFAULT 0,
		  failure_reason VARCHAR(255) NOT NULL DEFAULT '',
		  occurred_at_ms BIGINT NOT NULL,
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, event_id),
		  UNIQUE KEY uk_game_level_event_order(app_code, order_id),
		  KEY idx_game_level_event_pending(app_code, status, next_retry_at_ms, created_at_ms),
		  KEY idx_game_level_event_claim(app_code, status, locked_until_ms, next_retry_at_ms, created_at_ms, event_id),
		  KEY idx_game_level_event_claim_order(app_code, status, locked_until_ms, created_at_ms, event_id, next_retry_at_ms),
		  KEY idx_game_level_event_retention(app_code, status, updated_at_ms, event_id),
		  KEY idx_game_level_event_user(app_code, user_id, created_at_ms)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS game_outbox (
		  app_code VARCHAR(32) NOT NULL,
		  event_id VARCHAR(160) NOT NULL,
		  event_type VARCHAR(64) NOT NULL,
		  order_id VARCHAR(96) NOT NULL,
		  user_id BIGINT NOT NULL,
		  platform_code VARCHAR(64) NOT NULL,
		  game_id VARCHAR(96) NOT NULL,
		  op_type VARCHAR(32) NOT NULL,
		  coin_amount BIGINT NOT NULL,
		  payload_json JSON NOT NULL,
		  status VARCHAR(32) NOT NULL,
		  worker_id VARCHAR(128) NOT NULL DEFAULT '',
		  lock_until_ms BIGINT NOT NULL DEFAULT 0,
		  retry_count INT NOT NULL DEFAULT 0,
		  next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
		  last_error VARCHAR(512) NOT NULL DEFAULT '',
		  created_at_ms BIGINT NOT NULL,
		  updated_at_ms BIGINT NOT NULL,
		  PRIMARY KEY(app_code, event_id),
		  KEY idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id),
		  KEY idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id),
		  KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id),
		  KEY idx_game_outbox_order(app_code, order_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
	}
	for _, statement := range statements {
		if _, err := r.db.ExecContext(ctx, statement); err != nil {
			return err
		}
	}
	// game_catalog 线上已有数据量很小；新增带默认值的布尔列不会回填业务数据，默认关闭也保持历史可见范围。
	if err := r.ensureColumn(ctx, "game_catalog", "whitelist_enabled", "whitelist_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER status"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT '' AFTER api_base_url"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_catalog", "safe_height", "safe_height INT NOT NULL DEFAULT 0 AFTER orientation"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_launch_sessions", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_orders", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_dice_participants", "rps_gesture", "rps_gesture VARCHAR(32) NOT NULL DEFAULT '' AFTER dice_points_json"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_self_game_robots", "last_used_at_ms", "last_used_at_ms BIGINT NOT NULL DEFAULT 0 AFTER updated_at_ms"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_self_game_robots", "used_count", "used_count BIGINT NOT NULL DEFAULT 0 AFTER last_used_at_ms"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "lose_streak_protection_enabled", "lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER strategy_level"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "lose_streak_trigger", "lose_streak_trigger INT NOT NULL DEFAULT 2 AFTER lose_streak_protection_enabled"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "normal_phase_target_win_rate_percent", "normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53 AFTER lose_streak_trigger"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "black_pool_robot_force_win_enabled", "black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER normal_phase_target_win_rate_percent"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_orders", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_launch_sessions", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_dice_matches", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_outbox", "lock_until_ms", "lock_until_ms BIGINT NOT NULL DEFAULT 0 AFTER worker_id"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_outbox", "retry_count", "retry_count INT NOT NULL DEFAULT 0 AFTER lock_until_ms"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_outbox", "next_retry_at_ms", "next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count"); err != nil {
		return err
	}
	if err := r.ensureColumn(ctx, "game_outbox", "last_error", "last_error VARCHAR(512) NOT NULL DEFAULT '' AFTER next_retry_at_ms"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_status_created", []string{"app_code", "status", "next_retry_at_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_lock", []string{"app_code", "status", "lock_until_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_retention(app_code, status, updated_at_ms, event_id)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_claim", []string{"app_code", "status", "locked_until_ms", "next_retry_at_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_claim(app_code, status, locked_until_ms, next_retry_at_ms, created_at_ms, event_id)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_claim_order", []string{"app_code", "status", "locked_until_ms", "created_at_ms", "event_id", "next_retry_at_ms"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_claim_order(app_code, status, locked_until_ms, created_at_ms, event_id, next_retry_at_ms)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_retention(app_code, status, updated_at_ms, event_id)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_dice_matches", "idx_game_dice_room_active", []string{"app_code", "room_id", "status", "created_at_ms"}, "ALTER TABLE game_dice_matches ADD INDEX idx_game_dice_room_active(app_code, room_id, status, created_at_ms)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_dice_matches", "idx_game_dice_status_time", []string{"app_code", "status", "updated_at_ms", "match_id"}, "ALTER TABLE game_dice_matches ADD INDEX idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "uk_game_dice_seat", []string{"app_code", "match_id", "seat_no"}, "ALTER TABLE game_dice_participants ADD UNIQUE INDEX uk_game_dice_seat(app_code, match_id, seat_no)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "idx_game_dice_participant_user", []string{"app_code", "user_id", "updated_at_ms"}, "ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_user(app_code, user_id, updated_at_ms)"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "idx_game_dice_participant_status", []string{"app_code", "match_id", "status", "seat_no"}, "ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_status(app_code, match_id, status, seat_no)"); err != nil {
		return err
	}
	if err := r.dropIndexIfExists(ctx, "game_self_game_robots", "idx_game_robot_rotation"); err != nil {
		return err
	}
	if err := r.ensureIndexDefinition(ctx, "game_self_game_robots", "idx_game_robot_random_pool", []string{"app_code", "status", "user_id"}, "ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_random_pool(app_code, status, user_id)"); err != nil {
		return err
	}
	return nil
}

func (r *Repository) dropIndexIfExists(ctx context.Context, tableName string, indexName string) error {
	var count int
	if err := r.db.QueryRowContext(ctx,
		`SELECT COUNT(*)
		   FROM information_schema.STATISTICS
		  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?`,
		tableName, indexName,
	).Scan(&count); err != nil {
		return err
	}
	if count == 0 {
		return nil
	}
	_, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName)
	return err
}

func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error {
	var count int
	if err := r.db.QueryRowContext(ctx,
		`SELECT COUNT(*)
		   FROM information_schema.COLUMNS
		  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
		tableName, columnName,
	).Scan(&count); err != nil {
		return err
	}
	if count > 0 {
		return nil
	}
	_, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` ADD COLUMN `+columnDDL)
	return err
}

func (r *Repository) ensureIndexDefinition(ctx context.Context, tableName string, indexName string, columns []string, createDDL string) error {
	rows, err := r.db.QueryContext(ctx,
		`SELECT COLUMN_NAME
		   FROM information_schema.STATISTICS
		  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
		  ORDER BY SEQ_IN_INDEX`,
		tableName, indexName,
	)
	if err != nil {
		return err
	}
	defer rows.Close()
	existing := make([]string, 0, len(columns))
	for rows.Next() {
		var column string
		if err := rows.Scan(&column); err != nil {
			return err
		}
		existing = append(existing, column)
	}
	if err := rows.Err(); err != nil {
		return err
	}
	if equalStringSlices(existing, columns) {
		return nil
	}
	if len(existing) > 0 {
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName); err != nil {
			return err
		}
	}
	_, err = r.db.ExecContext(ctx, createDDL)
	return err
}

func equalStringSlices(left []string, right []string) bool {
	if len(left) != len(right) {
		return false
	}
	for i := range left {
		if left[i] != right[i] {
			return false
		}
	}
	return true
}

func isBuiltinSelfGameID(gameID string) bool {
	switch strings.TrimSpace(gameID) {
	case "dice", "rock":
		return true
	default:
		return false
	}
}

func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
	// dice/rock 需要保留 catalog 行给专用匹配入口做可玩性校验，但 App 普通游戏列表只展示三方 H5 游戏，避免自研入口混进运营列表后被误删。
	// 同一游戏命中多条区域/语言展示规则时取最大运营权重；0 仍表示沿用目录权重，最终按大值优先返回。
	rows, err := r.db.QueryContext(ctx,
		`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
		        c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status,
		        MAX(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort
		 FROM game_catalog c
		 JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
		 JOIN game_display_rules r ON r.app_code = c.app_code AND r.game_id = c.game_id
		 WHERE c.app_code = ?
		   AND c.status IN ('active', 'maintenance')
		   AND p.status IN ('active', 'maintenance')
		   AND c.game_id NOT IN ('dice', 'rock')
		   AND r.scene = ?
		   AND r.enabled = 1
		   AND r.visible = 1
		   AND (r.region_id = 0 OR r.region_id = ?)
		   AND (r.language = '' OR r.language = ?)
		   AND (r.platform = '' OR r.platform = ?)
		   AND (c.whitelist_enabled = 0 OR EXISTS (
		     SELECT 1
		       FROM game_user_whitelist w
		      WHERE w.app_code = c.app_code AND w.game_id = c.game_id AND w.user_id = ?
		   ))
		 GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
		          c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status, c.sort_order, p.sort_order
		 ORDER BY display_sort DESC, p.sort_order DESC, c.sort_order DESC, c.game_id ASC`,
		appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform, query.UserID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := []gamedomain.AppGame{}
	for rows.Next() {
		var item gamedomain.AppGame
		var gameStatus, platformStatus string
		if err := rows.Scan(&item.GameID, &item.PlatformCode, &item.Name, &item.IconURL, &item.CoverURL, &item.Category, &item.LaunchMode, &item.Orientation, &item.SafeHeight, &item.MinCoin, &gameStatus, &platformStatus, &item.SortOrder); err != nil {
			return nil, err
		}
		item.NameKey = "game." + item.GameID + ".name"
		item.Enabled = gameStatus == gamedomain.StatusActive && platformStatus == gamedomain.StatusActive
		item.Maintenance = gameStatus == gamedomain.StatusMaintenance || platformStatus == gamedomain.StatusMaintenance
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) ListRecentGames(ctx context.Context, query gameservice.ListRecentGamesQuery) ([]gamedomain.AppGame, error) {
	pageSize := int(query.PageSize)
	if pageSize <= 0 {
		pageSize = 4
	}
	// My Games 先按当前用户的启动会话聚合出每个游戏的最后一次启动时间，
	// 再套当前 catalog、platform 和 display_rules，保证最近玩过但已下架或当前区域不可见的游戏不会返回。
	rows, err := r.db.QueryContext(ctx,
		`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
		        c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status,
		        MAX(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort,
		        recent.last_played_at_ms
		   FROM (
		     SELECT game_id, MAX(created_at_ms) AS last_played_at_ms
		       FROM game_launch_sessions
		      WHERE app_code = ?
		        AND user_id = ?
		        AND scene = ?
		      GROUP BY game_id
		   ) recent
		   JOIN game_catalog c ON c.app_code = ? AND c.game_id = recent.game_id
		   JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
		   JOIN game_display_rules r ON r.app_code = c.app_code AND r.game_id = c.game_id
		  WHERE c.status IN ('active', 'maintenance')
		    AND p.status IN ('active', 'maintenance')
		    AND c.game_id NOT IN ('dice', 'rock')
		    AND r.scene = ?
		    AND r.enabled = 1
		    AND r.visible = 1
		    AND (r.region_id = 0 OR r.region_id = ?)
		    AND (r.language = '' OR r.language = ?)
		    AND (r.platform = '' OR r.platform = ?)
		    AND (c.whitelist_enabled = 0 OR EXISTS (
		      SELECT 1
		        FROM game_user_whitelist w
		       WHERE w.app_code = c.app_code AND w.game_id = c.game_id AND w.user_id = ?
		    ))
		  GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
		           c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status,
		           c.sort_order, p.sort_order, recent.last_played_at_ms
		  ORDER BY recent.last_played_at_ms DESC, display_sort DESC, p.sort_order DESC, c.sort_order DESC, c.game_id ASC
		  LIMIT ?`,
		appcode.Normalize(query.AppCode), query.UserID, query.Scene,
		appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform, query.UserID, pageSize,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := []gamedomain.AppGame{}
	for rows.Next() {
		var item gamedomain.AppGame
		var gameStatus, platformStatus string
		var displaySort int32
		var lastPlayedAtMS int64
		if err := rows.Scan(&item.GameID, &item.PlatformCode, &item.Name, &item.IconURL, &item.CoverURL, &item.Category, &item.LaunchMode, &item.Orientation, &item.SafeHeight, &item.MinCoin, &gameStatus, &platformStatus, &displaySort, &lastPlayedAtMS); err != nil {
			return nil, err
		}
		item.NameKey = "game." + item.GameID + ".name"
		item.SortOrder = displaySort
		item.Enabled = gameStatus == gamedomain.StatusActive && platformStatus == gamedomain.StatusActive
		item.Maintenance = gameStatus == gamedomain.StatusMaintenance || platformStatus == gamedomain.StatusMaintenance
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error) {
	// 启动游戏时一次性取出 catalog + platform 配置，避免拿到游戏后再查平台导致配置不一致。
	row := r.db.QueryRowContext(ctx,
		`SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category,
		        c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status,
		        c.sort_order, COALESCE(CAST(c.tags AS CHAR), '[]'), c.created_at_ms, c.updated_at_ms,
		        p.platform_name, p.status, p.api_base_url, COALESCE(p.adapter_type, ''),
		        p.callback_secret_ciphertext, COALESCE(CAST(p.callback_ip_whitelist AS CHAR), '[]'),
		        COALESCE(CAST(p.adapter_config AS CHAR), '{}')
		 FROM game_catalog c
		 JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
		 WHERE c.app_code = ? AND c.game_id = ?`,
		appcode.Normalize(appCode), strings.TrimSpace(gameID),
	)
	var item gamedomain.LaunchableGame
	var tagsJSON string
	var whitelistJSON string
	if err := row.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.SafeHeight, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.PlatformName, &item.PlatformStatus, &item.APIBaseURL, &item.AdapterType, &item.CallbackSecretCiphertext, &whitelistJSON, &item.AdapterConfigJSON); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return gamedomain.LaunchableGame{}, xerr.New(xerr.NotFound, "game not found")
		}
		return gamedomain.LaunchableGame{}, err
	}
	_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)
	_ = json.Unmarshal([]byte(whitelistJSON), &item.CallbackIPWhitelist)
	return item, nil
}

// CanUserAccessGame 在真正创建启动会话前做精确授权；关闭白名单时直接放行，开启时只命中复合主键，不扫描成员表。
func (r *Repository) CanUserAccessGame(ctx context.Context, appCode string, gameID string, userID int64) (bool, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT CASE
		          WHEN c.whitelist_enabled = 0 THEN 1
		          WHEN EXISTS (
		            SELECT 1 FROM game_user_whitelist w
		             WHERE w.app_code = c.app_code AND w.game_id = c.game_id AND w.user_id = ?
		          ) THEN 1
		          ELSE 0
		        END
		   FROM game_catalog c
		  WHERE c.app_code = ? AND c.game_id = ?`,
		userID, appcode.Normalize(appCode), strings.TrimSpace(gameID),
	)
	var allowed bool
	if err := row.Scan(&allowed); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return false, xerr.New(xerr.NotFound, "game not found")
		}
		return false, err
	}
	return allowed, nil
}

func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) {
	// 回调入口只知道 platform_code，必须按 app_code 隔离查询，防止多租户平台编码冲突。
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, ''),
		        callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'),
		        COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms
		   FROM game_platforms
		  WHERE app_code = ? AND platform_code = ?`,
		appcode.Normalize(appCode), strings.TrimSpace(platformCode),
	)
	item, err := scanPlatform(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return gamedomain.Platform{}, xerr.New(xerr.NotFound, "game platform not found")
		}
		return gamedomain.Platform{}, err
	}
	return item, nil
}

func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error {
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO game_launch_sessions (
		   app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
		   provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID, session.CountryID,
		session.Scene, session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
		session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
	)
	return err
}

func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
		        provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
		   FROM game_launch_sessions
		  WHERE app_code = ? AND launch_token_hash = ?`,
		appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)),
	)
	var item gamedomain.LaunchSession
	if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.CountryID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
		}
		return gamedomain.LaunchSession{}, err
	}
	return item, nil
}

func (r *Repository) GetLaunchSessionByTokenScope(ctx context.Context, appCode string, token string, platformCode string, providerGameID string) (gamedomain.LaunchSession, error) {
	// 同一个 App access token 会被多个厂商和多个游戏复用，回调校验必须把 token、平台和可选游戏 ID 一起收窄。
	// 这里按 created_at_ms 倒序取最新一条，避免同用户重复打开同一游戏时拿到旧房间或旧 session。
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
		        provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
		   FROM game_launch_sessions
		  WHERE app_code = ?
		    AND launch_token_hash = ?
		    AND platform_code = ?
		    AND (? = '' OR provider_game_id = ?)
		  ORDER BY created_at_ms DESC
		  LIMIT 1`,
		appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), strings.TrimSpace(platformCode), strings.TrimSpace(providerGameID), strings.TrimSpace(providerGameID),
	)
	var item gamedomain.LaunchSession
	if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.CountryID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
		}
		return gamedomain.LaunchSession{}, err
	}
	return item, nil
}

func (r *Repository) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error {
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO game_callback_logs (
		   app_code, callback_id, platform_code, operation, request_id, provider_request_id,
		   signature_valid, request_hash, response_code, response_body_hash, status, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.Normalize(log.AppCode), log.CallbackID, log.PlatformCode, log.Operation, log.RequestID, log.ProviderRequestID,
		log.SignatureValid, log.RequestHash, log.ResponseCode, log.ResponseBodyHash, log.Status, log.CreatedAtMS,
	)
	return err
}

func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return gamedomain.GameOrder{}, false, err
	}
	defer func() { _ = tx.Rollback() }()

	existing, exists, err := queryOrderForUpdate(ctx, tx, order.AppCode, order.PlatformCode, order.ProviderOrderID)
	if err != nil {
		return gamedomain.GameOrder{}, false, err
	}
	if exists {
		if existing.RequestHash != order.RequestHash {
			_, _ = tx.ExecContext(ctx,
				`UPDATE game_orders SET status = ?, failure_code = ?, failure_message = ?, updated_at_ms = ?
				 WHERE app_code = ? AND order_id = ?`,
				gamedomain.OrderStatusConflict, string(xerr.IdempotencyConflict), "provider order payload conflict",
				time.Now().UnixMilli(), appcode.Normalize(order.AppCode), existing.OrderID,
			)
			_ = tx.Commit()
			return gamedomain.GameOrder{}, true, xerr.New(xerr.IdempotencyConflict, "provider order payload conflict")
		}
		if err := tx.Commit(); err != nil {
			return gamedomain.GameOrder{}, false, err
		}
		return existing, true, nil
	}
	nowMs := time.Now().UnixMilli()
	order.AppCode = appcode.Normalize(order.AppCode)
	order.CreatedAtMS = nowMs
	order.UpdatedAtMS = nowMs
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO game_orders (
		   app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
		   user_id, room_id, region_id, country_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID,
		order.UserID, order.RoomID, order.RegionID, order.CountryID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
	); err != nil {
		return gamedomain.GameOrder{}, false, err
	}
	if err := tx.Commit(); err != nil {
		return gamedomain.GameOrder{}, false, err
	}
	return order, false, nil
}

func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
		        user_id, room_id, region_id, country_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
		        request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
		 FROM game_orders
		 WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?
		 FOR UPDATE`,
		appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
	)
	var order gamedomain.GameOrder
	if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.CountryID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return gamedomain.GameOrder{}, false, nil
		}
		return gamedomain.GameOrder{}, false, err
	}
	return order, true, nil
}

func (r *Repository) MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	order, exists, err := queryOrderByIDForUpdate(ctx, tx, appCode, orderID)
	if err != nil {
		return err
	}
	if !exists {
		return xerr.New(xerr.NotFound, "game order not found")
	}
	_, err = tx.ExecContext(ctx,
		`UPDATE game_orders
		 SET status = ?, wallet_transaction_id = ?, wallet_balance_after = ?, failure_code = '', failure_message = '', updated_at_ms = ?
		 WHERE app_code = ? AND order_id = ?`,
		gamedomain.OrderStatusSucceeded, walletTransactionID, balanceAfter, nowMs, appcode.Normalize(appCode), orderID,
	)
	if err != nil {
		return err
	}
	if strings.EqualFold(order.OpType, "debit") && order.CoinAmount > 0 {
		if err := insertGameLevelEventOutbox(ctx, tx, order, walletTransactionID, nowMs); err != nil {
			return err
		}
	}
	if err := insertGameStatisticsOutbox(ctx, tx, order, walletTransactionID, balanceAfter, nowMs); err != nil {
		return err
	}
	return tx.Commit()
}

func (r *Repository) MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE game_orders SET status = ?, failure_code = ?, failure_message = ?, updated_at_ms = ?
		 WHERE app_code = ? AND order_id = ?`,
		status, code, truncate(message, 256), nowMs, appcode.Normalize(appCode), orderID,
	)
	return err
}

func (r *Repository) CreateRepairOrder(ctx context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) {
	nowMs := time.Now().UnixMilli()
	order.AppCode = appcode.Normalize(order.AppCode)
	if order.CreatedAtMS == 0 {
		order.CreatedAtMS = nowMs
	}
	order.UpdatedAtMS = nowMs
	if strings.TrimSpace(order.Status) == "" {
		order.Status = gamedomain.RepairStatusLogged
	}
	result, err := r.db.ExecContext(ctx,
		`INSERT IGNORE INTO game_repair_orders (
		   app_code, repair_id, platform_code, provider_order_id, reason, status, operator_admin_id,
		   attempts, next_run_at_ms, last_error_code, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		order.AppCode, order.RepairID, order.PlatformCode, order.ProviderOrderID, order.Reason, order.Status,
		order.OperatorAdminID, order.Attempts, order.NextRunAtMS, order.LastErrorCode, order.CreatedAtMS, order.UpdatedAtMS,
	)
	if err != nil {
		return gamedomain.RepairOrder{}, false, err
	}
	affected, _ := result.RowsAffected()
	if affected == 0 {
		existing, err := r.getRepairOrderByProvider(ctx, order.AppCode, order.PlatformCode, order.ProviderOrderID)
		return existing, true, err
	}
	return order, false, nil
}

func (r *Repository) getRepairOrderByProvider(ctx context.Context, appCode string, platformCode string, providerOrderID string) (gamedomain.RepairOrder, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, repair_id, platform_code, provider_order_id, reason, status, operator_admin_id,
		        attempts, next_run_at_ms, last_error_code, created_at_ms, updated_at_ms
		   FROM game_repair_orders
		  WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?`,
		appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
	)
	var item gamedomain.RepairOrder
	if err := row.Scan(&item.AppCode, &item.RepairID, &item.PlatformCode, &item.ProviderOrderID, &item.Reason, &item.Status, &item.OperatorAdminID, &item.Attempts, &item.NextRunAtMS, &item.LastErrorCode, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
		return gamedomain.RepairOrder{}, err
	}
	return item, nil
}

func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
		        user_id, room_id, region_id, country_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
		        request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
		 FROM game_orders
		 WHERE app_code = ? AND order_id = ?
		 FOR UPDATE`,
		appcode.Normalize(appCode), strings.TrimSpace(orderID),
	)
	var order gamedomain.GameOrder
	if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.CountryID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return gamedomain.GameOrder{}, false, nil
		}
		return gamedomain.GameOrder{}, false, err
	}
	return order, true, nil
}

func insertGameLevelEventOutbox(ctx context.Context, tx *sql.Tx, order gamedomain.GameOrder, walletTransactionID string, nowMs int64) error {
	eventID := "game_level:" + order.OrderID
	_, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO game_level_event_outbox (
			app_code, event_id, order_id, user_id, game_id, provider_order_id, provider_round_id,
			coin_amount, wallet_transaction_id, payload_json, status, next_retry_at_ms,
			occurred_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT(
			'platform_code', ?,
			'game_id', ?,
			'provider_game_id', ?,
			'provider_order_id', ?,
			'provider_round_id', ?,
			'room_id', ?,
			'region_id', ?,
			'wallet_transaction_id', ?
		), 'pending', 0, ?, ?, ?)`,
		order.AppCode, eventID, order.OrderID, order.UserID, order.GameID, order.ProviderOrderID, order.ProviderRoundID,
		order.CoinAmount, walletTransactionID,
		order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID, order.RoomID, order.RegionID, walletTransactionID,
		nowMs, nowMs, nowMs,
	)
	return err
}

func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomain.GameOrder, walletTransactionID string, balanceAfter int64, nowMs int64) error {
	eventID := "GameOrderSettled:" + order.OrderID
	// 国家必须来自订单快照；region_id 只能表达区域分桶，不能替代国家维度。
	// 游戏名称和图标用 game_catalog 复合主键点查固化到 owner outbox，消费端无需反查游戏库，也不会把结算写放大成目录扫描。
	_, 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 (?, ?, 'GameOrderSettled', ?, ?, ?, ?, ?, ?, JSON_OBJECT(
			'order_id', ?,
			'platform_code', ?,
			'game_id', ?,
			'game_name', COALESCE((SELECT c.game_name FROM game_catalog c WHERE c.app_code = ? AND c.game_id = ?), ''),
			'game_icon_url', COALESCE((SELECT COALESCE(NULLIF(c.icon_url, ''), c.cover_url) FROM game_catalog c WHERE c.app_code = ? AND c.game_id = ?), ''),
			'provider_game_id', ?,
			'provider_order_id', ?,
			'provider_round_id', ?,
			'user_id', ?,
			'room_id', ?,
			'visible_region_id', ?,
			'region_id', ?,
			'country_id', ?,
			'op_type', ?,
			'coin_amount', ?,
			'wallet_transaction_id', ?,
			'wallet_balance_after', ?
		), 'pending', ?, ?)`,
		order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount,
		order.OrderID, order.PlatformCode, order.GameID,
		order.AppCode, order.GameID, order.AppCode, order.GameID,
		order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID,
		order.UserID, order.RoomID, order.RegionID, order.RegionID, order.CountryID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
		nowMs, nowMs,
	)
	return err
}

// GameOutboxRecord is a durable game-service fact waiting for MQ fanout.
type GameOutboxRecord struct {
	AppCode      string
	EventID      string
	EventType    string
	OrderID      string
	UserID       int64
	PlatformCode string
	GameID       string
	OpType       string
	CoinAmount   int64
	PayloadJSON  string
	CreatedAtMS  int64
	RetryCount   int
	LastError    string
	WorkerID     string
	LockUntilMS  int64
}

// ListGameOutboxAppCodes enumerates real tenant keys from the game owner table.
// PRIMARY is ordered by (app_code,event_id), allowing cached discovery without
// weakening the app_code prefix used by every SKIP LOCKED claim transaction.
func (r *Repository) ListGameOutboxAppCodes(ctx context.Context) ([]string, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT app_code FROM game_outbox FORCE INDEX (PRIMARY) ORDER BY app_code`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	appCodes := make([]string, 0, 4)
	for rows.Next() {
		var appCode string
		if err := rows.Scan(&appCode); err != nil {
			return nil, err
		}
		appCodes = append(appCodes, appCode)
	}
	return appCodes, rows.Err()
}

// ClaimPendingGameOutbox locks pending game facts so one worker publishes them.
func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, workerID string, batchSize int) ([]GameOutboxRecord, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if batchSize <= 0 {
		batchSize = 100
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		workerID = "game-outbox-worker"
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()
	nowMS := time.Now().UTC().UnixMilli()
	lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
	appCode := appcode.FromContext(ctx)
	records := make([]GameOutboxRecord, 0, batchSize)
	// Recovery work must claim before fresh pending facts. A continuously growing
	// pending backlog can otherwise fill every batch and permanently strand both
	// due retries and running rows whose worker lease expired. Keep the states in
	// separate SKIP LOCKED queries so each branch retains its status-leading index.
	claimBranches := []struct {
		query string
		args  []any
	}{
		{
			query: `
		SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
		       coin_amount, CAST(payload_json AS CHAR), created_at_ms
		FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
		WHERE app_code = ? AND status = 'retryable' AND next_retry_at_ms <= ? AND lock_until_ms = 0
		ORDER BY created_at_ms ASC, event_id ASC
		LIMIT ? FOR UPDATE SKIP LOCKED
	`,
			args: []any{appCode, nowMS},
		},
		{
			query: `
		SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
		       coin_amount, CAST(payload_json AS CHAR), created_at_ms
		FROM game_outbox FORCE INDEX (idx_game_outbox_lock)
		WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
		ORDER BY created_at_ms ASC, event_id ASC
		LIMIT ? FOR UPDATE SKIP LOCKED
	`,
			args: []any{appCode, nowMS},
		},
		{
			query: `
		SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
		       coin_amount, CAST(payload_json AS CHAR), created_at_ms
		FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
		WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
		ORDER BY created_at_ms ASC, event_id ASC
		LIMIT ? FOR UPDATE SKIP LOCKED
	`,
			args: []any{appCode, nowMS},
		},
	}
	for _, branch := range claimBranches {
		remaining := batchSize - len(records)
		if remaining <= 0 {
			break
		}
		args := append(append([]any{}, branch.args...), remaining)
		branchRecords, err := queryGameOutboxRecords(ctx, tx, branch.query, args...)
		if err != nil {
			return nil, err
		}
		records = append(records, branchRecords...)
	}
	for _, record := range records {
		if _, err := tx.ExecContext(ctx, `
			UPDATE game_outbox
			SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND event_id = ?
		`, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	for index := range records {
		records[index].WorkerID = workerID
		records[index].LockUntilMS = lockUntilMS
	}
	return records, nil
}

func queryGameOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]GameOutboxRecord, error) {
	rows, err := tx.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	records := make([]GameOutboxRecord, 0)
	for rows.Next() {
		var record GameOutboxRecord
		if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.OrderID, &record.UserID, &record.PlatformCode, &record.GameID, &record.OpType, &record.CoinAmount, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
			return nil, err
		}
		records = append(records, record)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return records, nil
}

func (r *Repository) MarkGameOutboxDelivered(ctx context.Context, appCode string, eventID string) error {
	_, err := r.db.ExecContext(ctx, `
		UPDATE game_outbox
		SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
		WHERE app_code = ? AND event_id = ?
	`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
	return err
}

func (r *Repository) MarkGameOutboxRetryable(ctx context.Context, appCode string, eventID string, lastErr string, nextRetryAtMS int64) error {
	_, err := r.db.ExecContext(ctx, `
		UPDATE game_outbox
		SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1,
		    next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
		WHERE app_code = ? AND event_id = ? AND status = 'running'
	`, nextRetryAtMS, truncate(lastErr, 512), time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
	return err
}

func (r *Repository) ClaimPendingLevelEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]gamedomain.LevelEventOutbox, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if batchSize <= 0 {
		return nil, nil
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	// 先用轻量主键查询完成行锁 claim，避免 payload_json 和完整业务列参与排序。
	keys, err := r.queryUnlockedLevelEventClaimKeys(ctx, tx, appCode, nowMS, batchSize)
	if err != nil {
		return nil, err
	}
	// 普通未锁事件优先处理；不足一批时再接管锁过期事件，保持原有补偿顺序。
	if remaining := batchSize - len(keys); remaining > 0 {
		retryKeys, err := r.queryExpiredLevelEventClaimKeys(ctx, tx, appCode, nowMS, remaining)
		if err != nil {
			return nil, err
		}
		keys = append(keys, retryKeys...)
	}
	// 状态更新前按主键回读完整事件，让上层仍拿到 pending/failed 的原始状态和负载。
	events, err := r.fetchLevelEventsByClaimKeys(ctx, tx, appCode, keys)
	if err != nil {
		return nil, err
	}
	lockUntil := nowMS + lockTTL.Milliseconds()
	// 只有被本事务锁住且准备返回的事件会切到 running，额外扫描到的候选行随提交释放锁。
	for _, key := range keys {
		if _, err := tx.ExecContext(ctx, `
			UPDATE game_level_event_outbox
			SET status = 'running', attempt_count = attempt_count + 1, locked_by = ?, locked_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND event_id = ?`,
			workerID, lockUntil, nowMS, appCode, key.EventID,
		); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return events, nil
}

type levelEventClaimKey struct {
	EventID     string
	CreatedAtMS int64
}

func (r *Repository) queryUnlockedLevelEventClaimKeys(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64, batchSize int) ([]levelEventClaimKey, error) {
	// status 拆成两个等值查询，避免 MySQL 把 status IN 展开成多段 range 后再 filesort。
	return queryLevelEventClaimKeysByStatus(batchSize, func(status string) ([]levelEventClaimKey, error) {
		rows, err := tx.QueryContext(ctx, `
			SELECT event_id, created_at_ms
			FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim_order)
			WHERE app_code = ? AND status = ? AND locked_until_ms = 0 AND next_retry_at_ms <= ?
			ORDER BY created_at_ms ASC, event_id ASC
			LIMIT ? FOR UPDATE SKIP LOCKED`,
			appCode, status, nowMS, batchSize,
		)
		if err != nil {
			return nil, err
		}
		defer rows.Close()
		return scanLevelEventClaimKeys(rows)
	})
}

func (r *Repository) queryExpiredLevelEventClaimKeys(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64, batchSize int) ([]levelEventClaimKey, error) {
	// 过期接管只处理 running：pending/failed 已由 unlocked path 负责。拆开 legacy lock=0
	// 与正常过期锁可继续利用 (app_code,status,locked_until_ms,...) 前缀，同时明确
	// 排除未来锁，避免并发 cron 抢走仍由其它 worker 处理的事实。
	query := func(lockCondition string, args ...any) ([]levelEventClaimKey, error) {
		queryArgs := []any{appCode, "running"}
		queryArgs = append(queryArgs, args...)
		queryArgs = append(queryArgs, nowMS, batchSize)
		rows, err := tx.QueryContext(ctx, `
			SELECT event_id, created_at_ms
			FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim)
			WHERE app_code = ? AND status = ? AND `+lockCondition+` AND next_retry_at_ms <= ?
			ORDER BY created_at_ms ASC, event_id ASC
			LIMIT ? FOR UPDATE SKIP LOCKED`, queryArgs...)
		if err != nil {
			return nil, err
		}
		defer rows.Close()
		return scanLevelEventClaimKeys(rows)
	}
	legacyUnlocked, err := query("locked_until_ms = 0")
	if err != nil {
		return nil, err
	}
	expired, err := query("locked_until_ms > 0 AND locked_until_ms <= ?", nowMS)
	if err != nil {
		return nil, err
	}
	return mergeLevelEventClaimKeys(batchSize, legacyUnlocked, expired), nil
}

func queryLevelEventClaimKeysByStatus(batchSize int, query func(status string) ([]levelEventClaimKey, error)) ([]levelEventClaimKey, error) {
	statuses := []string{"pending", "failed"}
	groups := make([][]levelEventClaimKey, 0, len(statuses))
	for _, status := range statuses {
		keys, err := query(status)
		if err != nil {
			return nil, err
		}
		groups = append(groups, keys)
	}
	return mergeLevelEventClaimKeys(batchSize, groups...), nil
}

func scanLevelEventClaimKeys(rows *sql.Rows) ([]levelEventClaimKey, error) {
	keys := make([]levelEventClaimKey, 0)
	for rows.Next() {
		var key levelEventClaimKey
		if err := rows.Scan(&key.EventID, &key.CreatedAtMS); err != nil {
			return nil, err
		}
		keys = append(keys, key)
	}
	return keys, rows.Err()
}

func mergeLevelEventClaimKeys(limit int, groups ...[]levelEventClaimKey) []levelEventClaimKey {
	if limit <= 0 {
		return nil
	}
	offsets := make([]int, len(groups))
	merged := make([]levelEventClaimKey, 0, limit)
	// 每个 status 查询内部已经按 created_at/event_id 排序，这里做小规模归并来维持原 SQL 的跨状态顺序。
	for len(merged) < limit {
		bestGroup := -1
		var best levelEventClaimKey
		for groupIndex, group := range groups {
			offset := offsets[groupIndex]
			if offset >= len(group) {
				continue
			}
			candidate := group[offset]
			if bestGroup == -1 || levelEventClaimKeyLess(candidate, best) {
				bestGroup = groupIndex
				best = candidate
			}
		}
		if bestGroup == -1 {
			break
		}
		merged = append(merged, best)
		offsets[bestGroup]++
	}
	return merged
}

func levelEventClaimKeyLess(left levelEventClaimKey, right levelEventClaimKey) bool {
	if left.CreatedAtMS != right.CreatedAtMS {
		return left.CreatedAtMS < right.CreatedAtMS
	}
	return left.EventID < right.EventID
}

func (r *Repository) fetchLevelEventsByClaimKeys(ctx context.Context, tx *sql.Tx, appCode string, keys []levelEventClaimKey) ([]gamedomain.LevelEventOutbox, error) {
	if len(keys) == 0 {
		return nil, nil
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(keys)), ",")
	args := make([]any, 0, len(keys)+1)
	args = append(args, appCode)
	for _, key := range keys {
		args = append(args, key.EventID)
	}
	rows, err := tx.QueryContext(ctx, levelEventOutboxSelectSQL()+`WHERE app_code = ? AND event_id IN (`+placeholders+`)`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items, err := scanLevelEventOutboxRows(rows)
	if err != nil {
		return nil, err
	}
	byID := make(map[string]gamedomain.LevelEventOutbox, len(items))
	for _, item := range items {
		byID[item.EventID] = item
	}
	ordered := make([]gamedomain.LevelEventOutbox, 0, len(keys))
	for _, key := range keys {
		item, ok := byID[key.EventID]
		if !ok {
			return nil, errors.New("claimed level event disappeared before fetch")
		}
		ordered = append(ordered, item)
	}
	return ordered, nil
}

func (r *Repository) MarkLevelEventDelivered(ctx context.Context, eventID string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE game_level_event_outbox
		SET status = 'delivered', locked_by = '', locked_until_ms = 0, failure_reason = '', updated_at_ms = ?
		WHERE app_code = ? AND event_id = ?`,
		nowMS, appcode.FromContext(ctx), eventID,
	)
	return err
}

func (r *Repository) MarkLevelEventFailed(ctx context.Context, eventID string, failureReason string, nextRetryAtMS int64, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE game_level_event_outbox
		SET status = 'failed', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0,
		    failure_reason = ?, updated_at_ms = ?
		WHERE app_code = ? AND event_id = ? AND status <> 'delivered'`,
		nextRetryAtMS, truncate(failureReason, 255), nowMS, appcode.FromContext(ctx), eventID,
	)
	return err
}

func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error) {
	query := `SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, ''),
	                 callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'),
	                 COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms
		FROM game_platforms WHERE app_code = ?`
	args := []any{appcode.Normalize(appCode)}
	if strings.TrimSpace(status) != "" {
		query += " AND status = ?"
		args = append(args, strings.TrimSpace(status))
	}
	query += " ORDER BY sort_order DESC, platform_code ASC"
	rows, err := r.db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var items []gamedomain.Platform
	for rows.Next() {
		item, err := scanPlatform(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) UpsertPlatform(ctx context.Context, platform gamedomain.Platform) (gamedomain.Platform, error) {
	nowMs := time.Now().UnixMilli()
	platform.AppCode = appcode.Normalize(platform.AppCode)
	if platform.CreatedAtMS == 0 {
		platform.CreatedAtMS = nowMs
	}
	platform.UpdatedAtMS = nowMs
	whitelistJSON, _ := json.Marshal(platform.CallbackIPWhitelist)
	adapterConfig := strings.TrimSpace(platform.AdapterConfigJSON)
	if adapterConfig == "" {
		adapterConfig = "{}"
	}
	// callback_secret_ciphertext 空值表示“保持旧密钥”，这样后台编辑 URL/白名单时不会误清密钥。
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO game_platforms (
		   app_code, platform_code, platform_name, status, api_base_url, adapter_type,
		   callback_secret_ciphertext, callback_ip_whitelist, adapter_config,
		   sort_order, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name), status = VALUES(status),
		   api_base_url = VALUES(api_base_url), adapter_type = VALUES(adapter_type),
		   callback_secret_ciphertext = IF(VALUES(callback_secret_ciphertext) = '', callback_secret_ciphertext, VALUES(callback_secret_ciphertext)),
		   callback_ip_whitelist = VALUES(callback_ip_whitelist), adapter_config = VALUES(adapter_config),
		   sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`,
		platform.AppCode, platform.PlatformCode, platform.PlatformName, platform.Status, platform.APIBaseURL, platform.AdapterType,
		platform.CallbackSecretCiphertext, string(whitelistJSON), adapterConfig, platform.SortOrder, platform.CreatedAtMS, platform.UpdatedAtMS,
	)
	if err != nil {
		return gamedomain.Platform{}, err
	}
	return r.GetPlatform(ctx, platform.AppCode, platform.PlatformCode)
}

func (r *Repository) ListCatalog(ctx context.Context, query gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
	sqlText := `SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
		       launch_mode, orientation, safe_height, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms,
		       whitelist_enabled
		FROM game_catalog WHERE app_code = ?`
	args := []any{appcode.Normalize(query.AppCode)}
	if strings.TrimSpace(query.PlatformCode) != "" {
		sqlText += " AND platform_code = ?"
		args = append(args, strings.TrimSpace(query.PlatformCode))
	}
	if strings.TrimSpace(query.Status) != "" {
		sqlText += " AND status = ?"
		args = append(args, strings.TrimSpace(query.Status))
	}
	sqlText += " ORDER BY sort_order DESC, game_id ASC"
	if query.PageSize > 0 {
		sqlText += " LIMIT ?"
		args = append(args, query.PageSize)
	}
	rows, err := r.db.QueryContext(ctx, sqlText, args...)
	if err != nil {
		return nil, "", err
	}
	defer rows.Close()
	var items []gamedomain.CatalogItem
	for rows.Next() {
		item, err := scanCatalog(rows)
		if err != nil {
			return nil, "", err
		}
		items = append(items, item)
	}
	return items, "", rows.Err()
}

func (r *Repository) UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
	nowMs := time.Now().UnixMilli()
	item.AppCode = appcode.Normalize(item.AppCode)
	if item.CreatedAtMS == 0 {
		item.CreatedAtMS = nowMs
	}
	item.UpdatedAtMS = nowMs
	tagsJSON, _ := json.Marshal(item.Tags)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return gamedomain.CatalogItem{}, err
	}
	defer tx.Rollback()
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO game_catalog (
		   app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
		   launch_mode, orientation, safe_height, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE platform_code = VALUES(platform_code), provider_game_id = VALUES(provider_game_id),
		   game_name = VALUES(game_name), category = VALUES(category), icon_url = VALUES(icon_url), cover_url = VALUES(cover_url),
		   launch_mode = VALUES(launch_mode), orientation = VALUES(orientation), safe_height = VALUES(safe_height), min_coin = VALUES(min_coin),
		   status = VALUES(status), sort_order = VALUES(sort_order), tags = VALUES(tags), updated_at_ms = VALUES(updated_at_ms)`,
		item.AppCode, item.GameID, item.PlatformCode, item.ProviderGameID, item.GameName, item.Category, item.IconURL, item.CoverURL,
		item.LaunchMode, item.Orientation, item.SafeHeight, item.MinCoin, item.Status, item.SortOrder, string(tagsJSON), item.CreatedAtMS, item.UpdatedAtMS,
	); err != nil {
		return gamedomain.CatalogItem{}, err
	}
	// App 的 /games 当前按 scene=voice_room 读取展示规则；导入目录时同步补默认规则，避免后台同步成功但 App 看不到。
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO game_display_rules (
		   app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, 0, '', '', 1, 1, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled),
		   sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`,
		item.AppCode, defaultVoiceRoomRuleID(item.GameID), item.GameID, gamedomain.SceneVoiceRoom, item.SortOrder, item.CreatedAtMS, item.UpdatedAtMS,
	); err != nil {
		return gamedomain.CatalogItem{}, err
	}
	if err := tx.Commit(); err != nil {
		return gamedomain.CatalogItem{}, err
	}
	// whitelist_enabled 只允许专用管理接口修改；更新目录后重新读取真实行，避免响应把既有白名单状态误报为 false。
	return r.getCatalog(ctx, item.AppCode, item.GameID)
}

func defaultVoiceRoomRuleID(gameID string) string {
	ruleID := strings.TrimSpace(gameID) + "_voice_default"
	if len(ruleID) <= 96 {
		return ruleID
	}
	sum := sha256.Sum256([]byte(gameID))
	return "voice_default_" + hex.EncodeToString(sum[:16])
}

func (r *Repository) SetGameStatus(ctx context.Context, appCode string, gameID string, status string, nowMs int64) (gamedomain.CatalogItem, error) {
	if _, err := r.db.ExecContext(ctx,
		`UPDATE game_catalog SET status = ?, updated_at_ms = ? WHERE app_code = ? AND game_id = ?`,
		status, nowMs, appcode.Normalize(appCode), strings.TrimSpace(gameID),
	); err != nil {
		return gamedomain.CatalogItem{}, err
	}
	return r.getCatalog(ctx, appCode, gameID)
}

func (r *Repository) SetGameWhitelistEnabled(ctx context.Context, appCode string, gameID string, enabled bool, nowMS int64) (gamedomain.CatalogItem, error) {
	result, err := r.db.ExecContext(ctx,
		`UPDATE game_catalog SET whitelist_enabled = ?, updated_at_ms = ? WHERE app_code = ? AND game_id = ?`,
		enabled, nowMS, appcode.Normalize(appCode), strings.TrimSpace(gameID),
	)
	if err != nil {
		return gamedomain.CatalogItem{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return gamedomain.CatalogItem{}, err
	} else if affected == 0 {
		return gamedomain.CatalogItem{}, xerr.New(xerr.NotFound, "game not found")
	}
	return r.getCatalog(ctx, appCode, gameID)
}

func (r *Repository) ListGameWhitelistUsers(ctx context.Context, appCode string, gameID string, pageSize int32) ([]gamedomain.WhitelistUser, error) {
	if pageSize <= 0 || pageSize > 200 {
		pageSize = 200
	}
	// 管理页默认展示当前游戏成员；覆盖索引按 game 范围读取并硬限制数量，避免成员增长后出现无界查询。
	rows, err := r.db.QueryContext(ctx,
		`SELECT app_code, game_id, user_id, created_by_admin_id, created_at_ms
		   FROM game_user_whitelist FORCE INDEX (idx_game_whitelist_game_time)
		  WHERE app_code = ? AND game_id = ?
		  ORDER BY created_at_ms DESC, user_id DESC
		  LIMIT ?`,
		appcode.Normalize(appCode), strings.TrimSpace(gameID), pageSize,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]gamedomain.WhitelistUser, 0)
	for rows.Next() {
		var item gamedomain.WhitelistUser
		if err := rows.Scan(&item.AppCode, &item.GameID, &item.UserID, &item.CreatedByAdminID, &item.CreatedAtMS); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) AddGameWhitelistUser(ctx context.Context, item gamedomain.WhitelistUser) (gamedomain.WhitelistUser, error) {
	item.AppCode = appcode.Normalize(item.AppCode)
	item.GameID = strings.TrimSpace(item.GameID)
	if item.UserID <= 0 || item.GameID == "" {
		return gamedomain.WhitelistUser{}, xerr.New(xerr.InvalidArgument, "game whitelist user is incomplete")
	}
	if item.CreatedAtMS <= 0 {
		item.CreatedAtMS = time.Now().UnixMilli()
	}
	// INSERT ... SELECT 同时验证目录存在；重复添加只更新操作来源和时间，复合主键保证不会产生重复成员。
	result, err := r.db.ExecContext(ctx,
		`INSERT INTO game_user_whitelist (app_code, game_id, user_id, created_by_admin_id, created_at_ms)
		 SELECT app_code, game_id, ?, ?, ? FROM game_catalog WHERE app_code = ? AND game_id = ?
		 ON DUPLICATE KEY UPDATE created_by_admin_id = VALUES(created_by_admin_id), created_at_ms = VALUES(created_at_ms)`,
		item.UserID, item.CreatedByAdminID, item.CreatedAtMS, item.AppCode, item.GameID,
	)
	if err != nil {
		return gamedomain.WhitelistUser{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return gamedomain.WhitelistUser{}, err
	} else if affected == 0 {
		return gamedomain.WhitelistUser{}, xerr.New(xerr.NotFound, "game not found")
	}
	return item, nil
}

func (r *Repository) DeleteGameWhitelistUser(ctx context.Context, appCode string, gameID string, userID int64) error {
	if userID <= 0 {
		return xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	result, err := r.db.ExecContext(ctx,
		`DELETE FROM game_user_whitelist WHERE app_code = ? AND game_id = ? AND user_id = ?`,
		appcode.Normalize(appCode), strings.TrimSpace(gameID), userID,
	)
	if err != nil {
		return err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return err
	} else if affected == 0 {
		return xerr.New(xerr.NotFound, "game whitelist user not found")
	}
	return nil
}

func (r *Repository) DeleteCatalog(ctx context.Context, appCode string, gameID string) error {
	app := appcode.Normalize(appCode)
	id := strings.TrimSpace(gameID)
	if id == "" {
		return xerr.New(xerr.InvalidArgument, "game_id is required")
	}
	if isBuiltinSelfGameID(id) {
		// 内置自研游戏的 catalog 是专用 /games/dice、/games/rps 入口的运行时前置校验，不属于普通三方游戏目录，不能通过删除目录下架。
		return xerr.New(xerr.InvalidArgument, "self game catalog cannot be deleted")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()
	result, err := tx.ExecContext(ctx, `DELETE FROM game_catalog WHERE app_code = ? AND game_id = ?`, app, id)
	if err != nil {
		return err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected == 0 {
		return xerr.New(xerr.NotFound, "game not found")
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM game_display_rules WHERE app_code = ? AND game_id = ?`, app, id); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM game_user_whitelist WHERE app_code = ? AND game_id = ?`, app, id); err != nil {
		return err
	}
	return tx.Commit()
}

func (r *Repository) getCatalog(ctx context.Context, appCode string, gameID string) (gamedomain.CatalogItem, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
		        launch_mode, orientation, safe_height, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms,
		        whitelist_enabled
		 FROM game_catalog WHERE app_code = ? AND game_id = ?`,
		appcode.Normalize(appCode), strings.TrimSpace(gameID),
	)
	item, err := scanCatalog(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return gamedomain.CatalogItem{}, xerr.New(xerr.NotFound, "game not found")
		}
		return gamedomain.CatalogItem{}, err
	}
	return item, nil
}

type catalogScanner interface {
	Scan(dest ...any) error
}

func scanLevelEventOutboxRows(rows *sql.Rows) ([]gamedomain.LevelEventOutbox, error) {
	items := make([]gamedomain.LevelEventOutbox, 0)
	for rows.Next() {
		item, err := scanLevelEventOutbox(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func scanLevelEventOutbox(scanner catalogScanner) (gamedomain.LevelEventOutbox, error) {
	var item gamedomain.LevelEventOutbox
	err := scanner.Scan(
		&item.AppCode, &item.EventID, &item.OrderID, &item.UserID, &item.GameID,
		&item.ProviderOrderID, &item.ProviderRoundID, &item.CoinAmount,
		&item.WalletTransactionID, &item.PayloadJSON, &item.Status,
		&item.AttemptCount, &item.FailureReason, &item.OccurredAtMS,
		&item.CreatedAtMS, &item.UpdatedAtMS,
	)
	return item, err
}

func scanPlatform(scanner catalogScanner) (gamedomain.Platform, error) {
	var item gamedomain.Platform
	var whitelistJSON string
	if err := scanner.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.AdapterType, &item.CallbackSecretCiphertext, &whitelistJSON, &item.AdapterConfigJSON, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
		return gamedomain.Platform{}, err
	}
	// 只把“是否已配置”暴露给上层，密钥明文不再通过 DTO/Proto 回给后台。
	item.CallbackSecretConfigured = strings.TrimSpace(item.CallbackSecretCiphertext) != ""
	_ = json.Unmarshal([]byte(whitelistJSON), &item.CallbackIPWhitelist)
	return item, nil
}

func levelEventOutboxSelectSQL() string {
	return `SELECT app_code, event_id, order_id, user_id, game_id, provider_order_id,
	               provider_round_id, coin_amount, wallet_transaction_id,
	               COALESCE(CAST(payload_json AS CHAR), '{}'), status, attempt_count,
	               failure_reason, occurred_at_ms, created_at_ms, updated_at_ms
	        FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim) `
}

func scanCatalog(scanner catalogScanner) (gamedomain.CatalogItem, error) {
	var item gamedomain.CatalogItem
	var tagsJSON string
	if err := scanner.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.SafeHeight, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.WhitelistEnabled); err != nil {
		return gamedomain.CatalogItem{}, err
	}
	_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)
	return item, nil
}

func truncate(value string, max int) string {
	value = strings.TrimSpace(value)
	if len(value) <= max {
		return value
	}
	return value[:max]
}

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