package mysql

import (
	"context"
	"database/sql"
	"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
	}
	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 '',
		  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',
		  min_coin BIGINT NOT NULL DEFAULT 0,
		  status VARCHAR(32) NOT NULL,
		  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_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 '',
		  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 '',
		  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_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`,
	}
	for _, statement := range statements {
		if _, err := r.db.ExecContext(ctx, statement); err != nil {
			return err
		}
	}
	return r.seedDefaults(ctx)
}

func (r *Repository) seedDefaults(ctx context.Context) error {
	if _, err := r.db.ExecContext(ctx,
		`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms)
		 VALUES ('lalu', 'demo', 'Demo Games', 'active', 'https://game.example.local/h5', 10, 0, 0)
		 ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name)`); err != nil {
		return err
	}
	if _, err := r.db.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, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
		 ) VALUES (
		   'lalu', 'demo_rocket_001', 'demo', 'rocket_001', 'Rocket', 'casual',
		   'https://cdn.example.com/game/rocket.png', 'https://cdn.example.com/game/rocket-cover.png',
		   'h5_popup', 'portrait', 100, 'active', 10, JSON_ARRAY('demo','voice_room'), 0, 0
		 ) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name)`); err != nil {
		return err
	}
	_, err := r.db.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 (
		   'lalu', 'demo_rocket_voice_default', 'demo_rocket_001', 'voice_room', 0, '', '', 1, 1, 10, 0, 0
		 ) ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled)`)
	return err
}

func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
	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.min_coin, c.status, p.status,
		        MIN(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 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 = ?)
		 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.min_coin, c.status, p.status, c.sort_order, p.sort_order
		 ORDER BY display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC`,
		appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform,
	)
	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.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) GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error) {
	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.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
		 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
	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.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.PlatformName, &item.PlatformStatus, &item.APIBaseURL); 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)
	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, 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.Scene,
		session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
		session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
	)
	return err
}

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, 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.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, 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.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 {
	_, err := r.db.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,
	)
	return err
}

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) ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error) {
	query := `SELECT app_code, platform_code, platform_name, status, api_base_url, 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 ASC, 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() {
		var item gamedomain.Platform
		if err := rows.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); 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
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, 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), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`,
		platform.AppCode, platform.PlatformCode, platform.PlatformName, platform.Status, platform.APIBaseURL, platform.SortOrder, platform.CreatedAtMS, platform.UpdatedAtMS,
	)
	return platform, err
}

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, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
		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 ASC, 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)
	_, err := r.db.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, 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), 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.MinCoin, item.Status, item.SortOrder, string(tagsJSON), item.CreatedAtMS, item.UpdatedAtMS,
	)
	return item, err
}

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) 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, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
		 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 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.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS); 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]
}
