// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql

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

	mysqlerr "github.com/go-sql-driver/mysql"
	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/room-service/internal/room/outbox"
	roomservice "hyapp/services/room-service/internal/room/service"
)

// Repository 把 room-service 的权威状态持久化到 MySQL。
type Repository struct {
	// db 是标准库连接池，所有 repository 方法都通过它访问 MySQL。
	db *sql.DB
}

// Open 建立 MySQL 连接池，并验证数据库可用。
func Open(ctx context.Context, dsn string, maxOpenConns int, maxIdleConns int) (*Repository, error) {
	// sql.Open 只校验 driver 和 DSN 形态，真实连通性由后续 PingContext 验证。
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, err
	}

	if maxOpenConns > 0 {
		// 只在配置显式给出正数时覆盖标准库默认值。
		db.SetMaxOpenConns(maxOpenConns)
	}

	if maxIdleConns > 0 {
		// 空闲连接数需要小于等于最大连接数，由配置层负责合理设置。
		db.SetMaxIdleConns(maxIdleConns)
	}

	// 长连接定期轮换，避免云数据库或网络层回收导致长期坏连接驻留。
	db.SetConnMaxLifetime(30 * time.Minute)

	if err := db.PingContext(ctx); err != nil {
		// 启动探测失败时必须关闭连接池，避免返回半可用 repository。
		_ = db.Close()
		return nil, err
	}

	return &Repository{db: db}, nil
}

// Close 关闭底层 MySQL 连接池。
func (r *Repository) Close() error {
	// App.Close 调用该方法释放所有空闲连接，并阻止后续新查询。
	return r.db.Close()
}

// Ping 验证 MySQL 连接池当前仍能在调用方 deadline 内拿到可用连接。
func (r *Repository) Ping(ctx context.Context) error {
	// ready 探针通过该方法确认 command log/snapshot/outbox 的持久化来源可用。
	return r.db.PingContext(ctx)
}

// Migrate 创建 room-service 首版需要的 MySQL 表。
func (r *Repository) Migrate(ctx context.Context) error {
	// 这里保持与 deploy/mysql/initdb 的表结构一致，服务本地启动可自动建表。
	statements := []string{
		`CREATE TABLE IF NOT EXISTS rooms (
			app_code VARCHAR(32) NOT NULL,
			room_id VARCHAR(64) NOT NULL,
			room_short_id VARCHAR(32) NOT NULL DEFAULT '',
			owner_user_id BIGINT NOT NULL,
			seat_count INT NOT NULL,
			mode VARCHAR(64) NOT NULL,
			status VARCHAR(32) NOT NULL,
			room_password_hash VARCHAR(128) NOT NULL DEFAULT '',
			close_reason VARCHAR(512) NOT NULL DEFAULT '',
			closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
			closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
			closed_at_ms BIGINT NOT NULL DEFAULT 0,
			visible_region_id BIGINT NOT NULL DEFAULT 0,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code, room_id),
			UNIQUE KEY uk_rooms_short_id (app_code, room_short_id),
			UNIQUE KEY uk_rooms_owner_user (app_code, owner_user_id),
			KEY idx_rooms_region_status (app_code, visible_region_id, status)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_list_entries (
			app_code VARCHAR(32) NOT NULL,
			room_id VARCHAR(64) NOT NULL,
			room_short_id VARCHAR(32) NOT NULL DEFAULT '',
			visible_region_id BIGINT NOT NULL DEFAULT 0,
			owner_country_code VARCHAR(3) NOT NULL DEFAULT '',
			owner_user_id BIGINT NOT NULL,
			title VARCHAR(128) NOT NULL DEFAULT '',
			cover_url VARCHAR(512) NOT NULL DEFAULT '',
			mode VARCHAR(64) NOT NULL,
			status VARCHAR(32) NOT NULL,
			locked BOOLEAN NOT NULL DEFAULT FALSE,
			close_reason VARCHAR(512) NOT NULL DEFAULT '',
			closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
			closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
			closed_at_ms BIGINT NOT NULL DEFAULT 0,
			heat BIGINT NOT NULL DEFAULT 0,
			online_count INT NOT NULL DEFAULT 0,
			seat_count INT NOT NULL DEFAULT 0,
			occupied_seat_count INT NOT NULL DEFAULT 0,
			sort_score BIGINT NOT NULL DEFAULT 0,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code, room_id),
			KEY idx_room_list_short_id (app_code, room_short_id),
			KEY idx_room_list_region_hot (app_code, visible_region_id, status, sort_score DESC, room_id),
			KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id),
			KEY idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id),
			KEY idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id),
			KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_user_presence (
			app_code VARCHAR(32) NOT NULL,
			user_id BIGINT NOT NULL,
			room_id VARCHAR(64) NOT NULL,
			role VARCHAR(32) NOT NULL,
			publish_state VARCHAR(32) NOT NULL DEFAULT '',
			mic_session_id VARCHAR(128) NOT NULL DEFAULT '',
			room_version BIGINT NOT NULL,
			status VARCHAR(32) NOT NULL,
			joined_at_ms BIGINT NOT NULL DEFAULT 0,
			last_seen_at_ms BIGINT NOT NULL DEFAULT 0,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code, user_id),
			KEY idx_room_user_presence_room (app_code, room_id, status),
			KEY idx_room_user_presence_updated (app_code, status, updated_at_ms)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_user_gift_stats (
			app_code VARCHAR(32) NOT NULL,
			room_id VARCHAR(64) NOT NULL,
			user_id BIGINT NOT NULL,
			gift_value BIGINT NOT NULL DEFAULT 0,
			last_gift_at_ms BIGINT NOT NULL DEFAULT 0,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code, room_id, user_id),
			KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_background_images (
			app_code VARCHAR(32) NOT NULL,
			background_id BIGINT NOT NULL AUTO_INCREMENT,
			room_id VARCHAR(64) NOT NULL,
			image_url VARCHAR(256) NOT NULL,
			created_by_user_id BIGINT NOT NULL,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (background_id),
			UNIQUE KEY uk_room_background_url (app_code, room_id, image_url),
			KEY idx_room_background_list (app_code, room_id, created_at_ms DESC, background_id DESC)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_snapshots (
			app_code VARCHAR(32) NOT NULL,
			room_id VARCHAR(64) NOT NULL,
			room_version BIGINT NOT NULL,
			payload LONGBLOB NOT NULL,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code, room_id),
			INDEX idx_room_snapshots_version (app_code, room_id, room_version)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_command_log (
			app_code VARCHAR(32) NOT NULL,
			id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
			room_id VARCHAR(64) NOT NULL,
			room_version BIGINT NOT NULL,
			command_id VARCHAR(128) NOT NULL,
			command_type VARCHAR(64) NOT NULL,
			owner_node_id VARCHAR(128) NOT NULL DEFAULT '',
			lease_token VARCHAR(128) NOT NULL DEFAULT '',
			payload LONGBLOB NOT NULL,
			replayable BOOLEAN NOT NULL,
			created_at_ms BIGINT NOT NULL,
			UNIQUE KEY uk_room_command (app_code, room_id, command_id),
			KEY idx_room_command_replay (app_code, room_id, room_version)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_outbox (
			app_code VARCHAR(32) NOT NULL,
			event_id VARCHAR(128) NOT NULL,
			event_type VARCHAR(64) NOT NULL,
			room_id VARCHAR(64) NOT NULL,
			status VARCHAR(32) NOT NULL,
			worker_id VARCHAR(128) NOT NULL DEFAULT '',
			lock_until_ms BIGINT NULL,
			envelope LONGBLOB NOT NULL,
			created_at_ms BIGINT NOT NULL,
			retry_count INT NOT NULL DEFAULT 0,
			next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
			last_error TEXT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code, event_id),
			KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id),
			KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id),
			KEY idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id),
			KEY idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_user_feed_entries (
			app_code VARCHAR(32) NOT NULL,
			user_id BIGINT NOT NULL,
			feed_type VARCHAR(32) NOT NULL,
			room_id VARCHAR(64) NOT NULL,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code, user_id, feed_type, room_id),
			KEY idx_room_user_feed_list (app_code, user_id, feed_type, updated_at_ms DESC, room_id)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_follows (
			app_code VARCHAR(32) NOT NULL,
			user_id BIGINT NOT NULL,
			room_id VARCHAR(64) NOT NULL,
			status VARCHAR(32) NOT NULL,
			followed_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code, user_id, room_id),
			KEY idx_room_follows_user_list (app_code, user_id, status, followed_at_ms DESC, room_id),
			KEY idx_room_follows_room (app_code, room_id, status)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_region_pins (
			id BIGINT NOT NULL AUTO_INCREMENT,
			app_code VARCHAR(32) NOT NULL,
			visible_region_id BIGINT NOT NULL,
			pin_type VARCHAR(32) NOT NULL DEFAULT 'region',
			room_id VARCHAR(64) NOT NULL,
			weight BIGINT NOT NULL DEFAULT 0,
			status VARCHAR(32) NOT NULL,
			pinned_at_ms BIGINT NOT NULL,
			expires_at_ms BIGINT NOT NULL,
			cancelled_at_ms BIGINT NOT NULL DEFAULT 0,
			created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
			cancelled_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (id),
			UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id),
			KEY idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id),
			KEY idx_room_region_pins_room (app_code, room_id, status)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间置顶表'`,
		`CREATE TABLE IF NOT EXISTS room_seat_configs (
			app_code VARCHAR(32) NOT NULL,
			allowed_seat_counts VARCHAR(128) NOT NULL,
			default_seat_count INT NOT NULL,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`CREATE TABLE IF NOT EXISTS room_rocket_configs (
			app_code VARCHAR(32) NOT NULL,
			enabled TINYINT(1) NOT NULL DEFAULT 0,
			config_version BIGINT NOT NULL DEFAULT 1,
			fuel_source VARCHAR(32) NOT NULL,
			launch_delay_ms BIGINT NOT NULL,
			broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1,
			broadcast_scope VARCHAR(32) NOT NULL,
			broadcast_delay_ms BIGINT NOT NULL DEFAULT 0,
			reward_stack_policy VARCHAR(32) NOT NULL,
			levels_json JSON NOT NULL,
			gift_fuel_rules_json JSON NOT NULL,
			updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
			created_at_ms BIGINT NOT NULL,
			updated_at_ms BIGINT NOT NULL,
			PRIMARY KEY (app_code),
			KEY idx_room_rocket_enabled (app_code, enabled)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
		`INSERT IGNORE INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
		 VALUES ('lalu', '[10,15,20,25,30]', 15, 0, 0)`,
		`INSERT IGNORE INTO room_rocket_configs (
			app_code,
			enabled,
			config_version,
			fuel_source,
			launch_delay_ms,
			broadcast_enabled,
			broadcast_scope,
			broadcast_delay_ms,
			reward_stack_policy,
			levels_json,
			gift_fuel_rules_json,
			updated_by_admin_id,
			created_at_ms,
			updated_at_ms
		) VALUES (
			'lalu',
			0,
			1,
			'heat_value',
			30000,
			1,
			'region',
			0,
			'allow_stack',
			JSON_ARRAY(
				JSON_OBJECT('level', 1, 'fuelThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
				JSON_OBJECT('level', 2, 'fuelThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
				JSON_OBJECT('level', 3, 'fuelThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
				JSON_OBJECT('level', 4, 'fuelThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
				JSON_OBJECT('level', 5, 'fuelThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'launchAnimationUrl', '', 'launchedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY())
			),
			JSON_ARRAY(),
			0,
			0,
			0
		)`,
		`ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_snapshots MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_command_log MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_outbox MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_user_feed_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_follows MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_region_pins MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_seat_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
		`ALTER TABLE room_rocket_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
	}

	for _, statement := range statements {
		// 每条 DDL 独立执行，失败时返回具体 SQL 错误给启动流程。
		if _, err := r.db.ExecContext(ctx, statement); err != nil {
			return err
		}
	}

	if err := r.ensureRoomListLockSchema(ctx); err != nil {
		return err
	}
	if err := r.ensureRoomListCountrySchema(ctx); err != nil {
		return err
	}
	if err := r.ensureRoomPasswordSchema(ctx); err != nil {
		return err
	}
	if err := r.ensureRoomAdminCloseSchema(ctx); err != nil {
		return err
	}
	if err := r.ensureRoomUserPresenceSchema(ctx); err != nil {
		return err
	}
	if err := r.ensureRoomPinSchema(ctx); err != nil {
		return err
	}

	return r.ensureOutboxRetrySchema(ctx)
}

func (r *Repository) ensureRoomPinSchema(ctx context.Context) error {
	hasColumn, err := r.columnExists(ctx, "room_region_pins", "pin_type")
	if err != nil {
		return err
	}
	if !hasColumn {
		// pin_type 把“全区置顶”和“区域置顶”的作用域写进同一 owner 表；历史数据默认都是区域置顶。
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD COLUMN pin_type VARCHAR(32) NOT NULL DEFAULT 'region' AFTER visible_region_id`); err != nil {
			return err
		}
	}
	hasScopeUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_scope")
	if err != nil {
		return err
	}
	if !hasScopeUnique {
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD UNIQUE KEY uk_room_region_pins_scope (app_code, pin_type, visible_region_id, room_id)`); err != nil {
			return err
		}
	}
	hasLegacyUnique, err := r.indexExists(ctx, "room_region_pins", "uk_room_region_pins_room")
	if err != nil {
		return err
	}
	if hasLegacyUnique {
		// 新唯一键允许 region_id=0 的区域置顶和全区置顶并存；旧唯一键会错误地把两者互斥。
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins DROP INDEX uk_room_region_pins_room`); err != nil {
			return err
		}
	}
	hasActiveScope, err := r.indexExists(ctx, "room_region_pins", "idx_room_region_pins_active_scope")
	if err != nil {
		return err
	}
	if !hasActiveScope {
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_region_pins ADD INDEX idx_room_region_pins_active_scope (app_code, pin_type, visible_region_id, status, pinned_at_ms, expires_at_ms, weight DESC, room_id)`); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) ensureRoomUserPresenceSchema(ctx context.Context) error {
	hasColumn, err := r.columnExists(ctx, "room_user_presence", "joined_at_ms")
	if err != nil {
		return err
	}
	if !hasColumn {
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_user_presence ADD COLUMN joined_at_ms BIGINT NOT NULL DEFAULT 0 AFTER status`); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) ensureRoomPasswordSchema(ctx context.Context) error {
	hasColumn, err := r.columnExists(ctx, "rooms", "room_password_hash")
	if err != nil {
		return err
	}
	if !hasColumn {
		// rooms 是最新快照恢复密码哈希的持久来源；列表只使用 locked 布尔投影。
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE rooms ADD COLUMN room_password_hash VARCHAR(128) NOT NULL DEFAULT '' AFTER status`); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) ensureRoomListLockSchema(ctx context.Context) error {
	hasColumn, err := r.columnExists(ctx, "room_list_entries", "locked")
	if err != nil {
		return err
	}
	if !hasColumn {
		// locked 是列表/搜索需要的纯投影字段，历史行默认 false，后续房间状态变更会刷新为真实值。
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN locked BOOLEAN NOT NULL DEFAULT FALSE AFTER status`); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) ensureRoomListCountrySchema(ctx context.Context) error {
	hasColumn, err := r.columnExists(ctx, "room_list_entries", "owner_country_code")
	if err != nil {
		return err
	}
	if !hasColumn {
		// owner_country_code 是房主国家快照，只服务发现页国家筛选；历史行先为空，后续房间投影刷新会补真实值。
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_list_entries ADD COLUMN owner_country_code VARCHAR(3) NOT NULL DEFAULT '' AFTER visible_region_id`); err != nil {
			return err
		}
	}
	indexes := []struct {
		name      string
		statement string
	}{
		{
			name:      "idx_room_list_region_country_hot",
			statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_hot (app_code, visible_region_id, owner_country_code, status, sort_score DESC, room_id)`,
		},
		{
			name:      "idx_room_list_region_country_new",
			statement: `ALTER TABLE room_list_entries ADD INDEX idx_room_list_region_country_new (app_code, visible_region_id, owner_country_code, status, created_at_ms DESC, room_id)`,
		},
	}
	for _, item := range indexes {
		hasIndex, err := r.indexExists(ctx, "room_list_entries", item.name)
		if err != nil {
			return err
		}
		if !hasIndex {
			if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
				return err
			}
		}
	}
	return nil
}

func (r *Repository) ensureRoomAdminCloseSchema(ctx context.Context) error {
	columns := []struct {
		table     string
		column    string
		statement string
	}{
		{"rooms", "close_reason", `ALTER TABLE rooms ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER room_password_hash`},
		{"rooms", "closed_by_admin_id", `ALTER TABLE rooms ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`},
		{"rooms", "closed_by_admin_name", `ALTER TABLE rooms ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`},
		{"rooms", "closed_at_ms", `ALTER TABLE rooms ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`},
		{"room_list_entries", "close_reason", `ALTER TABLE room_list_entries ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER locked`},
		{"room_list_entries", "closed_by_admin_id", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`},
		{"room_list_entries", "closed_by_admin_name", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`},
		{"room_list_entries", "closed_at_ms", `ALTER TABLE room_list_entries ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`},
	}
	for _, item := range columns {
		hasColumn, err := r.columnExists(ctx, item.table, item.column)
		if err != nil {
			return err
		}
		if !hasColumn {
			if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
				return err
			}
		}
	}
	return nil
}

func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error {
	hasColumn, err := r.columnExists(ctx, "room_outbox", "next_retry_at_ms")
	if err != nil {
		return err
	}
	if !hasColumn {
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD COLUMN next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count`); err != nil {
			return err
		}
	}
	indexes := []struct {
		name      string
		statement string
	}{
		{"idx_room_outbox_pending", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms, event_id)`},
		{"idx_room_outbox_retry", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms, event_id)`},
		{"idx_room_outbox_claim", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms, event_id)`},
		{"idx_room_outbox_event_created", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_event_created (app_code, event_type, created_at_ms, event_id)`},
		{"idx_room_outbox_retention", `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retention (app_code, status, updated_at_ms, event_id)`},
	}
	for _, index := range indexes {
		hasIndex, err := r.indexExists(ctx, "room_outbox", index.name)
		if err != nil {
			return err
		}
		if !hasIndex {
			if _, err := r.db.ExecContext(ctx, index.statement); err != nil {
				return err
			}
		}
	}
	return nil
}

func (r *Repository) columnExists(ctx context.Context, tableName string, columnName string) (bool, error) {
	var exists int
	err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM information_schema.COLUMNS
		WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
	`, tableName, columnName).Scan(&exists)
	return exists > 0, err
}

func (r *Repository) indexExists(ctx context.Context, tableName string, indexName string) (bool, error) {
	var exists int
	err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM information_schema.STATISTICS
		WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
	`, tableName, indexName).Scan(&exists)
	return exists > 0, err
}

// SaveRoomMeta 插入房间元数据。
func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error {
	// rooms 表只保存恢复初始状态需要的元数据，实时态以 snapshot/command log 为准。
	appCode := normalizedRecordAppCode(ctx, meta.AppCode)
	nowMS := time.Now().UTC().UnixMilli()
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO rooms (app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id, created_at_ms, updated_at_ms)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appCode,
		meta.RoomID,
		meta.RoomShortID,
		meta.OwnerUserID,
		meta.SeatCount,
		meta.Mode,
		meta.Status,
		meta.RoomPasswordHash,
		meta.VisibleRegionID,
		nowMS,
		nowMS,
	)

	return mapRoomMetaDuplicateError(err)
}

// GetRoomMeta 查询房间元数据。
func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservice.RoomMeta, bool, error) {
	// meta 是无内存 Cell 恢复的入口；不存在时上层返回 room not found。
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id
		 FROM rooms
		 WHERE app_code = ? AND room_id = ?`,
		appcode.FromContext(ctx),
		roomID,
	)

	var meta roomservice.RoomMeta
	if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			// 未创建房间不是数据库错误，用 exists=false 表达。
			return roomservice.RoomMeta{}, false, nil
		}

		return roomservice.RoomMeta{}, false, err
	}

	return meta, true, nil
}

// GetRoomMetaByOwner 查询用户已经创建的房间。
func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (roomservice.RoomMeta, bool, error) {
	// rooms.owner_user_id 有唯一约束，查询最多返回一条；该读路径用于 CreateRoom 前置失败。
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id
		 FROM rooms
		 WHERE app_code = ? AND owner_user_id = ?
		 LIMIT 1`,
		appcode.FromContext(ctx),
		ownerUserID,
	)

	var meta roomservice.RoomMeta
	if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return roomservice.RoomMeta{}, false, nil
		}

		return roomservice.RoomMeta{}, false, err
	}

	return meta, true, nil
}

// SaveRoomBackground 保存房间背景图素材；相同房间相同 URL 按幂等返回原记录。
func (r *Repository) SaveRoomBackground(ctx context.Context, background roomservice.RoomBackgroundImage) (roomservice.RoomBackgroundImage, error) {
	appCode := normalizedRecordAppCode(ctx, background.AppCode)
	roomID := strings.TrimSpace(background.RoomID)
	imageURL := strings.TrimSpace(background.ImageURL)
	nowMS := background.CreatedAtMS
	if nowMS <= 0 {
		nowMS = time.Now().UTC().UnixMilli()
	}

	_, err := r.db.ExecContext(ctx, `
		INSERT INTO room_background_images (
		  app_code, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)
	`, appCode, roomID, imageURL, background.CreatedByUserID, nowMS, nowMS)
	if err != nil {
		return roomservice.RoomBackgroundImage{}, err
	}

	row := r.db.QueryRowContext(ctx, `
		SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
		FROM room_background_images
		WHERE app_code = ? AND room_id = ? AND image_url = ?
		LIMIT 1
	`, appCode, roomID, imageURL)
	return scanRoomBackground(row)
}

// GetRoomBackground 精确读取一个房间背景图素材。
func (r *Repository) GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (roomservice.RoomBackgroundImage, bool, error) {
	row := r.db.QueryRowContext(ctx, `
		SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
		FROM room_background_images
		WHERE app_code = ? AND room_id = ? AND background_id = ?
		LIMIT 1
	`, appcode.FromContext(ctx), strings.TrimSpace(roomID), backgroundID)

	background, err := scanRoomBackground(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return roomservice.RoomBackgroundImage{}, false, nil
		}
		return roomservice.RoomBackgroundImage{}, false, err
	}
	return background, true, nil
}

// ListRoomBackgrounds 返回某个房间保存过的背景图素材，按最近保存优先。
func (r *Repository) ListRoomBackgrounds(ctx context.Context, roomID string) ([]roomservice.RoomBackgroundImage, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, background_id, room_id, image_url, created_by_user_id, created_at_ms, updated_at_ms
		FROM room_background_images
		WHERE app_code = ? AND room_id = ?
		ORDER BY created_at_ms DESC, background_id DESC
	`, appcode.FromContext(ctx), strings.TrimSpace(roomID))
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	backgrounds := []roomservice.RoomBackgroundImage{}
	for rows.Next() {
		background, err := scanRoomBackground(rows)
		if err != nil {
			return nil, err
		}
		backgrounds = append(backgrounds, background)
	}
	return backgrounds, rows.Err()
}

func scanRoomBackground(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomBackgroundImage, error) {
	var background roomservice.RoomBackgroundImage
	err := scanner.Scan(
		&background.AppCode,
		&background.BackgroundID,
		&background.RoomID,
		&background.ImageURL,
		&background.CreatedByUserID,
		&background.CreatedAtMS,
		&background.UpdatedAtMS,
	)
	return background, err
}

// GetCommand 读取已提交命令，用于幂等重试和 command_id 冲突判定。
func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) {
	// 幂等检查只看 command log，只有成功提交过的命令才算 seen。
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
		 FROM room_command_log
		 WHERE app_code = ? AND room_id = ? AND command_id = ?
		 LIMIT 1`,
		appcode.FromContext(ctx),
		roomID,
		commandID,
	)

	var record roomservice.CommandRecord
	if err := row.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			// 没有命令日志表示本命令尚未成功提交。
			return roomservice.CommandRecord{}, false, nil
		}

		return roomservice.CommandRecord{}, false, err
	}

	return record, true, nil
}

// SaveCommand 追加一条成功命令日志。
func (r *Repository) SaveCommand(ctx context.Context, record roomservice.CommandRecord) error {
	// ON DUPLICATE KEY 保证重复命令写入不会破坏已提交记录。
	appCode := normalizedRecordAppCode(ctx, record.AppCode)
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE command_id = command_id`,
		appCode,
		record.RoomID,
		record.RoomVersion,
		record.CommandID,
		record.CommandType,
		record.OwnerNodeID,
		record.LeaseToken,
		record.Payload,
		record.Replayable,
		record.CreatedAtMS,
	)

	return err
}

// SaveMutation 在同一事务中提交命令日志、outbox 和可选房间状态。
func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.MutationCommit) error {
	appCode := normalizedRecordAppCode(ctx, commit.Command.AppCode)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE command_id = command_id`,
		appCode,
		commit.Command.RoomID,
		commit.Command.RoomVersion,
		commit.Command.CommandID,
		commit.Command.CommandType,
		commit.Command.OwnerNodeID,
		commit.Command.LeaseToken,
		commit.Command.Payload,
		commit.Command.Replayable,
		commit.Command.CreatedAtMS,
	); err != nil {
		_ = tx.Rollback()
		return err
	}

	for _, record := range commit.OutboxRecords {
		record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
		if record.Envelope != nil {
			record.Envelope.AppCode = record.AppCode
		}
		envelopeBytes, err := proto.Marshal(record.Envelope)
		if err != nil {
			_ = tx.Rollback()
			return err
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
			 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			 ON DUPLICATE KEY UPDATE event_id = event_id`,
			record.AppCode,
			record.EventID,
			record.EventType,
			record.RoomID,
			record.Status,
			envelopeBytes,
			record.CreatedAtMS,
			record.RetryCount,
			record.NextRetryAtMS,
			nullableString(record.LastError),
			record.CreatedAtMS,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}

	if strings.TrimSpace(commit.RoomStatus) != "" {
		if _, err := tx.ExecContext(ctx,
			`UPDATE rooms
			 SET status = ?, updated_at_ms = ?
			 WHERE app_code = ? AND room_id = ?`,
			commit.RoomStatus,
			commit.Command.CreatedAtMS,
			appCode,
			commit.Command.RoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
		if _, err := tx.ExecContext(ctx,
			`UPDATE room_list_entries
			 SET status = ?, updated_at_ms = ?
			 WHERE app_code = ? AND room_id = ?`,
			commit.RoomStatus,
			commit.Command.CreatedAtMS,
			appCode,
			commit.Command.RoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}
	if commit.RoomSeatCount != nil {
		if _, err := tx.ExecContext(ctx,
			`UPDATE rooms
			 SET seat_count = ?, updated_at_ms = ?
			 WHERE app_code = ? AND room_id = ?`,
			*commit.RoomSeatCount,
			commit.Command.CreatedAtMS,
			appCode,
			commit.Command.RoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
		if _, err := tx.ExecContext(ctx,
			`UPDATE room_list_entries
			 SET seat_count = ?, updated_at_ms = ?
			 WHERE app_code = ? AND room_id = ?`,
			*commit.RoomSeatCount,
			commit.Command.CreatedAtMS,
			appCode,
			commit.Command.RoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}
	if commit.RoomPasswordHash != nil {
		locked := strings.TrimSpace(*commit.RoomPasswordHash) != ""
		if _, err := tx.ExecContext(ctx,
			`UPDATE rooms
			 SET room_password_hash = ?, updated_at_ms = ?
			 WHERE app_code = ? AND room_id = ?`,
			*commit.RoomPasswordHash,
			commit.Command.CreatedAtMS,
			appCode,
			commit.Command.RoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
		if _, err := tx.ExecContext(ctx,
			`UPDATE room_list_entries
			 SET locked = ?, updated_at_ms = ?
			 WHERE app_code = ? AND room_id = ?`,
			locked,
			commit.Command.CreatedAtMS,
			appCode,
			commit.Command.RoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}
	if commit.RoomMode != nil {
		if _, err := tx.ExecContext(ctx,
			`UPDATE rooms SET mode = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`,
			*commit.RoomMode, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}
	if commit.VisibleRegionID != nil {
		if _, err := tx.ExecContext(ctx,
			`UPDATE rooms SET visible_region_id = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`,
			*commit.VisibleRegionID, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}
	if commit.CloseInfo != nil {
		info := commit.CloseInfo
		reason, adminName, closedAt := info.Reason, info.AdminName, info.ClosedAtMS
		adminID := info.AdminID
		if info.ClearOnOpen {
			reason, adminName, closedAt, adminID = "", "", 0, 0
		}
		for _, table := range []string{"rooms", "room_list_entries"} {
			if _, err := tx.ExecContext(ctx,
				`UPDATE `+table+`
				 SET close_reason = ?, closed_by_admin_id = ?, closed_by_admin_name = ?, closed_at_ms = ?, updated_at_ms = ?
				 WHERE app_code = ? AND room_id = ?`,
				reason, adminID, adminName, closedAt, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
			); err != nil {
				_ = tx.Rollback()
				return err
			}
		}
	}
	for _, stat := range commit.RoomUserGiftStats {
		if stat.UserID <= 0 || stat.GiftValue <= 0 {
			continue
		}
		statAppCode := normalizedRecordAppCode(ctx, stat.AppCode)
		statRoomID := strings.TrimSpace(stat.RoomID)
		if statRoomID == "" {
			statRoomID = commit.Command.RoomID
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO room_user_gift_stats (
			   app_code, room_id, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?)
			 ON DUPLICATE KEY UPDATE
			   gift_value = gift_value + VALUES(gift_value),
			   last_gift_at_ms = VALUES(last_gift_at_ms),
			   updated_at_ms = VALUES(updated_at_ms)`,
			statAppCode,
			statRoomID,
			stat.UserID,
			stat.GiftValue,
			stat.LastGiftAtMS,
			commit.Command.CreatedAtMS,
			commit.Command.CreatedAtMS,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}

	return tx.Commit()
}

// GetRoomSeatConfig 读取当前 App 的房间座位数配置。
func (r *Repository) GetRoomSeatConfig(ctx context.Context) (roomservice.RoomSeatConfig, bool, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, allowed_seat_counts, default_seat_count, updated_at_ms
		 FROM room_seat_configs
		 WHERE app_code = ?`,
		appcode.FromContext(ctx),
	)

	var config roomservice.RoomSeatConfig
	var rawAllowed string
	if err := row.Scan(&config.AppCode, &rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return roomservice.RoomSeatConfig{}, false, nil
		}
		return roomservice.RoomSeatConfig{}, false, err
	}
	if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil {
		return roomservice.RoomSeatConfig{}, false, err
	}

	return config, true, nil
}

// UpsertRoomSeatConfig 写入当前 App 的房间座位数配置。
func (r *Repository) UpsertRoomSeatConfig(ctx context.Context, config roomservice.RoomSeatConfig) error {
	appCode := normalizedRecordAppCode(ctx, config.AppCode)
	rawAllowed, err := json.Marshal(config.AllowedSeatCounts)
	if err != nil {
		return err
	}
	nowMS := config.UpdatedAtMS
	if nowMS <= 0 {
		nowMS = time.Now().UTC().UnixMilli()
	}
	_, err = r.db.ExecContext(ctx,
		`INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
		 VALUES (?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE
		   allowed_seat_counts = VALUES(allowed_seat_counts),
		   default_seat_count = VALUES(default_seat_count),
		   updated_at_ms = VALUES(updated_at_ms)`,
		appCode,
		string(rawAllowed),
		config.DefaultSeatCount,
		nowMS,
		nowMS,
	)
	return err
}

// GetRoomRocketConfig 读取当前 App 的语音房火箭配置。
func (r *Repository) GetRoomRocketConfig(ctx context.Context) (roomservice.RoomRocketConfig, bool, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, enabled, config_version, fuel_source, launch_delay_ms,
		        broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy,
		        levels_json, gift_fuel_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms
		 FROM room_rocket_configs
		 WHERE app_code = ?`,
		appcode.FromContext(ctx),
	)

	var config roomservice.RoomRocketConfig
	var enabled int
	var broadcastEnabled int
	var rawLevels string
	var rawRules string
	if err := row.Scan(
		&config.AppCode,
		&enabled,
		&config.ConfigVersion,
		&config.FuelSource,
		&config.LaunchDelayMS,
		&broadcastEnabled,
		&config.BroadcastScope,
		&config.BroadcastDelayMS,
		&config.RewardStackPolicy,
		&rawLevels,
		&rawRules,
		&config.UpdatedByAdminID,
		&config.CreatedAtMS,
		&config.UpdatedAtMS,
	); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return roomservice.RoomRocketConfig{}, false, nil
		}
		return roomservice.RoomRocketConfig{}, false, err
	}
	config.Enabled = enabled == 1
	config.BroadcastEnabled = broadcastEnabled == 1
	if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil {
		return roomservice.RoomRocketConfig{}, false, err
	}
	if err := json.Unmarshal([]byte(rawRules), &config.GiftFuelRules); err != nil {
		return roomservice.RoomRocketConfig{}, false, err
	}

	return config, true, nil
}

// UpsertRoomRocketConfig 写入当前 App 的语音房火箭配置。
func (r *Repository) UpsertRoomRocketConfig(ctx context.Context, config roomservice.RoomRocketConfig) error {
	appCode := normalizedRecordAppCode(ctx, config.AppCode)
	rawLevels, err := json.Marshal(config.Levels)
	if err != nil {
		return err
	}
	rawRules, err := json.Marshal(config.GiftFuelRules)
	if err != nil {
		return err
	}
	nowMS := config.UpdatedAtMS
	if nowMS <= 0 {
		nowMS = time.Now().UTC().UnixMilli()
	}
	createdAtMS := config.CreatedAtMS
	if createdAtMS <= 0 {
		createdAtMS = nowMS
	}
	_, err = r.db.ExecContext(ctx,
		`INSERT INTO room_rocket_configs (
		   app_code, enabled, config_version, fuel_source, launch_delay_ms,
		   broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy,
		   levels_json, gift_fuel_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE
		   enabled = VALUES(enabled),
		   config_version = VALUES(config_version),
		   fuel_source = VALUES(fuel_source),
		   launch_delay_ms = VALUES(launch_delay_ms),
		   broadcast_enabled = VALUES(broadcast_enabled),
		   broadcast_scope = VALUES(broadcast_scope),
		   broadcast_delay_ms = VALUES(broadcast_delay_ms),
		   reward_stack_policy = VALUES(reward_stack_policy),
		   levels_json = VALUES(levels_json),
		   gift_fuel_rules_json = VALUES(gift_fuel_rules_json),
		   updated_by_admin_id = VALUES(updated_by_admin_id),
		   updated_at_ms = VALUES(updated_at_ms)`,
		appCode,
		boolToInt(config.Enabled),
		config.ConfigVersion,
		config.FuelSource,
		config.LaunchDelayMS,
		boolToInt(config.BroadcastEnabled),
		config.BroadcastScope,
		config.BroadcastDelayMS,
		config.RewardStackPolicy,
		string(rawLevels),
		string(rawRules),
		config.UpdatedByAdminID,
		createdAtMS,
		nowMS,
	)
	return err
}

// ListCommandsAfter 返回快照版本之后的可回放命令日志。
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
	// 恢复必须按 room_version 再按自增 id 排序，确保同版本异常数据也有稳定顺序。
	rows, err := r.db.QueryContext(ctx,
		`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
		 FROM room_command_log
		 WHERE app_code = ? AND room_id = ? AND room_version > ?
		 ORDER BY room_version ASC, id ASC`,
		appcode.FromContext(ctx),
		roomID,
		roomVersion,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	records := make([]roomservice.CommandRecord, 0)
	for rows.Next() {
		// payload 保持原始字节，领域层根据 command_type 反序列化。
		var record roomservice.CommandRecord
		if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
			return nil, err
		}

		records = append(records, record)
	}

	if err := rows.Err(); err != nil {
		// rows.Err 捕获迭代过程中发生的底层连接或扫描错误。
		return nil, err
	}

	return records, nil
}

// SaveSnapshot 保存房间最新快照。
func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error {
	// 快照按 room_id 保留最新一条，版本更旧的写入不会覆盖较新的恢复来源。
	appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms)
		 VALUES (?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE
		   room_version = IF(VALUES(room_version) >= room_version, VALUES(room_version), room_version),
		   payload = IF(VALUES(room_version) >= room_version, VALUES(payload), payload),
		   created_at_ms = IF(VALUES(room_version) >= room_version, VALUES(created_at_ms), created_at_ms),
		   updated_at_ms = IF(VALUES(room_version) >= room_version, VALUES(updated_at_ms), updated_at_ms)`,
		appCode,
		snapshot.RoomID,
		snapshot.RoomVersion,
		snapshot.Payload,
		snapshot.CreatedAtMS,
		snapshot.CreatedAtMS,
	)

	return err
}

// GetLatestSnapshot 读取房间最新快照。
func (r *Repository) GetLatestSnapshot(ctx context.Context, roomID string) (roomservice.SnapshotRecord, bool, error) {
	// room_snapshots 主键是 room_id，因此查询结果最多一条。
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, room_id, room_version, payload, created_at_ms
		 FROM room_snapshots
		 WHERE app_code = ? AND room_id = ?`,
		appcode.FromContext(ctx),
		roomID,
	)

	var snapshot roomservice.SnapshotRecord
	if err := row.Scan(&snapshot.AppCode, &snapshot.RoomID, &snapshot.RoomVersion, &snapshot.Payload, &snapshot.CreatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			// 没有快照时上层会从 RoomMeta 和 command log 从头恢复。
			return roomservice.SnapshotRecord{}, false, nil
		}

		return roomservice.SnapshotRecord{}, false, err
	}

	return snapshot, true, nil
}

// SaveOutbox 追加待投递 outbox 事件。
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
	if len(records) == 0 {
		// 没有事件时保持无副作用，方便领域层统一调用。
		return nil
	}

	// 多条事件用一个事务写入，保证同一命令产生的事件不会部分入库。
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}

	for _, record := range records {
		record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
		if record.Envelope != nil {
			record.Envelope.AppCode = record.AppCode
		}
		// envelope 是 protobuf 信封，存储层不解析具体事件 body。
		envelopeBytes, err := proto.Marshal(record.Envelope)
		if err != nil {
			_ = tx.Rollback()
			return err
		}

		if _, err := tx.ExecContext(ctx,
			`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
			 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			 ON DUPLICATE KEY UPDATE event_id = event_id`,
			record.AppCode,
			record.EventID,
			record.EventType,
			record.RoomID,
			record.Status,
			envelopeBytes,
			record.CreatedAtMS,
			record.RetryCount,
			record.NextRetryAtMS,
			nullableString(record.LastError),
			record.CreatedAtMS,
		); err != nil {
			// 任一事件失败就回滚整批，避免 GiftSent/HeatChanged/RankChanged 部分缺失。
			_ = tx.Rollback()
			return err
		}
	}

	// commit 成功后事件才对 outbox worker 可见。
	return tx.Commit()
}

// ListPendingOutbox 返回待投递事件。
func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) {
	if limit <= 0 {
		// 默认批量上限避免调用方传 0 导致全表扫描。
		limit = 100
	}

	// 按创建时间顺序扫描 pending，尽量保持事件投递顺序。
	nowMS := time.Now().UTC().UnixMilli()
	rows, err := r.db.QueryContext(ctx,
		`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
		 FROM room_outbox FORCE INDEX (idx_room_outbox_pending)
		 WHERE app_code = ? AND status IN (?, ?)
		   AND next_retry_at_ms <= ?
		 ORDER BY created_at_ms ASC
		 LIMIT ?`,
		appcode.FromContext(ctx),
		outbox.StatusPending,
		outbox.StatusRetryable,
		nowMS,
		limit,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	records := make([]outbox.Record, 0, limit)
	for rows.Next() {
		// 每条 outbox 记录恢复 envelope，发布器只消费 protobuf 信封。
		var envelopeBytes []byte
		var record outbox.Record
		var lockUntil sql.Null[int64]
		if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
			return nil, err
		}
		if lockUntil.Valid {
			record.LockUntilMS = lockUntil.V
		}

		var envelope roomeventsv1.EventEnvelope
		if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
			// envelope 损坏说明 outbox 数据不可投递，需要暴露错误而不是跳过。
			return nil, err
		}

		record.Envelope = &envelope
		record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
		records = append(records, record)
	}

	if err := rows.Err(); err != nil {
		return nil, err
	}

	return records, nil
}

// ClaimPendingOutbox 抢占一批待投递事件，避免多 worker 同时投递同一批。
func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error) {
	if limit <= 0 {
		limit = 100
	}
	if strings.TrimSpace(workerID) == "" {
		workerID = "room-outbox-worker"
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}

	nowMS := time.Now().UTC().UnixMilli()
	appCode := appcode.FromContext(ctx)
	records := make([]outbox.Record, 0, limit)
	claimBranches := []struct {
		query string
		args  []any
	}{
		{
			query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
			 FROM room_outbox FORCE INDEX (idx_room_outbox_pending)
			 WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL
			 ORDER BY created_at_ms ASC, event_id ASC
			 LIMIT ? FOR UPDATE SKIP LOCKED`,
			args: []any{appCode, outbox.StatusPending, nowMS},
		},
		{
			query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
			 FROM room_outbox FORCE INDEX (idx_room_outbox_pending)
			 WHERE app_code = ? AND status = ? AND next_retry_at_ms <= ? AND lock_until_ms IS NULL
			 ORDER BY created_at_ms ASC, event_id ASC
			 LIMIT ? FOR UPDATE SKIP LOCKED`,
			args: []any{appCode, outbox.StatusRetryable, nowMS},
		},
		{
			query: `SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
			 FROM room_outbox FORCE INDEX (idx_room_outbox_claim)
			 WHERE app_code = ? AND status = ? AND lock_until_ms <= ?
			 ORDER BY created_at_ms ASC, event_id ASC
			 LIMIT ? FOR UPDATE SKIP LOCKED`,
			args: []any{appCode, outbox.StatusDelivering, nowMS},
		},
	}
	for _, branch := range claimBranches {
		remaining := limit - len(records)
		if remaining <= 0 {
			break
		}
		args := append(append([]any{}, branch.args...), remaining)
		branchRecords, err := r.queryRoomOutboxRecords(ctx, tx, branch.query, args...)
		if err != nil {
			_ = tx.Rollback()
			return nil, err
		}
		records = append(records, branchRecords...)
	}

	for _, record := range records {
		if _, err := tx.ExecContext(ctx,
			`UPDATE room_outbox
			 SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
			 WHERE app_code = ? AND event_id = ?`,
			outbox.StatusDelivering,
			workerID,
			lockUntilMS,
			nowMS,
			appCode,
			record.EventID,
		); err != nil {
			_ = tx.Rollback()
			return nil, err
		}
	}

	if err := tx.Commit(); err != nil {
		return nil, err
	}

	for index := range records {
		records[index].Status = outbox.StatusDelivering
		records[index].WorkerID = workerID
		records[index].LockUntilMS = lockUntilMS
	}

	return records, nil
}

func (r *Repository) queryRoomOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]outbox.Record, error) {
	rows, err := tx.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	records := make([]outbox.Record, 0)
	for rows.Next() {
		var envelopeBytes []byte
		var record outbox.Record
		var lockUntilValue sql.Null[int64]
		if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
			return nil, err
		}
		if lockUntilValue.Valid {
			record.LockUntilMS = lockUntilValue.V
		}

		var envelope roomeventsv1.EventEnvelope
		if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
			return nil, err
		}

		record.Envelope = &envelope
		record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
		records = append(records, record)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return records, nil
}

// MarkOutboxDelivered 标记事件投递成功。
func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) error {
	// 成功后清空 last_error，后续扫描不再返回该事件。
	_, err := r.db.ExecContext(ctx,
		`UPDATE room_outbox
		 SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = NULL, updated_at_ms = ?
		 WHERE app_code = ? AND event_id = ?`,
		outbox.StatusDelivered,
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)

	return err
}

// MarkOutboxRetryable 标记事件投递失败并写入下一次重试时间。
func (r *Repository) MarkOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
	// 失败转入 retryable，worker 只有到 next_retry_at_ms 后才会重新抢占。
	_, err := r.db.ExecContext(ctx,
		`UPDATE room_outbox
		 SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
		 WHERE app_code = ? AND event_id = ?`,
		outbox.StatusRetryable,
		nextRetryAtMS,
		lastErr,
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)

	return err
}

// MarkOutboxDead 把超过最大重试次数的事件转入 failed。
func (r *Repository) MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE room_outbox
		 SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ?
		 WHERE app_code = ? AND event_id = ?`,
		outbox.StatusFailed,
		lastErr,
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)

	return err
}

// UpsertRoomListEntry 写入房间列表读模型。
func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.RoomListEntry) error {
	// created_at_ms 首次插入后保持不变，避免 Join/Leave/SendGift 等更新把 new tab 顺序洗掉。
	appCode := normalizedRecordAppCode(ctx, entry.AppCode)
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO room_list_entries (
		   app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
		   heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE
		   room_short_id = VALUES(room_short_id),
		   visible_region_id = VALUES(visible_region_id),
		   owner_country_code = VALUES(owner_country_code),
		   owner_user_id = VALUES(owner_user_id),
		   title = VALUES(title),
		   cover_url = VALUES(cover_url),
		   mode = VALUES(mode),
		   status = VALUES(status),
		   locked = VALUES(locked),
		   heat = VALUES(heat),
		   online_count = VALUES(online_count),
		   seat_count = VALUES(seat_count),
		   occupied_seat_count = VALUES(occupied_seat_count),
		   sort_score = VALUES(sort_score),
		   updated_at_ms = VALUES(updated_at_ms)`,
		appCode,
		entry.RoomID,
		entry.RoomShortID,
		entry.VisibleRegionID,
		entry.OwnerCountryCode,
		entry.OwnerUserID,
		entry.Title,
		entry.CoverURL,
		entry.Mode,
		entry.Status,
		entry.Locked,
		entry.Heat,
		entry.OnlineCount,
		entry.SeatCount,
		entry.OccupiedSeatCount,
		entry.SortScore,
		entry.CreatedAtMS,
		entry.UpdatedAtMS,
	)

	return err
}

// ListRoomListEntries 读取公共发现列表读模型，并按查询区域把全区置顶、区域置顶、本区房和外区房排成稳定分页序列。
func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.RoomListQuery) ([]roomservice.RoomListEntry, error) {
	if query.Limit <= 0 {
		query.Limit = 20
	}
	query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)
	if query.NowMS <= 0 {
		query.NowMS = time.Now().UTC().UnixMilli()
	}

	sqlText, args := buildRoomListQuerySQL(query)
	rows, err := r.db.QueryContext(ctx, sqlText, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	entries := make([]roomservice.RoomListEntry, 0, query.Limit)
	for rows.Next() {
		var entry roomservice.RoomListEntry
		var pinned int64
		if err := rows.Scan(
			&entry.AppCode,
			&entry.RoomID,
			&entry.RoomShortID,
			&entry.VisibleRegionID,
			&entry.OwnerCountryCode,
			&entry.OwnerUserID,
			&entry.Title,
			&entry.CoverURL,
			&entry.Mode,
			&entry.Status,
			&entry.Locked,
			&entry.Heat,
			&entry.OnlineCount,
			&entry.SeatCount,
			&entry.OccupiedSeatCount,
			&entry.SortScore,
			&entry.CreatedAtMS,
			&entry.UpdatedAtMS,
			&pinned,
			&entry.PinListRank,
			&entry.PinWeight,
			&entry.PinnedUntilMS,
		); err != nil {
			return nil, err
		}

		entry.IsPinned = pinned > 0
		entries = append(entries, entry)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	return entries, nil
}

// ListRoomListEntriesByIDs 按榜单已命中的 room_id 批量读取列表卡片。
func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomservice.RoomListEntriesByIDQuery) (map[string]roomservice.RoomListEntry, error) {
	appCode := normalizedRecordAppCode(ctx, query.AppCode)
	roomIDs := uniqueRoomListIDs(query.RoomIDs)
	result := make(map[string]roomservice.RoomListEntry, len(roomIDs))
	if len(roomIDs) == 0 {
		return result, nil
	}

	placeholders := strings.TrimRight(strings.Repeat("?,", len(roomIDs)), ",")
	args := make([]any, 0, len(roomIDs)+1)
	args = append(args, appCode)
	for _, roomID := range roomIDs {
		args = append(args, roomID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
		       heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
		FROM room_list_entries
		WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	for rows.Next() {
		var entry roomservice.RoomListEntry
		if err := rows.Scan(
			&entry.AppCode,
			&entry.RoomID,
			&entry.RoomShortID,
			&entry.VisibleRegionID,
			&entry.OwnerCountryCode,
			&entry.OwnerUserID,
			&entry.Title,
			&entry.CoverURL,
			&entry.Mode,
			&entry.Status,
			&entry.Locked,
			&entry.Heat,
			&entry.OnlineCount,
			&entry.SeatCount,
			&entry.OccupiedSeatCount,
			&entry.SortScore,
			&entry.CreatedAtMS,
			&entry.UpdatedAtMS,
		); err != nil {
			return nil, err
		}
		result[entry.RoomID] = entry
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return result, nil
}

func uniqueRoomListIDs(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}

// UpsertRoomUserFeedEntry 写入用户房间关系流索引。
func (r *Repository) UpsertRoomUserFeedEntry(ctx context.Context, entry roomservice.RoomUserFeedEntry) error {
	// feed 只保存用户到房间的索引；房间卡片字段仍以 room_list_entries 为准，避免复制不一致。
	appCode := normalizedRecordAppCode(ctx, entry.AppCode)
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO room_user_feed_entries (app_code, user_id, feed_type, room_id, created_at_ms, updated_at_ms)
		 VALUES (?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
		appCode,
		entry.UserID,
		entry.FeedType,
		entry.RoomID,
		entry.CreatedAtMS,
		entry.UpdatedAtMS,
	)

	return err
}

// ListRoomUserFeedEntries 按用户关系流读取房间卡片。
func (r *Repository) ListRoomUserFeedEntries(ctx context.Context, query roomservice.RoomUserFeedQuery) ([]roomservice.RoomListEntry, error) {
	if query.Limit <= 0 {
		query.Limit = 20
	}
	query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)

	sqlText, args := buildRoomUserFeedQuerySQL(query)
	rows, err := r.db.QueryContext(ctx, sqlText, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	entries := make([]roomservice.RoomListEntry, 0, query.Limit)
	for rows.Next() {
		var entry roomservice.RoomListEntry
		if err := rows.Scan(
			&entry.AppCode,
			&entry.RoomID,
			&entry.RoomShortID,
			&entry.VisibleRegionID,
			&entry.OwnerCountryCode,
			&entry.OwnerUserID,
			&entry.Title,
			&entry.CoverURL,
			&entry.Mode,
			&entry.Status,
			&entry.Locked,
			&entry.Heat,
			&entry.OnlineCount,
			&entry.SeatCount,
			&entry.OccupiedSeatCount,
			&entry.SortScore,
			&entry.CreatedAtMS,
			&entry.UpdatedAtMS,
		); err != nil {
			return nil, err
		}

		entries = append(entries, entry)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	return entries, nil
}

// FollowRoom 建立或恢复用户对房间的关注关系。
func (r *Repository) FollowRoom(ctx context.Context, userID int64, roomID string, nowMS int64) (roomservice.RoomFollowRecord, error) {
	appCode := appcode.FromContext(ctx)
	if nowMS <= 0 {
		nowMS = time.Now().UTC().UnixMilli()
	}
	if _, err := r.db.ExecContext(ctx,
		`INSERT INTO room_follows (app_code, user_id, room_id, status, followed_at_ms, updated_at_ms)
		 VALUES (?, ?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE
		   followed_at_ms = IF(status = ?, followed_at_ms, VALUES(followed_at_ms)),
		   status = VALUES(status),
		   updated_at_ms = VALUES(updated_at_ms)`,
		appCode,
		userID,
		roomID,
		roomFollowStatusActiveSQL,
		nowMS,
		nowMS,
		roomFollowStatusActiveSQL,
	); err != nil {
		return roomservice.RoomFollowRecord{}, err
	}

	record, _, err := r.getRoomFollowRecord(ctx, userID, roomID)
	return record, err
}

// UnfollowRoom 取消用户对房间的关注关系；没有现存关系时不写入新行。
func (r *Repository) UnfollowRoom(ctx context.Context, userID int64, roomID string, nowMS int64) error {
	if nowMS <= 0 {
		nowMS = time.Now().UTC().UnixMilli()
	}
	_, err := r.db.ExecContext(ctx,
		`UPDATE room_follows
		 SET status = ?, updated_at_ms = ?
		 WHERE app_code = ? AND user_id = ? AND room_id = ? AND status = ?`,
		roomFollowStatusCancelledSQL,
		nowMS,
		appcode.FromContext(ctx),
		userID,
		roomID,
		roomFollowStatusActiveSQL,
	)
	return err
}

// IsRoomFollowed 查询用户是否正在关注指定房间。
func (r *Repository) IsRoomFollowed(ctx context.Context, userID int64, roomID string) (bool, error) {
	record, exists, err := r.getRoomFollowRecord(ctx, userID, roomID)
	if err != nil || !exists {
		return false, err
	}
	return record.Status == roomFollowStatusActiveSQL, nil
}

func (r *Repository) getRoomFollowRecord(ctx context.Context, userID int64, roomID string) (roomservice.RoomFollowRecord, bool, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, user_id, room_id, status, followed_at_ms, updated_at_ms
		 FROM room_follows
		 WHERE app_code = ? AND user_id = ? AND room_id = ?
		 LIMIT 1`,
		appcode.FromContext(ctx),
		userID,
		roomID,
	)

	var record roomservice.RoomFollowRecord
	if err := row.Scan(&record.AppCode, &record.UserID, &record.RoomID, &record.Status, &record.FollowedAtMS, &record.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return roomservice.RoomFollowRecord{}, false, nil
		}
		return roomservice.RoomFollowRecord{}, false, err
	}
	return record, true, nil
}

// ListRoomFollowEntries 读取当前用户关注的 active 房间卡片。
func (r *Repository) ListRoomFollowEntries(ctx context.Context, query roomservice.RoomFollowQuery) ([]roomservice.RoomListEntry, error) {
	if query.Limit <= 0 {
		query.Limit = 20
	}
	query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)

	sqlText, args := buildRoomFollowQuerySQL(query)
	rows, err := r.db.QueryContext(ctx, sqlText, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	entries := make([]roomservice.RoomListEntry, 0, query.Limit)
	for rows.Next() {
		var entry roomservice.RoomListEntry
		if err := rows.Scan(
			&entry.AppCode,
			&entry.RoomID,
			&entry.RoomShortID,
			&entry.VisibleRegionID,
			&entry.OwnerCountryCode,
			&entry.OwnerUserID,
			&entry.Title,
			&entry.CoverURL,
			&entry.Mode,
			&entry.Status,
			&entry.Locked,
			&entry.Heat,
			&entry.OnlineCount,
			&entry.SeatCount,
			&entry.OccupiedSeatCount,
			&entry.SortScore,
			&entry.CreatedAtMS,
			&entry.UpdatedAtMS,
		); err != nil {
			return nil, err
		}

		entries = append(entries, entry)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	return entries, nil
}

// ListRoomRelatedFeedEntries 根据 user-service 给出的好友/关注用户查询 active 房间卡片。
func (r *Repository) ListRoomRelatedFeedEntries(ctx context.Context, query roomservice.RoomRelatedFeedQuery) ([]roomservice.RoomListEntry, error) {
	if query.Limit <= 0 {
		query.Limit = 20
	}
	query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)
	relations := normalizeRoomFeedRelations(query.RelatedUsers)
	if len(relations) == 0 {
		return []roomservice.RoomListEntry{}, nil
	}

	sqlText, args := buildRoomRelatedFeedQuerySQL(query, relations)
	rows, err := r.db.QueryContext(ctx, sqlText, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	entries := make([]roomservice.RoomListEntry, 0, query.Limit)
	for rows.Next() {
		var entry roomservice.RoomListEntry
		if err := rows.Scan(
			&entry.AppCode,
			&entry.RoomID,
			&entry.RoomShortID,
			&entry.VisibleRegionID,
			&entry.OwnerCountryCode,
			&entry.OwnerUserID,
			&entry.Title,
			&entry.CoverURL,
			&entry.Mode,
			&entry.Status,
			&entry.Locked,
			&entry.Heat,
			&entry.OnlineCount,
			&entry.SeatCount,
			&entry.OccupiedSeatCount,
			&entry.SortScore,
			&entry.CreatedAtMS,
			&entry.UpdatedAtMS,
		); err != nil {
			return nil, err
		}

		entry.FeedSubjectUserID, entry.UpdatedAtMS = relatedFeedSortKey(entry, relations)
		if entry.FeedSubjectUserID == 0 || !roomRelatedFeedAfterCursor(entry, query.CursorUpdatedAtMS, query.CursorSubjectUserID, query.CursorRoomID) {
			continue
		}
		entries = append(entries, entry)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	sort.SliceStable(entries, func(i, j int) bool {
		if entries[i].UpdatedAtMS != entries[j].UpdatedAtMS {
			return entries[i].UpdatedAtMS > entries[j].UpdatedAtMS
		}
		if entries[i].FeedSubjectUserID != entries[j].FeedSubjectUserID {
			return entries[i].FeedSubjectUserID > entries[j].FeedSubjectUserID
		}
		return entries[i].RoomID < entries[j].RoomID
	})
	if len(entries) > query.Limit {
		entries = entries[:query.Limit]
	}
	return entries, nil
}

const roomListSelectColumns = `
	SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
	       heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
	FROM room_list_entries`

const roomUserFeedSelectColumns = `
	SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
	       r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms
	FROM room_user_feed_entries f
	JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`

const roomFollowSelectColumns = `
	SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
	       r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms
	FROM room_follows f
	JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`

func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
	regionID := query.VisibleRegionID
	if regionID < 0 {
		regionID = 0
	}
	regionLiteral := strconv.FormatInt(regionID, 10)
	pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL THEN 0 ELSE 1 END"
	newPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " THEN 2 ELSE 3 END"
	hotPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " AND r.online_count > 0 THEN 2 WHEN r.visible_region_id <> " + regionLiteral + " AND r.online_count > 0 THEN 3 WHEN r.visible_region_id = " + regionLiteral + " THEN 4 ELSE 5 END"
	pinRankExpr := hotPinRankExpr
	if query.Tab == "new" {
		pinRankExpr = newPinRankExpr
	}
	pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight ELSE 0 END"
	pinExpiresExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.expires_at_ms WHEN rp.room_id IS NOT NULL THEN rp.expires_at_ms ELSE 0 END"
	selectSQL := `
	SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
	       r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms,
	       ` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms
	FROM room_list_entries r
	LEFT JOIN room_region_pins gp
	  ON gp.app_code = r.app_code
	 AND gp.pin_type = 'global'
	 AND gp.visible_region_id = 0
	 AND gp.room_id = r.room_id
	 AND gp.status = ?
	 AND gp.pinned_at_ms <= ?
	 AND gp.expires_at_ms > ?
	LEFT JOIN room_region_pins rp
	  ON rp.app_code = r.app_code
	 AND rp.pin_type = 'region'
	 AND rp.visible_region_id = ?
	 AND rp.room_id = r.room_id
	 AND rp.status = ?
	 AND rp.pinned_at_ms <= ?
	 AND rp.expires_at_ms > ?`
	where := []string{"r.app_code = ?", "r.status = ?"}
	args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active"}
	if query.OwnerUserID > 0 {
		where = append(where, "r.owner_user_id = ?")
		args = append(args, query.OwnerUserID)
	}
	if strings.TrimSpace(query.CountryCode) != "" {
		where = append(where, "r.owner_country_code = ?")
		args = append(args, strings.ToUpper(strings.TrimSpace(query.CountryCode)))
	}
	if strings.TrimSpace(query.Query) != "" {
		where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
		like := "%" + escapeRoomListLike(query.Query) + "%"
		args = append(args, like, like, like)
	}
	if query.Tab == "new" {
		if query.CursorRoomID != "" {
			where = append(where, roomListAfterCursorSQL(pinRankExpr, pinWeightExpr, pinExpiresExpr, "r.created_at_ms"))
			args = append(args, roomListAfterCursorArgs(query.CursorPinListRank, query.CursorPinWeight, query.CursorPinnedUntilMS, query.CursorCreatedAtMS, query.CursorRoomID)...)
		}
		args = append(args, query.Limit)
		return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.created_at_ms DESC, r.room_id ASC\n\tLIMIT ?", args
	}

	if query.CursorRoomID != "" {
		where = append(where, roomListAfterCursorSQL(pinRankExpr, pinWeightExpr, pinExpiresExpr, "r.sort_score"))
		args = append(args, roomListAfterCursorArgs(query.CursorPinListRank, query.CursorPinWeight, query.CursorPinnedUntilMS, query.CursorSortScore, query.CursorRoomID)...)
	}
	args = append(args, query.Limit)
	return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.sort_score DESC, r.room_id ASC\n\tLIMIT ?", args
}

func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.AdminRoomListQuery) ([]roomservice.AdminRoomListEntry, int64, error) {
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	where, args := adminRoomWhere(ctx, query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_list_entries rle `+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL()+where+` ORDER BY `+adminRoomOrderBy(query)+` LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]roomservice.AdminRoomListEntry, 0, query.PageSize)
	for rows.Next() {
		item, err := scanAdminRoom(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

func (r *Repository) AdminGetRoom(ctx context.Context, roomID string) (roomservice.AdminRoomListEntry, bool, error) {
	row := r.db.QueryRowContext(ctx, adminRoomSelectSQL()+` WHERE rle.app_code = ? AND rle.room_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(roomID))
	item, err := scanAdminRoom(row)
	if errors.Is(err, sql.ErrNoRows) {
		return roomservice.AdminRoomListEntry{}, false, nil
	}
	if err != nil {
		return roomservice.AdminRoomListEntry{}, false, err
	}
	return item, true, nil
}

func adminRoomSelectSQL() string {
	return `
		SELECT rle.room_id, rle.room_short_id, rle.visible_region_id, rle.owner_user_id,
		       rle.title, rle.cover_url, rle.mode, rle.status, rle.close_reason,
		       rle.closed_by_admin_id, rle.closed_by_admin_name, rle.closed_at_ms,
		       rle.heat, rle.online_count, rle.seat_count, rle.occupied_seat_count,
		       rle.sort_score, rle.created_at_ms, rle.updated_at_ms
		FROM room_list_entries rle `
}

func adminRoomWhere(ctx context.Context, query roomservice.AdminRoomListQuery) (string, []any) {
	where := []string{"rle.app_code = ?"}
	app := appcode.Normalize(query.AppCode)
	if app == "" {
		app = appcode.FromContext(ctx)
	}
	args := []any{app}
	switch strings.TrimSpace(query.Status) {
	case "closed":
		where = append(where, "rle.status <> ?")
		args = append(args, "active")
	case "":
	default:
		where = append(where, "rle.status = ?")
		args = append(args, strings.TrimSpace(query.Status))
	}
	if query.RegionID > 0 {
		where = append(where, "rle.visible_region_id = ?")
		args = append(args, query.RegionID)
	}
	if query.OwnerUserID > 0 {
		where = append(where, "rle.owner_user_id = ?")
		args = append(args, query.OwnerUserID)
	}
	if strings.TrimSpace(query.Keyword) != "" {
		like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%"
		where = append(where, "(rle.room_id LIKE ? ESCAPE '\\\\' OR rle.room_short_id LIKE ? ESCAPE '\\\\' OR rle.title LIKE ? ESCAPE '\\\\' OR CAST(rle.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')")
		args = append(args, like, like, like, like)
	}
	return " WHERE " + strings.Join(where, " AND "), args
}

func adminRoomOrderBy(query roomservice.AdminRoomListQuery) string {
	direction := "DESC"
	if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") {
		direction = "ASC"
	}
	switch strings.TrimSpace(query.SortBy) {
	case "room_contribution", "heat":
		return "rle.heat " + direction + ", rle.created_at_ms DESC, rle.room_id DESC"
	default:
		return "rle.created_at_ms DESC, rle.room_id DESC"
	}
}

func scanAdminRoom(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomListEntry, error) {
	var item roomservice.AdminRoomListEntry
	err := scanner.Scan(
		&item.RoomID,
		&item.RoomShortID,
		&item.VisibleRegionID,
		&item.OwnerUserID,
		&item.Title,
		&item.CoverURL,
		&item.Mode,
		&item.Status,
		&item.CloseReason,
		&item.ClosedByAdminID,
		&item.ClosedByAdminName,
		&item.ClosedAtMS,
		&item.Heat,
		&item.OnlineCount,
		&item.SeatCount,
		&item.OccupiedSeatCount,
		&item.SortScore,
		&item.CreatedAtMS,
		&item.UpdatedAtMS,
	)
	return item, err
}

func (r *Repository) AdminListRoomPins(ctx context.Context, query roomservice.AdminRoomPinQuery) ([]roomservice.AdminRoomPinEntry, int64, error) {
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	where, args := adminRoomPinWhere(ctx, query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) `+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, adminRoomPinSelectSQL()+where+` ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]roomservice.AdminRoomPinEntry, 0, query.PageSize)
	for rows.Next() {
		item, err := scanAdminRoomPin(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

func (r *Repository) AdminCreateRoomPin(ctx context.Context, input roomservice.AdminCreateRoomPinInput) (roomservice.AdminRoomPinEntry, bool, error) {
	app := appcode.FromContext(ctx)
	room, exists, err := r.AdminGetRoom(ctx, input.RoomID)
	if err != nil || !exists {
		return roomservice.AdminRoomPinEntry{}, false, err
	}
	if room.Status != "active" {
		return roomservice.AdminRoomPinEntry{}, false, nil
	}
	pinType := strings.TrimSpace(input.PinType)
	if pinType == "" {
		pinType = "region"
	}
	visibleRegionID := room.VisibleRegionID
	if pinType == "global" {
		visibleRegionID = 0
	}
	pinnedAtMS := input.PinnedAtMS
	if pinnedAtMS <= 0 {
		pinnedAtMS = input.NowMS
	}
	expiresAtMS := input.ExpiresAtMS
	if expiresAtMS <= 0 {
		expiresAtMS = pinnedAtMS + input.DurationDays*24*60*60*1000
	}
	if _, err := r.db.ExecContext(ctx, `
		INSERT INTO room_region_pins (
		  app_code, visible_region_id, pin_type, room_id, weight, status, pinned_at_ms, expires_at_ms,
		  cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
		  weight = VALUES(weight), status = VALUES(status), pinned_at_ms = VALUES(pinned_at_ms),
		  expires_at_ms = VALUES(expires_at_ms), cancelled_at_ms = 0,
		  created_by_admin_id = VALUES(created_by_admin_id), cancelled_by_admin_id = 0,
		  created_at_ms = VALUES(created_at_ms), updated_at_ms = VALUES(updated_at_ms)
	`, app, visibleRegionID, pinType, room.RoomID, input.Weight, pinnedAtMS, expiresAtMS, input.AdminID, input.NowMS, input.NowMS); err != nil {
		return roomservice.AdminRoomPinEntry{}, false, err
	}
	return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.pin_type = ? AND p.visible_region_id = ? AND p.room_id = ?`, app, pinType, visibleRegionID, room.RoomID)
}

func (r *Repository) AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (roomservice.AdminRoomPinEntry, bool, error) {
	app := appcode.FromContext(ctx)
	if _, err := r.db.ExecContext(ctx, `
		UPDATE room_region_pins
		SET status = 'cancelled', cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND id = ? AND status <> 'cancelled'
	`, nowMS, adminID, nowMS, app, pinID); err != nil {
		return roomservice.AdminRoomPinEntry{}, false, err
	}
	return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.id = ?`, app, pinID)
}

func (r *Repository) adminGetRoomPin(ctx context.Context, where string, args ...any) (roomservice.AdminRoomPinEntry, bool, error) {
	item, err := scanAdminRoomPin(r.db.QueryRowContext(ctx, adminRoomPinSelectSQL()+where+` LIMIT 1`, args...))
	if errors.Is(err, sql.ErrNoRows) {
		return roomservice.AdminRoomPinEntry{}, false, nil
	}
	if err != nil {
		return roomservice.AdminRoomPinEntry{}, false, err
	}
	return item, true, nil
}

func adminRoomPinSelectSQL() string {
	return `
		SELECT p.id, p.visible_region_id, p.pin_type, p.room_id, p.weight, p.status,
		       p.pinned_at_ms, p.expires_at_ms, p.cancelled_at_ms,
		       p.created_by_admin_id, p.cancelled_by_admin_id, p.created_at_ms, p.updated_at_ms,
		       r.room_short_id, r.visible_region_id, r.title, r.cover_url, r.owner_user_id, r.status
		FROM room_region_pins p
		JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id `
}

func adminRoomPinWhere(ctx context.Context, query roomservice.AdminRoomPinQuery) (string, []any) {
	nowMS := query.NowMS
	if nowMS <= 0 {
		nowMS = time.Now().UTC().UnixMilli()
	}
	where := []string{"p.app_code = ?"}
	args := []any{appcode.FromContext(ctx)}
	switch strings.TrimSpace(query.Status) {
	case "active":
		where = append(where, "p.status = ?", "p.expires_at_ms > ?")
		args = append(args, "active", nowMS)
	case "expired":
		where = append(where, "p.status = ?", "p.expires_at_ms <= ?")
		args = append(args, "active", nowMS)
	case "cancelled":
		where = append(where, "p.status = ?")
		args = append(args, "cancelled")
	}
	if query.RegionID > 0 {
		where = append(where, "p.visible_region_id = ?")
		args = append(args, query.RegionID)
	}
	if strings.TrimSpace(query.PinType) != "" {
		where = append(where, "p.pin_type = ?")
		args = append(args, strings.TrimSpace(query.PinType))
	}
	if strings.TrimSpace(query.Keyword) != "" {
		like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%"
		where = append(where, "(p.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\' OR CAST(r.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')")
		args = append(args, like, like, like, like)
	}
	return ` FROM room_region_pins p JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id WHERE ` + strings.Join(where, " AND "), args
}

func scanAdminRoomPin(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomPinEntry, error) {
	var item roomservice.AdminRoomPinEntry
	err := scanner.Scan(&item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus)
	return item, err
}

func roomListAfterCursorSQL(pinRankExpr string, pinWeightExpr string, pinExpiresExpr string, sortExpr string) string {
	// 排序桶、置顶权重、过期时间和 tab 排序键共同组成游标，避免跨全区置顶/区域置顶/普通房分区翻页时重复或跳过。
	return "(" +
		pinRankExpr + " > ? OR (" +
		pinRankExpr + " = ? AND " + pinWeightExpr + " < ?) OR (" +
		pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " < ?) OR (" +
		pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " < ?) OR (" +
		pinRankExpr + " = ? AND " + pinWeightExpr + " = ? AND " + pinExpiresExpr + " = ? AND " + sortExpr + " = ? AND r.room_id > ?))"
}

func roomListAfterCursorArgs(pinListRank int64, pinWeight int64, pinnedUntilMS int64, sortValue int64, roomID string) []any {
	return []any{
		pinListRank,
		pinListRank, pinWeight,
		pinListRank, pinWeight, pinnedUntilMS,
		pinListRank, pinWeight, pinnedUntilMS, sortValue,
		pinListRank, pinWeight, pinnedUntilMS, sortValue, roomID,
	}
}

func buildRoomUserFeedQuerySQL(query roomservice.RoomUserFeedQuery) (string, []any) {
	where := []string{"f.app_code = ?", "f.user_id = ?", "f.feed_type = ?", "r.visible_region_id = ?", "r.status = ?"}
	args := []any{appcode.Normalize(query.AppCode), query.UserID, query.FeedType, query.VisibleRegionID, "active"}
	if strings.TrimSpace(query.Query) != "" {
		where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
		like := "%" + escapeRoomListLike(query.Query) + "%"
		args = append(args, like, like, like)
	}
	if query.CursorRoomID != "" {
		where = append(where, "(f.updated_at_ms < ? OR (f.updated_at_ms = ? AND f.room_id > ?))")
		args = append(args, query.CursorUpdatedAtMS, query.CursorUpdatedAtMS, query.CursorRoomID)
	}
	args = append(args, query.Limit)
	return roomUserFeedSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.updated_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args
}

func buildRoomFollowQuerySQL(query roomservice.RoomFollowQuery) (string, []any) {
	where := []string{"f.app_code = ?", "f.user_id = ?", "f.status = ?", "r.visible_region_id = ?", "r.status = ?"}
	args := []any{appcode.Normalize(query.AppCode), query.UserID, roomFollowStatusActiveSQL, query.VisibleRegionID, "active"}
	if strings.TrimSpace(query.Query) != "" {
		where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
		like := "%" + escapeRoomListLike(query.Query) + "%"
		args = append(args, like, like, like)
	}
	if query.CursorRoomID != "" {
		where = append(where, "(f.followed_at_ms < ? OR (f.followed_at_ms = ? AND f.room_id > ?))")
		args = append(args, query.CursorFollowedAtMS, query.CursorFollowedAtMS, query.CursorRoomID)
	}
	args = append(args, query.Limit)
	return roomFollowSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.followed_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args
}

func buildRoomRelatedFeedQuerySQL(query roomservice.RoomRelatedFeedQuery, relations map[int64]int64) (string, []any) {
	where := []string{"app_code = ?", "visible_region_id = ?", "status = ?"}
	args := []any{appcode.Normalize(query.AppCode), query.VisibleRegionID, "active"}

	placeholders := make([]string, 0, len(relations))
	relatedIDs := make([]any, 0, len(relations))
	for userID := range relations {
		placeholders = append(placeholders, "?")
		relatedIDs = append(relatedIDs, userID)
	}
	inClause := strings.Join(placeholders, ",")
	where = append(where, "owner_user_id IN ("+inClause+")")
	args = append(args, relatedIDs...)

	if strings.TrimSpace(query.Query) != "" {
		where = append(where, "(room_id LIKE ? ESCAPE '\\\\' OR room_short_id LIKE ? ESCAPE '\\\\' OR title LIKE ? ESCAPE '\\\\')")
		like := "%" + escapeRoomListLike(query.Query) + "%"
		args = append(args, like, like, like)
	}

	return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND "), args
}

func normalizeRoomFeedRelations(items []roomservice.RoomFeedRelatedUser) map[int64]int64 {
	relations := make(map[int64]int64, len(items))
	for _, item := range items {
		if item.UserID <= 0 || item.RelationUpdatedAtMS <= 0 {
			continue
		}
		if item.RelationUpdatedAtMS > relations[item.UserID] {
			relations[item.UserID] = item.RelationUpdatedAtMS
		}
	}
	return relations
}

func relatedFeedSortKey(entry roomservice.RoomListEntry, relations map[int64]int64) (int64, int64) {
	bestUserID, bestUpdatedAtMS := int64(0), int64(0)
	choose := func(userID int64) {
		updatedAtMS := relations[userID]
		if updatedAtMS == 0 {
			return
		}
		if updatedAtMS > bestUpdatedAtMS || (updatedAtMS == bestUpdatedAtMS && userID > bestUserID) {
			bestUserID = userID
			bestUpdatedAtMS = updatedAtMS
		}
	}
	choose(entry.OwnerUserID)
	return bestUserID, bestUpdatedAtMS
}

func roomRelatedFeedAfterCursor(entry roomservice.RoomListEntry, cursorUpdatedAtMS int64, cursorSubjectUserID int64, cursorRoomID string) bool {
	if cursorUpdatedAtMS <= 0 || cursorSubjectUserID <= 0 || cursorRoomID == "" {
		return true
	}
	if entry.UpdatedAtMS != cursorUpdatedAtMS {
		return entry.UpdatedAtMS < cursorUpdatedAtMS
	}
	if entry.FeedSubjectUserID != cursorSubjectUserID {
		return entry.FeedSubjectUserID < cursorSubjectUserID
	}
	return entry.RoomID > cursorRoomID
}

// ProjectRoomPresence 用最新 RoomSnapshot 投影用户当前房间读模型。
func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservice.RoomPresenceSnapshot) error {
	appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
	if strings.TrimSpace(snapshot.RoomID) == "" {
		return nil
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}

	// 先把该房间上一版 active presence 全部置为 left，再把本次快照里的在线用户 upsert 回 active。
	// 这样 Join/Leave/Kick/Close 的投影逻辑统一，避免按命令类型维护多份分支。
	if _, err := tx.ExecContext(ctx,
		`UPDATE room_user_presence
		 SET status = ?, publish_state = '', mic_session_id = '', room_version = ?, updated_at_ms = ?
		 WHERE app_code = ? AND room_id = ? AND status = ?`,
		roomPresenceStatusLeftSQL,
		snapshot.RoomVersion,
		snapshot.UpdatedAtMS,
		appCode,
		snapshot.RoomID,
		roomPresenceStatusActiveSQL,
	); err != nil {
		_ = tx.Rollback()
		return err
	}

	for _, user := range snapshot.Users {
		if user.UserID <= 0 {
			continue
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO room_user_presence (
			   app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			 ON DUPLICATE KEY UPDATE
			   room_id = VALUES(room_id),
			   role = VALUES(role),
			   publish_state = VALUES(publish_state),
			   mic_session_id = VALUES(mic_session_id),
			   room_version = VALUES(room_version),
			   status = VALUES(status),
			   joined_at_ms = VALUES(joined_at_ms),
			   last_seen_at_ms = VALUES(last_seen_at_ms),
			   updated_at_ms = VALUES(updated_at_ms)`,
			appCode,
			user.UserID,
			snapshot.RoomID,
			user.Role,
			user.PublishState,
			user.MicSessionID,
			snapshot.RoomVersion,
			roomPresenceStatusActiveSQL,
			user.JoinedAtMS,
			user.LastSeenAtMS,
			snapshot.UpdatedAtMS,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}

	return tx.Commit()
}

// GetCurrentRoomPresence 查询用户当前 active 房间，不扫描发现页列表。
func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
		 FROM room_user_presence
		 WHERE app_code = ? AND user_id = ? AND status = ?
		 LIMIT 1`,
		appcode.FromContext(ctx),
		userID,
		roomPresenceStatusActiveSQL,
	)

	var presence roomservice.RoomPresence
	if err := row.Scan(
		&presence.AppCode,
		&presence.UserID,
		&presence.RoomID,
		&presence.Role,
		&presence.PublishState,
		&presence.MicSessionID,
		&presence.RoomVersion,
		&presence.Status,
		&presence.JoinedAtMS,
		&presence.LastSeenAtMS,
		&presence.UpdatedAtMS,
	); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return roomservice.RoomPresence{}, false, nil
		}
		return roomservice.RoomPresence{}, false, err
	}

	return presence, true, nil
}

// ListRoomOnlineUsers 分页查询某个房间当前 active presence。
func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.RoomOnlineUserQuery) (roomservice.RoomOnlineUserPage, error) {
	page := query.Page
	if page <= 0 {
		page = 1
	}
	pageSize := query.PageSize
	if pageSize <= 0 {
		pageSize = 50
	}
	offset := (page - 1) * pageSize
	appCode := normalizedRecordAppCode(ctx, query.AppCode)

	var total int64
	if err := r.db.QueryRowContext(ctx,
		`SELECT COUNT(*)
		 FROM room_user_presence
		 WHERE app_code = ? AND room_id = ? AND status = ?`,
		appCode,
		query.RoomID,
		roomPresenceStatusActiveSQL,
	).Scan(&total); err != nil {
		return roomservice.RoomOnlineUserPage{}, err
	}

	orderBy := "p.last_seen_at_ms DESC, p.user_id ASC"
	if strings.EqualFold(strings.TrimSpace(query.Sort), "gift_value") {
		orderBy = "COALESCE(g.gift_value, 0) DESC, p.last_seen_at_ms DESC, p.user_id ASC"
	}

	rows, err := r.db.QueryContext(ctx,
		`SELECT p.app_code, p.user_id, p.room_id, p.role, p.publish_state, p.mic_session_id, p.room_version, p.status,
		        p.joined_at_ms, p.last_seen_at_ms, p.updated_at_ms, COALESCE(g.gift_value, 0)
		 FROM room_user_presence p
		 LEFT JOIN room_user_gift_stats g
		   ON g.app_code = p.app_code AND g.room_id = p.room_id AND g.user_id = p.user_id
		 WHERE p.app_code = ? AND p.room_id = ? AND p.status = ?
		 ORDER BY `+orderBy+`
		 LIMIT ? OFFSET ?`,
		appCode,
		query.RoomID,
		roomPresenceStatusActiveSQL,
		pageSize,
		offset,
	)
	if err != nil {
		return roomservice.RoomOnlineUserPage{}, err
	}
	defer rows.Close()

	users := make([]roomservice.RoomPresence, 0, pageSize)
	for rows.Next() {
		var presence roomservice.RoomPresence
		if err := rows.Scan(
			&presence.AppCode,
			&presence.UserID,
			&presence.RoomID,
			&presence.Role,
			&presence.PublishState,
			&presence.MicSessionID,
			&presence.RoomVersion,
			&presence.Status,
			&presence.JoinedAtMS,
			&presence.LastSeenAtMS,
			&presence.UpdatedAtMS,
			&presence.GiftValue,
		); err != nil {
			return roomservice.RoomOnlineUserPage{}, err
		}
		users = append(users, presence)
	}
	if err := rows.Err(); err != nil {
		return roomservice.RoomOnlineUserPage{}, err
	}

	return roomservice.RoomOnlineUserPage{
		Users:    users,
		Total:    total,
		Page:     page,
		PageSize: pageSize,
	}, nil
}

const (
	roomPresenceStatusActiveSQL  = "active"
	roomPresenceStatusLeftSQL    = "left"
	roomFollowStatusActiveSQL    = "active"
	roomFollowStatusCancelledSQL = "cancelled"
)

func escapeRoomListLike(value string) string {
	var builder strings.Builder
	for _, r := range value {
		if r == '\\' || r == '%' || r == '_' {
			builder.WriteRune('\\')
		}
		builder.WriteRune(r)
	}
	return builder.String()
}

func nullableString(value string) sql.Null[string] {
	// 空错误写 NULL，便于 SQL 查询区分没有错误和空字符串。
	return sql.Null[string]{
		V:     value,
		Valid: value != "",
	}
}

func normalizedRecordAppCode(ctx context.Context, value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return appcode.FromContext(ctx)
	}

	return appcode.Normalize(value)
}

func boolToInt(value bool) int {
	if value {
		return 1
	}
	return 0
}

func mapRoomMetaDuplicateError(err error) error {
	if err == nil {
		return nil
	}

	var mysqlErr *mysqlerr.MySQLError
	if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1062 {
		return err
	}

	message := mysqlErr.Message
	if strings.Contains(message, "uk_rooms_owner_user") || strings.Contains(message, "owner_user_id") {
		return xerr.New(xerr.Conflict, "owner already has room")
	}

	return xerr.New(xerr.Conflict, "room already exists")
}
