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

import (
	"context"
	"database/sql"
	"errors"
	"sort"
	"strings"
	"time"

	"hyapp/pkg/appcode"
	roomservice "hyapp/services/room-service/internal/room/service"
)

// 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, rocket_near_completion,
		   room_border_json, room_name_style_json, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?)
		 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),
		   rocket_near_completion = VALUES(rocket_near_completion),
		   room_border_json = VALUES(room_border_json),
		   room_name_style_json = VALUES(room_name_style_json),
		   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.RocketNearCompletion,
		entry.RoomBorderJSON,
		entry.RoomNameStyleJSON,
		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() {
		entry, pinned, err := scanRoomListEntryWithPins(rows)
		if 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,
		       COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
		       owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion,
		       has_active_red_packet, active_red_packet_expires_at_ms,
		       owner_vip_fallback_level, owner_vip_fallback_expires_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() {
		entry, err := scanRoomListEntry(rows)
		if err != nil {
			return nil, err
		}
		result[entry.RoomID] = entry
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return result, nil
}

// UpdateRoomOwnerVIP 把 wallet 已求值的 effective VIP 写入房主唯一房间的列表投影。
// 写入和随后点查都命中 idx_room_list_owner；高频列表查询只读取同一行，不增加 join、子查询或跨服务 RPC。
func (r *Repository) UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64) (roomservice.RoomListEntry, bool, error) {
	if ownerUserID <= 0 {
		return roomservice.RoomListEntry{}, false, nil
	}
	if level <= 0 || expiresAtMS <= time.Now().UTC().UnixMilli() {
		level = 0
		expiresAtMS = 0
	}
	appCode := normalizedRecordAppCode(ctx, "")
	if _, err := r.db.ExecContext(ctx, `
		UPDATE room_list_entries
		SET owner_vip_level = ?, owner_vip_expires_at_ms = ?
		WHERE app_code = ? AND owner_user_id = ?`, level, expiresAtMS, appCode, ownerUserID); err != nil {
		return roomservice.RoomListEntry{}, false, err
	}

	// RowsAffected=0 同时表示“不存在”和“值未变化”，因此必须按同一 owner 索引点查后再决定是否刷新 Redis 卡片。
	rows, err := r.db.QueryContext(ctx, roomListSelectColumns+`
		WHERE app_code = ? AND owner_user_id = ?
		LIMIT 1`, appCode, ownerUserID)
	if err != nil {
		return roomservice.RoomListEntry{}, false, err
	}
	defer rows.Close()
	if !rows.Next() {
		if err := rows.Err(); err != nil {
			return roomservice.RoomListEntry{}, false, err
		}
		return roomservice.RoomListEntry{}, false, nil
	}
	entry, err := scanRoomListEntry(rows)
	if err != nil {
		return roomservice.RoomListEntry{}, false, err
	}
	return entry, true, nil
}

// UpdateRoomOwnerVIPSchedule 把当前 effective VIP 及体验卡自然到期后的 paid 回落计划一起写入。
// 单行 UPDATE 和随后点查都命中 idx_room_list_owner；新增列不参与索引，不改变 feeds 查询计划。
func (r *Repository) UpdateRoomOwnerVIPSchedule(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64, fallbackLevel int32, fallbackExpiresAtMS int64) (roomservice.RoomListEntry, bool, error) {
	if ownerUserID <= 0 {
		return roomservice.RoomListEntry{}, false, nil
	}
	nowMS := time.Now().UTC().UnixMilli()
	if level <= 0 || expiresAtMS <= nowMS {
		level = 0
		expiresAtMS = 0
	}
	if fallbackLevel <= 0 || fallbackExpiresAtMS <= nowMS {
		fallbackLevel = 0
		fallbackExpiresAtMS = 0
	}
	appCode := normalizedRecordAppCode(ctx, "")
	if _, err := r.db.ExecContext(ctx, `
		UPDATE room_list_entries
		SET owner_vip_level = ?, owner_vip_expires_at_ms = ?,
		    owner_vip_fallback_level = ?, owner_vip_fallback_expires_at_ms = ?
		WHERE app_code = ? AND owner_user_id = ?`,
		level, expiresAtMS, fallbackLevel, fallbackExpiresAtMS, appCode, ownerUserID,
	); err != nil {
		return roomservice.RoomListEntry{}, false, err
	}
	rows, err := r.db.QueryContext(ctx, roomListSelectColumns+`
		WHERE app_code = ? AND owner_user_id = ?
		LIMIT 1`, appCode, ownerUserID)
	if err != nil {
		return roomservice.RoomListEntry{}, false, err
	}
	defer rows.Close()
	if !rows.Next() {
		if err := rows.Err(); err != nil {
			return roomservice.RoomListEntry{}, false, err
		}
		return roomservice.RoomListEntry{}, false, nil
	}
	entry, err := scanRoomListEntry(rows)
	if err != nil {
		return roomservice.RoomListEntry{}, false, err
	}
	return entry, true, nil
}

// ListRoomListCacheEntries 按 app_code/room_id 主键批量扫描 active 列表投影，供 Redis 展示读模型重建。
func (r *Repository) ListRoomListCacheEntries(ctx context.Context, query roomservice.RoomListCacheEntryScanQuery) ([]roomservice.RoomListEntry, error) {
	if query.Limit <= 0 {
		query.Limit = 500
	}
	if query.Limit > 2000 {
		query.Limit = 2000
	}
	where := []string{"status = ?"}
	args := []any{"active"}
	if strings.TrimSpace(query.AfterAppCode) != "" || strings.TrimSpace(query.AfterRoomID) != "" {
		// 复合主键游标必须严格前进，避免刷新 worker 用 OFFSET 反复扫描前段数据。
		where = append(where, "(app_code > ? OR (app_code = ? AND room_id > ?))")
		args = append(args, strings.TrimSpace(query.AfterAppCode), strings.TrimSpace(query.AfterAppCode), strings.TrimSpace(query.AfterRoomID))
	}
	args = append(args, query.Limit)
	rows, err := r.db.QueryContext(ctx, roomListSelectColumns+"\n\tWHERE "+strings.Join(where, " AND ")+"\n\tORDER BY app_code ASC, room_id ASC\n\tLIMIT ?", args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	entries := make([]roomservice.RoomListEntry, 0, query.Limit)
	for rows.Next() {
		entry, err := scanRoomListEntry(rows)
		if err != nil {
			return nil, err
		}
		entries = append(entries, entry)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return entries, nil
}

// ListRoomListCachePins 按 pin id 批量扫描仍可能生效的置顶事实，供 Redis 展示读模型重建。
func (r *Repository) ListRoomListCachePins(ctx context.Context, query roomservice.RoomListPinScanQuery) ([]roomservice.RoomListPinEntry, error) {
	if query.Limit <= 0 {
		query.Limit = 500
	}
	if query.Limit > 2000 {
		query.Limit = 2000
	}
	nowMS := query.NowMS
	if nowMS <= 0 {
		nowMS = time.Now().UTC().UnixMilli()
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT id, app_code, visible_region_id, pin_type, room_id, weight, status, pinned_at_ms, expires_at_ms
		FROM room_region_pins
		WHERE id > ? AND status = ? AND expires_at_ms > ?
		ORDER BY id ASC
		LIMIT ?`, query.AfterID, "active", nowMS, query.Limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	pins := make([]roomservice.RoomListPinEntry, 0, query.Limit)
	for rows.Next() {
		var pin roomservice.RoomListPinEntry
		if err := rows.Scan(&pin.ID, &pin.AppCode, &pin.VisibleRegionID, &pin.PinType, &pin.RoomID, &pin.Weight, &pin.Status, &pin.PinnedAtMS, &pin.ExpiresAtMS); err != nil {
			return nil, err
		}
		pins = append(pins, pin)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return pins, nil
}

func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomListEntry, error) {
	var entry roomservice.RoomListEntry
	dest := []any{
		&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,
	}
	columnCount := scannerColumnCount(scanner)
	if columnCount >= len(dest)+2 {
		dest = append(dest, &entry.RoomBorderJSON, &entry.RoomNameStyleJSON)
	}
	if columnCount >= len(dest)+2 {
		dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS)
		entry.OwnerVIPProjectionKnown = true
	}
	if columnCount >= len(dest)+1 {
		dest = append(dest, &entry.RocketNearCompletion)
	}
	if columnCount >= len(dest)+2 {
		dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS)
		entry.ActiveRedPacketProjectionKnown = true
	}
	if columnCount >= len(dest)+2 {
		dest = append(dest, &entry.OwnerVIPFallbackLevel, &entry.OwnerVIPFallbackExpiresAtMS)
	}
	err := scanner.Scan(dest...)
	return entry, err
}

func scanRoomListEntryWithPins(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomListEntry, int64, error) {
	var entry roomservice.RoomListEntry
	var pinned int64
	dest := []any{
		&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,
	}
	columnCount := scannerColumnCount(scanner)
	if columnCount >= len(dest)+2+4 {
		dest = append(dest, &entry.RoomBorderJSON, &entry.RoomNameStyleJSON)
	}
	if columnCount >= len(dest)+2+4 {
		dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS)
		entry.OwnerVIPProjectionKnown = true
	}
	if columnCount >= len(dest)+1+4 {
		dest = append(dest, &entry.RocketNearCompletion)
	}
	if columnCount >= len(dest)+2+4 {
		dest = append(dest, &entry.HasActiveRedPacket, &entry.ActiveRedPacketExpiresAtMS)
		entry.ActiveRedPacketProjectionKnown = true
	}
	if columnCount >= len(dest)+2+4 {
		dest = append(dest, &entry.OwnerVIPFallbackLevel, &entry.OwnerVIPFallbackExpiresAtMS)
	}
	dest = append(dest, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS)
	err := scanner.Scan(dest...)
	return entry, pinned, err
}

func scannerColumnCount(scanner any) int {
	withColumns, ok := scanner.(interface{ Columns() ([]string, error) })
	if !ok {
		return 0
	}
	columns, err := withColumns.Columns()
	if err != nil {
		return 0
	}
	return len(columns)
}

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() {
		entry, err := scanRoomListEntry(rows)
		if 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() {
		entry, err := scanRoomListEntry(rows)
		if 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() {
		entry, err := scanRoomListEntry(rows)
		if 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,
	       COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
	       owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion,
	       has_active_red_packet, active_red_packet_expires_at_ms,
	       owner_vip_fallback_level, owner_vip_fallback_expires_at_ms
	FROM room_list_entries`

// owner 定向列表复用公共列表扫描器，但不参与公共发现页置顶排序；这里补零值列保持 scan 契约稳定。
const roomListOwnerSelectColumns = `
	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,
	       COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
	       owner_vip_level, owner_vip_expires_at_ms, rocket_near_completion,
	       has_active_red_packet, active_red_packet_expires_at_ms,
	       owner_vip_fallback_level, owner_vip_fallback_expires_at_ms,
	       0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_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,
	       COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
	       r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion,
	       r.has_active_red_packet, r.active_red_packet_expires_at_ms,
	       r.owner_vip_fallback_level, r.owner_vip_fallback_expires_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,
	       COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
	       r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion,
	       r.has_active_red_packet, r.active_red_packet_expires_at_ms,
	       r.owner_vip_fallback_level, r.owner_vip_fallback_expires_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) {
	if len(query.OwnerUserIDs) > 0 {
		return buildRoomListByOwnersQuerySQL(query)
	}
	regionID := query.VisibleRegionID
	if query.FilterRegionID > 0 {
		// 直接 repository 调用也必须尊重已校验的区域筛选，不依赖 service 预先覆盖默认区域。
		regionID = query.FilterRegionID
	}
	if regionID < 0 {
		regionID = 0
	}
	viewerCountryCode := normalizeRoomListSQLCountryCode(query.ViewerCountryCode)
	filterCountryCode := normalizeRoomListSQLCountryCode(query.CountryCode)
	sortCountryCode := viewerCountryCode
	if filterCountryCode != "" {
		// 国家 tab 已经把结果集收敛到一个国家，国家置顶和国家优先桶都应按该 tab 国家解释。
		sortCountryCode = filterCountryCode
	}
	normalRankExpr := "CASE WHEN r.online_count > 0 THEN 2 ELSE 4 END"
	if sortCountryCode != "" {
		countryLiteral := roomListSQLStringLiteral(sortCountryCode)
		normalRankExpr = "CASE WHEN r.owner_country_code = " + countryLiteral + " AND r.online_count > 0 THEN 2 WHEN r.owner_country_code <> " + countryLiteral + " AND r.online_count > 0 THEN 3 WHEN r.owner_country_code = " + countryLiteral + " THEN 4 ELSE 5 END"
	}
	regionPinScopeSQL := "AND rp.visible_region_id = ?"
	countryPinScopeSQL := "AND cp.visible_region_id = ?"
	if query.AllVisibleRegions {
		// 白名单用户查看全区列表时，区域/国家置顶按房间自身区域命中，不能再固定到 viewer 区域。
		regionPinScopeSQL = "AND rp.visible_region_id = r.visible_region_id"
		countryPinScopeSQL = "AND cp.visible_region_id = r.visible_region_id"
	}
	pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL AND cp.room_id IS NULL THEN 0 ELSE 1 END"
	pinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 0 WHEN cp.room_id IS NOT NULL THEN 1 ELSE " + normalRankExpr + " END"
	pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight WHEN cp.room_id IS NOT NULL THEN cp.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 WHEN cp.room_id IS NOT NULL THEN cp.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,
	       COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
	       r.owner_vip_level, r.owner_vip_expires_at_ms, r.rocket_near_completion,
	       r.has_active_red_packet, r.active_red_packet_expires_at_ms,
	       r.owner_vip_fallback_level, r.owner_vip_fallback_expires_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'
	 ` + regionPinScopeSQL + `
	 AND rp.room_id = r.room_id
	 AND rp.status = ?
	 AND rp.pinned_at_ms <= ?
	 AND rp.expires_at_ms > ?
	LEFT JOIN room_region_pins cp
	  ON cp.app_code = r.app_code
	 AND cp.pin_type = 'country'
	 ` + countryPinScopeSQL + `
	 AND cp.room_id = r.room_id
	 AND r.owner_country_code = ?
	 AND cp.status = ?
	 AND cp.pinned_at_ms <= ?
	 AND cp.expires_at_ms > ?`
	where := []string{"r.app_code = ?", "r.status = ?"}
	args := []any{"active", query.NowMS, query.NowMS}
	if !query.AllVisibleRegions {
		args = append(args, regionID)
	}
	args = append(args, "active", query.NowMS, query.NowMS)
	if !query.AllVisibleRegions {
		args = append(args, regionID)
	}
	args = append(args, sortCountryCode, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active")
	if !query.AllVisibleRegions {
		// 普通发现页必须利用区域索引隔离结果；白名单模式只保留 app/status/可选国家/关键词条件。
		where = append(where, "r.visible_region_id = ?")
		args = append(args, regionID)
	}
	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 buildRoomListByOwnersQuerySQL(query roomservice.RoomListQuery) (string, []any) {
	where := []string{"app_code = ?", "status = ?"}
	args := []any{appcode.Normalize(query.AppCode), "active"}

	placeholders := make([]string, 0, len(query.OwnerUserIDs))
	for _, ownerUserID := range query.OwnerUserIDs {
		placeholders = append(placeholders, "?")
		args = append(args, ownerUserID)
	}
	where = append(where, "owner_user_id IN ("+strings.Join(placeholders, ",")+")")

	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 roomListOwnerSelectColumns + "\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 roomListOwnerSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY sort_score DESC, room_id ASC\n\tLIMIT ?", args
}

func normalizeRoomListSQLCountryCode(countryCode string) string {
	countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
	if len(countryCode) < 2 || len(countryCode) > 3 {
		return ""
	}
	for _, ch := range countryCode {
		if ch < 'A' || ch > 'Z' {
			return ""
		}
	}
	return countryCode
}

func roomListSQLStringLiteral(value string) string {
	return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}

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 投影用户当前房间读模型。
