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

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"sort"
	"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,
			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_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,
			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_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,
			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_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),
			KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms)
		) 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_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`,
		`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)`,
		`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_seat_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
		}
	}

	return r.ensureOutboxRetrySchema(ctx)
}

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
		}
	}
	hasIndex, err := r.indexExists(ctx, "room_outbox", "idx_room_outbox_retry")
	if err != nil {
		return err
	}
	if !hasIndex {
		if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms)`); 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, visible_region_id, created_at_ms, updated_at_ms)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appCode,
		meta.RoomID,
		meta.RoomShortID,
		meta.OwnerUserID,
		meta.SeatCount,
		meta.Mode,
		meta.Status,
		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, 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.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, 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.VisibleRegionID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return roomservice.RoomMeta{}, false, nil
		}

		return roomservice.RoomMeta{}, false, err
	}

	return meta, true, nil
}

// 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
		}
	}

	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
}

// 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
		 WHERE app_code = ? AND status IN (?, ?)
		   AND (status = ? OR next_retry_at_ms <= ?)
		 ORDER BY created_at_ms ASC
		 LIMIT ?`,
		appcode.FromContext(ctx),
		outbox.StatusPending,
		outbox.StatusRetryable,
		outbox.StatusPending,
		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()
	rows, err := tx.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
		 WHERE app_code = ?
		   AND (
		     status = ?
		     OR (status = ? AND next_retry_at_ms <= ?)
		     OR (status = ? AND lock_until_ms IS NOT NULL AND lock_until_ms <= ?)
		   )
		 ORDER BY created_at_ms ASC
		 LIMIT ?
		 FOR UPDATE SKIP LOCKED`,
		appcode.FromContext(ctx),
		outbox.StatusPending,
		outbox.StatusRetryable,
		nowMS,
		outbox.StatusDelivering,
		nowMS,
		limit,
	)
	if err != nil {
		_ = tx.Rollback()
		return nil, err
	}

	records := make([]outbox.Record, 0, limit)
	eventIDs := make([]string, 0, limit)
	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 {
			_ = rows.Close()
			_ = tx.Rollback()
			return nil, err
		}
		if lockUntilValue.Valid {
			record.LockUntilMS = lockUntilValue.V
		}

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

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

	for _, eventID := range eventIDs {
		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.FromContext(ctx),
			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
}

// 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_user_id, title, cover_url, mode, status,
		   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_user_id = VALUES(owner_user_id),
		   title = VALUES(title),
		   cover_url = VALUES(cover_url),
		   mode = VALUES(mode),
		   status = VALUES(status),
		   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.OwnerUserID,
		entry.Title,
		entry.CoverURL,
		entry.Mode,
		entry.Status,
		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)

	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
		if err := rows.Scan(
			&entry.AppCode,
			&entry.RoomID,
			&entry.RoomShortID,
			&entry.VisibleRegionID,
			&entry.OwnerUserID,
			&entry.Title,
			&entry.CoverURL,
			&entry.Mode,
			&entry.Status,
			&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
}

// 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.OwnerUserID,
			&entry.Title,
			&entry.CoverURL,
			&entry.Mode,
			&entry.Status,
			&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.OwnerUserID,
			&entry.Title,
			&entry.CoverURL,
			&entry.Mode,
			&entry.Status,
			&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_user_id, title, cover_url, mode, status,
	       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_user_id, r.title, r.cover_url, r.mode, r.status,
	       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`

func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
	where := []string{"app_code = ?", "visible_region_id = ?", "status = ?"}
	args := []any{appcode.Normalize(query.AppCode), query.VisibleRegionID, "active"}
	if query.OwnerUserID > 0 {
		where = append(where, "owner_user_id = ?")
		args = append(args, query.OwnerUserID)
	}
	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)
	}
	if query.Tab == "new" {
		if query.CursorRoomID != "" {
			where = append(where, "(created_at_ms < ? OR (created_at_ms = ? AND room_id > ?))")
			args = append(args, query.CursorCreatedAtMS, query.CursorCreatedAtMS, query.CursorRoomID)
		}
		args = append(args, query.Limit)
		return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY created_at_ms DESC, room_id ASC\n\tLIMIT ?", args
	}

	if query.CursorRoomID != "" {
		where = append(where, "(sort_score < ? OR (sort_score = ? AND room_id > ?))")
		args = append(args, query.CursorSortScore, query.CursorSortScore, query.CursorRoomID)
	}
	args = append(args, query.Limit)
	return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY sort_score DESC, room_id ASC\n\tLIMIT ?", args
}

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 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, 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),
			   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.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, 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.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
	}

	rows, err := r.db.QueryContext(ctx,
		`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, last_seen_at_ms, updated_at_ms
		 FROM room_user_presence
		 WHERE app_code = ? AND room_id = ? AND status = ?
		 ORDER BY last_seen_at_ms DESC, user_id ASC
		 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.LastSeenAtMS,
			&presence.UpdatedAtMS,
		); 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"
)

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 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")
}
