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

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"

	mysqlerr "github.com/go-sql-driver/mysql"
	"google.golang.org/protobuf/proto"
	"hyapp/pkg/appcode"
	"hyapp/services/room-service/internal/room/command"
	"hyapp/services/room-service/internal/room/outbox"
	roomservice "hyapp/services/room-service/internal/room/service"
)

// 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, result_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.ResultPayload, &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, result_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,
		nullableBytes(record.ResultPayload),
		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 meta := commit.CreateRoomMeta; meta != nil {
		// 创建元数据、首条 command/outbox 和 cover_upload_id 消费必须共用同一事务；
		// 任一后续步骤失败时 rooms 唯一约束和预上传凭证都会一起回滚。
		_, err := tx.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,
			strings.TrimSpace(meta.RoomID),
			strings.TrimSpace(meta.RoomShortID),
			meta.OwnerUserID,
			meta.SeatCount,
			strings.TrimSpace(meta.Mode),
			strings.TrimSpace(meta.Status),
			strings.TrimSpace(meta.RoomPasswordHash),
			meta.VisibleRegionID,
			commit.Command.CreatedAtMS,
			commit.Command.CreatedAtMS,
		)
		if err != nil {
			_ = tx.Rollback()
			return mapRoomMetaDuplicateError(err)
		}
	}

	if err := r.lockRoomMutation(ctx, tx, appCode, commit.Command.RoomID); err != nil {
		_ = tx.Rollback()
		return err
	}

	if commit.Command.Replayable && enforceUniqueReplayableRoomVersion(commit.Command.CommandType) {
		var existingCommandID string
		err := tx.QueryRowContext(ctx,
			`SELECT command_id
			   FROM room_command_log FORCE INDEX (idx_room_command_replay)
			  WHERE app_code = ? AND room_id = ? AND room_version = ? AND replayable = TRUE AND command_id <> ?
			  LIMIT 1
			  FOR UPDATE`,
			appCode,
			commit.Command.RoomID,
			commit.Command.RoomVersion,
			commit.Command.CommandID,
		).Scan(&existingCommandID)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			_ = tx.Rollback()
			return err
		}
		if existingCommandID != "" {
			_ = tx.Rollback()
			// 同房间同版本只能有一个可回放状态变更；这是恢复链路的最后一道持久化防线。
			return fmt.Errorf("room command version conflict: room_id=%s room_version=%d existing_command_id=%s new_command_id=%s", commit.Command.RoomID, commit.Command.RoomVersion, existingCommandID, commit.Command.CommandID)
		}
	}

	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, result_payload, replayable, created_at_ms)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appCode,
		commit.Command.RoomID,
		commit.Command.RoomVersion,
		commit.Command.CommandID,
		commit.Command.CommandType,
		commit.Command.OwnerNodeID,
		commit.Command.LeaseToken,
		commit.Command.Payload,
		nullableBytes(commit.Command.ResultPayload),
		commit.Command.Replayable,
		commit.Command.CreatedAtMS,
	); err != nil {
		_ = tx.Rollback()
		var duplicate *mysqlerr.MySQLError
		if errors.As(err, &duplicate) && duplicate.Number == 1062 && strings.Contains(duplicate.Message, "uk_room_command") {
			// 绝不能用 ON DUPLICATE KEY 吞掉重复命令后继续写 outbox/统计增量；调用方会读取首次结果并原样重放。
			return roomservice.ErrCommandAlreadyCommitted
		}
		return err
	}

	if err := r.insertRoomOutboxRecords(ctx, tx, commit.OutboxRecords); err != nil {
		// 任一事件失败就回滚整批，避免 GiftSent/HeatChanged/RankChanged 部分缺失。
		_ = tx.Rollback()
		return err
	}

	if consumption := commit.RoomCoverUploadConsumption; consumption != nil {
		// PRIMARY KEY(app_code, room_id, command_id) 使该条件直接定位预上传行，不会扫描上传历史。
		// 把 room_id 从空值改成目标房间同时完成归属绑定；status=active 保证凭证只能成功消费一次。
		result, err := tx.ExecContext(ctx, `
			UPDATE room_media_upload_registrations
			SET room_id = ?, status = 'consumed', updated_at_ms = ?
			WHERE app_code = ? AND room_id = '' AND command_id = ?
			  AND actor_user_id = ? AND purpose = 'room_cover' AND status = 'active'`,
			commit.Command.RoomID,
			commit.Command.CreatedAtMS,
			appCode,
			strings.TrimSpace(consumption.CoverUploadID),
			consumption.ActorUserID,
		)
		if err != nil {
			_ = tx.Rollback()
			return err
		}
		affected, err := result.RowsAffected()
		if err != nil || affected != 1 {
			_ = tx.Rollback()
			if err != nil {
				return err
			}
			return fmt.Errorf("%w: room_id=%s cover_upload_id=%s", roomservice.ErrRoomCoverUploadNotConsumable, commit.Command.RoomID, consumption.CoverUploadID)
		}
	}

	if change := commit.RoomDecorationAssignment; change != nil {
		decorationType := strings.TrimSpace(change.DecorationType)
		if decorationType == "" {
			_ = tx.Rollback()
			return errors.New("room decoration assignment type is required")
		}
		if change.Assignment == nil {
			statement := `DELETE FROM room_decoration_assignments WHERE app_code = ? AND room_id = ? AND decoration_type = ?`
			args := []any{appCode, commit.Command.RoomID, decorationType}
			if change.ExpectedResourceID > 0 {
				// wallet 事件可能和房主切换新资源并发；只允许旧事件删除自己查询到的 assignment。
				statement += ` AND resource_id = ?`
				args = append(args, change.ExpectedResourceID)
			}
			if _, err := tx.ExecContext(ctx, statement, args...); err != nil {
				_ = tx.Rollback()
				return err
			}
		} else {
			assignment := change.Assignment
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO room_decoration_assignments (
				  app_code, room_id, decoration_type, owner_user_id, benefit_code,
				  resource_id, entitlement_id, expires_at_ms, updated_at_ms
				) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
				ON DUPLICATE KEY UPDATE
				  owner_user_id = VALUES(owner_user_id), benefit_code = VALUES(benefit_code),
				  resource_id = VALUES(resource_id), entitlement_id = VALUES(entitlement_id),
				  expires_at_ms = VALUES(expires_at_ms), updated_at_ms = VALUES(updated_at_ms)`,
				appCode, commit.Command.RoomID, decorationType, assignment.OwnerUserID,
				strings.TrimSpace(assignment.BenefitCode), assignment.ResourceID,
				strings.TrimSpace(assignment.EntitlementID), assignment.ExpiresAtMS, assignment.UpdatedAtMS,
			); err != nil {
				_ = tx.Rollback()
				return err
			}
		}
	}

	if commit.DeleteRoom {
		if err := r.deleteRoomRows(ctx, tx, appCode, commit.Command.RoomID); err != nil {
			_ = tx.Rollback()
			return err
		}
		return tx.Commit()
	}

	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
		}
	}
	for _, stat := range commit.RoomUserContributionPeriodStats {
		if stat.UserID <= 0 || stat.GiftValue <= 0 || strings.TrimSpace(stat.Period) == "" || stat.PeriodStartMS <= 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_contribution_period_stats (
			   app_code, room_id, period, period_start_ms, period_end_ms, 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),
			   period_end_ms = VALUES(period_end_ms),
			   last_gift_at_ms = VALUES(last_gift_at_ms),
			   updated_at_ms = VALUES(updated_at_ms)`,
			statAppCode,
			statRoomID,
			strings.TrimSpace(stat.Period),
			stat.PeriodStartMS,
			stat.PeriodEndMS,
			stat.UserID,
			stat.GiftValue,
			stat.LastGiftAtMS,
			commit.Command.CreatedAtMS,
			commit.Command.CreatedAtMS,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}
	if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 {
		weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode)
		weeklyRoomID := strings.TrimSpace(weekly.RoomID)
		if weeklyRoomID == "" {
			weeklyRoomID = commit.Command.RoomID
		}
		occurredMS := weekly.OccurredMS
		if occurredMS <= 0 {
			occurredMS = commit.Command.CreatedAtMS
		}
		weekStartMS := roomContributionWeekStartMS(occurredMS)
		if _, err := tx.ExecContext(ctx,
			`UPDATE room_list_entries
			 SET weekly_contribution = CASE
			       WHEN contribution_week_start_ms = ? THEN weekly_contribution + ?
			       ELSE ?
			     END,
			     contribution_week_start_ms = ?,
			     updated_at_ms = ?
			 WHERE app_code = ? AND room_id = ?`,
			weekStartMS,
			weekly.GiftValue,
			weekly.GiftValue,
			weekStartMS,
			commit.Command.CreatedAtMS,
			weeklyAppCode,
			weeklyRoomID,
		); err != nil {
			_ = tx.Rollback()
			return err
		}
	}

	if commit.GiftOperation != nil {
		operation := commit.GiftOperation
		result, err := tx.ExecContext(ctx,
			`UPDATE room_gift_operations
			    SET status = ?, result_payload = ?, last_error = NULL, worker_id = '', lock_until_ms = NULL,
			        next_retry_at_ms = 0, updated_at_ms = ?
			  WHERE app_code = ? AND room_id = ? AND command_id = ? AND status <> ?`,
			roomservice.GiftOperationStatusCommitted,
			operation.ResultPayload,
			operation.UpdatedAtMS,
			appCode,
			commit.Command.RoomID,
			operation.CommandID,
			roomservice.GiftOperationStatusCompensated,
		)
		if err != nil {
			_ = tx.Rollback()
			return err
		}
		affected, err := result.RowsAffected()
		if err != nil || affected != 1 {
			_ = tx.Rollback()
			if err != nil {
				return err
			}
			return fmt.Errorf("gift operation cannot commit: room_id=%s command_id=%s", commit.Command.RoomID, operation.CommandID)
		}
	}

	return tx.Commit()
}

func (r *Repository) lockRoomMutation(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error {
	var lockedRoomID string
	err := tx.QueryRowContext(ctx,
		`SELECT room_id
		   FROM rooms
		  WHERE app_code = ? AND room_id = ?
		  FOR UPDATE`,
		appCode,
		roomID,
	).Scan(&lockedRoomID)
	if errors.Is(err, sql.ErrNoRows) {
		// 低层 command-log 测试可能不预置 rooms 行；生产 CreateRoom 通过 CreateRoomMeta 在本事务先插入。
		return nil
	}
	if err != nil {
		return err
	}
	return nil
}

func enforceUniqueReplayableRoomVersion(commandType string) bool {
	switch strings.TrimSpace(commandType) {
	case command.SendGift{}.Type():
		// SendGift 是账务成功后的 additive 房间表现：热度、榜单和展示事件都可以按自增 id 顺序补偿。
		// 历史机器人房间已经存在同版本礼物命令时，继续拦截会让 Cell 永久卡在旧版本，反而阻止后续快照推进。
		return false
	default:
		// presence、麦位、房间资料和生命周期命令会改变恢复结构；同版本双写必须 fail-closed。
		return true
	}
}

func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error {
	// 删除覆盖所有能让房间重新出现在列表、恢复链路或 owner 唯一约束里的本服务表。
	// room_outbox 也一起清理，避免已删除房间继续补偿投递旧的展示事件。
	statements := []string{
		`DELETE FROM room_outbox WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_robot_rooms WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_region_pins WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_follows WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_user_feed_entries WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_user_presence WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_user_contribution_period_stats WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_media_upload_registrations WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_decoration_assignments WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_gift_operations WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`,
		`DELETE FROM rooms WHERE app_code = ? AND room_id = ?`,
	}
	for _, statement := range statements {
		if _, err := tx.ExecContext(ctx, statement, appCode, roomID); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, records []outbox.Record) error {
	if len(records) == 0 {
		return nil
	}

	for _, record := range records {
		record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
		if record.Envelope != nil {
			record.Envelope.AppCode = record.AppCode
		}
		envelopeBytes, err := proto.Marshal(record.Envelope)
		if err != nil {
			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 {
			return err
		}
	}

	return nil
}

// 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, result_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.ResultPayload, &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 保留最新一条；同版本后写可能来自旧 owner 分支，不能覆盖已经存在的恢复来源。
	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
		   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),
		   room_version = IF(VALUES(room_version) > room_version, VALUES(room_version), room_version)`,
		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
}
