// Package service 提供 meeting 模块的业务逻辑
package service

import (
	"context"
	"encoding/json"
	"fmt"
	"sync"
	"time"

	"github.com/echochat/backend/app/constants"
	"github.com/echochat/backend/app/meeting/dao"
	"github.com/echochat/backend/config"
	"github.com/echochat/backend/pkg/logs"
	"github.com/redis/go-redis/v9"
	"go.uber.org/zap"
)

// Redis key 前缀（设计文档 §5.4 生命周期 keys；Task 8 落地）
const (
	redisKeyHostGracePrefix        = "echo:meeting:host_grace:"
	redisKeyEmptyTTLPrefix         = "echo:meeting:empty_ttl:"
	redisKeyHostGraceHandlingLock  = "echo:meeting:host_grace_handling:"  // Task 16 P1-7：处理互斥锁
	redisKeyEmptyTTLHandlingLock   = "echo:meeting:empty_ttl_handling:"   // Task 16 P1-7：空房处理互斥锁
	handlingLockTTLSeconds         = 60                                   // 处理锁 60s，远大于正常处理耗时（P99 <2s）
)

// hostGracePayload host 宽限期 Redis value 结构
type hostGracePayload struct {
	HostID     int64 `json:"host_id"`
	StartedAt  int64 `json:"started_at"`  // 秒级 unix
	GraceUntil int64 `json:"grace_until"` // 秒级 unix
}

// MeetingLifecycleService 会议生命周期状态机
// 负责设计 §6.5 的 5 类状态跃迁副作用（host 宽限期、自动转让、空房 TTL、TTL 过期销毁）
// 采用 time.AfterFunc 本地 timer + Redis key TTL 双保险：
//   - 本地 timer 负责低延迟触发；服务重启由 RescheduleFromRedis 按 Redis 剩余 PTTL 重新装载
//   - 后台定时任务 MeetingCleanupTask 每 N 秒扫描 Redis 兜底防御（timer 丢失 / 多实例部署重复触发场景）
//
// 并发保护：HandleHostGraceExpired / HandleEmptyRoomExpired 入口先 DEL Redis key
// 通过 DEL 返回值确认唯一处理权（0 = 已被其他 goroutine/节点清走，直接 return 避免重复操作）
type MeetingLifecycleService struct {
	roomDAO        *dao.MeetingRoomDAO
	participantDAO *dao.MeetingParticipantDAO
	db             dbExecutor
	redis          *redis.Client
	broadcaster    *MeetingBroadcaster
	media          MediaOrchestrator
	cfg            config.MeetingConfig

	graceTimers    sync.Map // roomCode -> *time.Timer（host 宽限期）
	emptyTTLTimers sync.Map // roomCode -> *time.Timer（空房 TTL）
}

// dbExecutor MeetingLifecycleService 对事务 DB 的最小依赖抽象
// 仅用于 HandleEmptyRoomExpired 的 MarkEnded 调用链路，避免强绑 *gorm.DB
type dbExecutor interface{}

// NewMeetingLifecycleService 构造生命周期服务
// media 由上游通过 wire 注入 HTTPMediaOrchestrator
func NewMeetingLifecycleService(
	roomDAO *dao.MeetingRoomDAO,
	participantDAO *dao.MeetingParticipantDAO,
	redis *redis.Client,
	broadcaster *MeetingBroadcaster,
	media MediaOrchestrator,
	cfg *config.Config,
) *MeetingLifecycleService {
	meetingCfg := cfg.Meeting
	// 零值兜底：防止 yaml 未配置时走 0 秒 TTL
	if meetingCfg.HostGraceSeconds <= 0 {
		meetingCfg.HostGraceSeconds = constants.MeetingHostGraceSeconds
	}
	if meetingCfg.EmptyRoomTTLSeconds <= 0 {
		meetingCfg.EmptyRoomTTLSeconds = constants.MeetingEmptyRoomTTLSeconds
	}
	if meetingCfg.CleanupIntervalSeconds <= 0 {
		meetingCfg.CleanupIntervalSeconds = 30
	}
	if meetingCfg.StaleRoomHours <= 0 {
		meetingCfg.StaleRoomHours = 4
	}
	return &MeetingLifecycleService{
		roomDAO:        roomDAO,
		participantDAO: participantDAO,
		redis:          redis,
		broadcaster:    broadcaster,
		media:          media,
		cfg:            meetingCfg,
	}
}

// Config 返回生效的生命周期配置（供定时任务读取扫描周期等）
func (s *MeetingLifecycleService) Config() config.MeetingConfig {
	return s.cfg
}

// HostGraceKey 返回 host 宽限期 Redis key
func HostGraceKey(roomCode string) string {
	return redisKeyHostGracePrefix + roomCode
}

// EmptyTTLKey 返回空房 TTL Redis key
func EmptyTTLKey(roomCode string) string {
	return redisKeyEmptyTTLPrefix + roomCode
}

// OnHostDisconnect host WS 断线钩子
// 写入 host_grace key（TTL HostGraceSeconds）并启动本地 AfterFunc
// 若已有同 roomCode 的 grace 记录（重复触发），幂等刷新 TTL + 替换 timer
func (s *MeetingLifecycleService) OnHostDisconnect(ctx context.Context, roomCode string, hostID int64) {
	funcName := "service.meeting_lifecycle_service.OnHostDisconnect"

	now := time.Now()
	payload := hostGracePayload{
		HostID:     hostID,
		StartedAt:  now.Unix(),
		GraceUntil: now.Add(time.Duration(s.cfg.HostGraceSeconds) * time.Second).Unix(),
	}
	raw, _ := json.Marshal(payload)

	graceDur := time.Duration(s.cfg.HostGraceSeconds) * time.Second
	// Redis TTL 比本地 timer 多留一段 buffer（max(cleanup*2, 30s)）
	// 保证本地 timer 先触发 → DEL 命中；即便本地 timer 丢失，cleanup 也能在 buffer 内兜底扫到
	redisTTL := graceDur + s.ttlBuffer()
	if err := s.redis.Set(ctx, HostGraceKey(roomCode), string(raw), redisTTL).Err(); err != nil {
		logs.Warn(ctx, funcName, "写入 host_grace key 失败",
			zap.String("room_code", roomCode), zap.Int64("host_id", hostID), zap.Error(err))
		return
	}

	s.scheduleHostGraceTimer(roomCode, graceDur)
	logs.Info(ctx, funcName, "host 进入宽限期",
		zap.String("room_code", roomCode),
		zap.Int64("host_id", hostID),
		zap.Int("grace_seconds", s.cfg.HostGraceSeconds))
}

// OnHostReconnect host 重连钩子
// 清除 host_grace key 并取消本地 timer；用于 host 掉线后 TTL 内重新出现在 WS 层
// 幂等：key 不存在也不报错
func (s *MeetingLifecycleService) OnHostReconnect(ctx context.Context, roomCode string, hostID int64) {
	funcName := "service.meeting_lifecycle_service.OnHostReconnect"

	deleted, err := s.redis.Del(ctx, HostGraceKey(roomCode)).Result()
	if err != nil {
		logs.Warn(ctx, funcName, "清除 host_grace key 失败",
			zap.String("room_code", roomCode), zap.Error(err))
	}
	s.cancelTimer(&s.graceTimers, roomCode)
	if deleted > 0 {
		logs.Info(ctx, funcName, "host 宽限期内重连，撤销转让计划",
			zap.String("room_code", roomCode), zap.Int64("host_id", hostID))
	}
}

// HandleHostGraceExpired host 宽限期过期处理
// 触发时机：本地 timer 到期 或 cleanup task 扫到 key TTL <=0
// Task 16 P1-7 强化并发控制：
//   旧版依赖 DEL host_grace 主 key 返回值作为唯一处理权判据；但在 Redis TTL 自然过期 >
//   本地 timer 到点的极端场景（时钟漂移 / 进程重启 RescheduleFromRedis 丢 timer 后 cleanup 扫到前 Redis 已自然过期），
//   本地 AfterFunc 执行 DEL 返回 0 被误判为"已处理"，主持人宽限转让彻底丢失。
// 现引入独立的 host_grace_handling 处理锁 (SETNX + 60s TTL)：
//   - 拿到锁 → 当前协程是唯一处理者，继续执行转让逻辑；无论主 key 是否还存在
//   - 未拿到锁 → 其他协程/节点正在处理，当前跳过
//   主 key DEL 仅作为"重连撤销"语义（OnHostReconnect 调用），不再作为处理权判据。
// 流程：
//  1. SET NX host_grace_handling key → 失败则跳过
//  2. cancelTimer 防重入
//  3. 取消原 host_grace key（正常清理，允许失败）
//  4. 加载 room + 活跃成员；若 room 已 Ended / host 已变更，视为幂等完成
//  5. 活跃成员存在：选最早加入者转 host（事务），广播 host.changed
//  6. 无活跃成员：走 OnAllMembersLeft 路径（置 empty_ttl）
func (s *MeetingLifecycleService) HandleHostGraceExpired(ctx context.Context, roomCode string) {
	funcName := "service.meeting_lifecycle_service.HandleHostGraceExpired"

	handlingKey := redisKeyHostGraceHandlingLock + roomCode
	locked, lockErr := s.redis.SetNX(ctx, handlingKey, "1", handlingLockTTLSeconds*time.Second).Result()
	if lockErr != nil {
		logs.Warn(ctx, funcName, "抢占 host_grace 处理锁失败（跳过本次处理）",
			zap.String("room_code", roomCode), zap.Error(lockErr))
		return
	}
	if !locked {
		logs.Debug(ctx, funcName, "host_grace 处理锁被其他协程持有，跳过",
			zap.String("room_code", roomCode))
		return
	}
	// 处理完成后释放锁（防止同 roomCode 60s 内无法重入正常排班）
	defer func() {
		if err := s.redis.Del(ctx, handlingKey).Err(); err != nil {
			logs.Warn(ctx, funcName, "释放 host_grace 处理锁失败（60s 后自然过期）",
				zap.String("room_code", roomCode), zap.Error(err))
		}
	}()

	s.cancelTimer(&s.graceTimers, roomCode)
	if err := s.redis.Del(ctx, HostGraceKey(roomCode)).Err(); err != nil {
		logs.Warn(ctx, funcName, "DEL host_grace 主 key 失败（继续处理，锁已持有）",
			zap.String("room_code", roomCode), zap.Error(err))
	}

	room, err := s.roomDAO.GetByCode(ctx, roomCode)
	if err != nil || room == nil {
		logs.Warn(ctx, funcName, "加载会议失败或已不存在",
			zap.String("room_code", roomCode), zap.Error(err))
		return
	}
	if room.Status == constants.MeetingStatusEnded {
		logs.Info(ctx, funcName, "会议已结束，跳过 host 宽限期过期处理",
			zap.String("room_code", roomCode))
		return
	}

	actives, err := s.participantDAO.ListActiveByRoom(ctx, room.ID)
	if err != nil {
		logs.Warn(ctx, funcName, "拉取活跃成员失败",
			zap.String("room_code", roomCode), zap.Error(err))
		return
	}
	// 排除原 host（若 left_at IS NULL 但 WS 掉线未落库）
	candidates := make([]int64, 0, len(actives))
	for _, p := range actives {
		if p.UserID == room.HostID {
			continue
		}
		candidates = append(candidates, p.UserID)
	}

	if len(candidates) == 0 {
		logs.Info(ctx, funcName, "host 宽限期过期且无其他活跃成员，转空房 TTL",
			zap.String("room_code", roomCode), zap.Int64("old_host_id", room.HostID))
		// 清掉 host 自己的记录（若 WS 掉线未落库，由兜底 LeaveRoom 处理）
		_, _ = s.participantDAO.LeaveRoom(ctx, room.ID, room.HostID, constants.MeetingLeftReasonDisconnect)
		s.OnAllMembersLeft(ctx, roomCode)
		return
	}

	newHostID := candidates[0]
	// P1-1：TransferHost 内部事务已原子更新 meeting_rooms.host_id，不再需要单独 UpdateHost
	if err := s.participantDAO.TransferHost(ctx, room.ID, room.HostID, newHostID); err != nil {
		logs.Error(ctx, funcName, "host 自动转让 TransferHost 失败",
			zap.String("room_code", roomCode), zap.Error(err))
		return
	}
	// 老 host 彻底离会（宽限期内未重连视为网络断开）
	_, _ = s.participantDAO.LeaveRoom(ctx, room.ID, room.HostID, constants.MeetingLeftReasonDisconnect)

	s.broadcaster.BroadcastToMeeting(ctx, room.ID, constants.MeetingWSEventHostChanged, map[string]interface{}{
		"room_code":   roomCode,
		"old_host_id": room.HostID,
		"new_host_id": newHostID,
		"auto_reason": "host_grace_expired",
	})
	logs.Info(ctx, funcName, "host 宽限期过期自动转让完成",
		zap.String("room_code", roomCode),
		zap.Int64("old_host_id", room.HostID),
		zap.Int64("new_host_id", newHostID))
}

// OnAllMembersLeft 全员退出钩子
// 设置 empty_ttl key + 启动本地 AfterFunc；期间有人重新加入可由 CancelEmptyTTL 复活房间
func (s *MeetingLifecycleService) OnAllMembersLeft(ctx context.Context, roomCode string) {
	funcName := "service.meeting_lifecycle_service.OnAllMembersLeft"

	ttl := time.Duration(s.cfg.EmptyRoomTTLSeconds) * time.Second
	// Redis TTL 同样加 buffer 避免与本地 timer 同时到期导致 DEL 无法命中
	redisTTL := ttl + s.ttlBuffer()
	if err := s.redis.Set(ctx, EmptyTTLKey(roomCode), "1", redisTTL).Err(); err != nil {
		logs.Warn(ctx, funcName, "写入 empty_ttl key 失败",
			zap.String("room_code", roomCode), zap.Error(err))
		return
	}
	s.scheduleEmptyTTLTimer(roomCode, ttl)
	logs.Info(ctx, funcName, "空房 TTL 已启动",
		zap.String("room_code", roomCode), zap.Int("ttl_seconds", s.cfg.EmptyRoomTTLSeconds))
}

// CancelEmptyTTL 取消空房 TTL（有新成员加入时调用，房间复活）
// 幂等：key 不存在也不报错
func (s *MeetingLifecycleService) CancelEmptyTTL(ctx context.Context, roomCode string) {
	funcName := "service.meeting_lifecycle_service.CancelEmptyTTL"

	deleted, err := s.redis.Del(ctx, EmptyTTLKey(roomCode)).Result()
	if err != nil {
		logs.Warn(ctx, funcName, "清除 empty_ttl key 失败",
			zap.String("room_code", roomCode), zap.Error(err))
	}
	s.cancelTimer(&s.emptyTTLTimers, roomCode)
	if deleted > 0 {
		logs.Info(ctx, funcName, "空房 TTL 已取消（有人加入）",
			zap.String("room_code", roomCode))
	}
}

// HandleEmptyRoomExpired 空房 TTL 过期处理
// Task 16 P1-7：采用 empty_ttl_handling 独立处理锁避免"Redis 自然过期 + 本地 timer 到点 + DEL 返回 0"盲区
// 新成员重入由 "DEL 成功 → DB activeCount==0" 复合校验保证（持锁期间仍会被 CountActiveByRoom 二次校验）
func (s *MeetingLifecycleService) HandleEmptyRoomExpired(ctx context.Context, roomCode string) {
	funcName := "service.meeting_lifecycle_service.HandleEmptyRoomExpired"

	handlingKey := redisKeyEmptyTTLHandlingLock + roomCode
	locked, lockErr := s.redis.SetNX(ctx, handlingKey, "1", handlingLockTTLSeconds*time.Second).Result()
	if lockErr != nil {
		logs.Warn(ctx, funcName, "抢占 empty_ttl 处理锁失败（跳过本次处理）",
			zap.String("room_code", roomCode), zap.Error(lockErr))
		return
	}
	if !locked {
		logs.Debug(ctx, funcName, "empty_ttl 处理锁被其他协程持有，跳过",
			zap.String("room_code", roomCode))
		return
	}
	defer func() {
		if err := s.redis.Del(ctx, handlingKey).Err(); err != nil {
			logs.Warn(ctx, funcName, "释放 empty_ttl 处理锁失败（60s 后自然过期）",
				zap.String("room_code", roomCode), zap.Error(err))
		}
	}()

	s.cancelTimer(&s.emptyTTLTimers, roomCode)
	if err := s.redis.Del(ctx, EmptyTTLKey(roomCode)).Err(); err != nil {
		logs.Warn(ctx, funcName, "DEL empty_ttl 主 key 失败（继续处理，锁已持有）",
			zap.String("room_code", roomCode), zap.Error(err))
	}

	room, err := s.roomDAO.GetByCode(ctx, roomCode)
	if err != nil || room == nil {
		logs.Warn(ctx, funcName, "加载会议失败或已不存在",
			zap.String("room_code", roomCode), zap.Error(err))
		return
	}
	if room.Status == constants.MeetingStatusEnded {
		return
	}

	// 二次校验：处理锁 ≠ 新成员重入阻断，若进入执行时已有活跃成员则放弃销毁
	activeCount, _ := s.participantDAO.CountActiveByRoom(ctx, room.ID)
	if activeCount > 0 {
		logs.Info(ctx, funcName, "检测到活跃成员，放弃销毁",
			zap.String("room_code", roomCode), zap.Int64("active_count", activeCount))
		return
	}

	if _, mErr := s.roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonEmptyTTL, time.Now()); mErr != nil {
		logs.Error(ctx, funcName, "MarkEnded 失败",
			zap.String("room_code", roomCode), zap.Error(mErr))
		return
	}
	if err := s.media.CloseRouter(ctx, roomCode); err != nil {
		logs.Warn(ctx, funcName, "CloseRouter 失败（不影响 DB 状态）",
			zap.String("room_code", roomCode), zap.Error(err))
	}

	s.broadcaster.BroadcastToMeeting(ctx, room.ID, constants.MeetingWSEventRoomEnded, map[string]interface{}{
		"room_code": roomCode,
		"reason":    constants.MeetingEndedReasonEmptyTTL,
	})

	// Task 16 资源清理专项：空房销毁时 activeCount==0，每个前任活跃成员的 Redis Set / Hash 理论上
	// 应已被各自的 LeaveRoom → cleanupUserResources 清掉；此处幂等兜底清理生命周期自身持有的 timer
	// 与 handling 锁 key（避免 OnRoomEnded 缺失导致的 handle/timers 累积）
	cleanupRoomRedisResidual(ctx, s.redis, roomCode, nil)
	s.cancelTimer(&s.graceTimers, roomCode)
	// empty_ttl 主 key 已经在当前函数顶部 DEL，此处再 DEL host_grace + handling 锁 key 做收尾
	pipe := s.redis.Pipeline()
	pipe.Del(ctx, HostGraceKey(roomCode))
	pipe.Del(ctx, redisKeyHostGraceHandlingLock+roomCode)
	if _, err := pipe.Exec(ctx); err != nil {
		logs.Warn(ctx, funcName, "清理会议剩余生命周期 key 失败（忽略）",
			zap.String("room_code", roomCode), zap.Error(err))
	}

	logs.Info(ctx, funcName, "空房 TTL 过期，会议已销毁",
		zap.String("room_code", roomCode), zap.Int64("room_id", room.ID))
}

// OnRoomEnded 会议结束（host 主动 EndRoom / 空房 TTL 销毁 / 其他销毁路径）统一生命周期收尾
// Task 16 资源清理专项：
//   1. 取消 host 宽限期 / 空房 TTL 两条本地 timer，避免 time.AfterFunc 到期后跑一轮无效 HandleXxxExpired
//      (虽然 HandleXxxExpired 内部有 status=Ended 早退路径，但仍浪费 30s context + Redis 往返)
//   2. DEL host_grace / empty_ttl 主 key 及对应的处理锁 key，避免 Redis 条目累积到自然过期
// 幂等：多次调用、无 timer / 无 key 场景都安全
func (s *MeetingLifecycleService) OnRoomEnded(ctx context.Context, roomCode string) {
	funcName := "service.meeting_lifecycle_service.OnRoomEnded"

	s.cancelTimer(&s.graceTimers, roomCode)
	s.cancelTimer(&s.emptyTTLTimers, roomCode)

	pipe := s.redis.Pipeline()
	pipe.Del(ctx, HostGraceKey(roomCode))
	pipe.Del(ctx, EmptyTTLKey(roomCode))
	pipe.Del(ctx, redisKeyHostGraceHandlingLock+roomCode)
	pipe.Del(ctx, redisKeyEmptyTTLHandlingLock+roomCode)
	if _, err := pipe.Exec(ctx); err != nil {
		logs.Warn(ctx, funcName, "批量清理生命周期 Redis key 失败（忽略，继续）",
			zap.String("room_code", roomCode), zap.Error(err))
	}
	logs.Debug(ctx, funcName, "会议结束，生命周期 timers/keys 已清理",
		zap.String("room_code", roomCode))
}

// RescheduleFromRedis 服务启动时扫描 Redis 已存在的 grace / empty_ttl key，按剩余 PTTL 补装本地 timer
// 服务重启后调用一次，避免重启期间 WS 断线事件的本地 timer 丢失导致宽限期/TTL 永远不触发
func (s *MeetingLifecycleService) RescheduleFromRedis(ctx context.Context) {
	funcName := "service.meeting_lifecycle_service.RescheduleFromRedis"

	s.rescheduleByPattern(ctx, funcName, redisKeyHostGracePrefix+"*", func(roomCode string, dur time.Duration) {
		s.scheduleHostGraceTimer(roomCode, dur)
	})
	s.rescheduleByPattern(ctx, funcName, redisKeyEmptyTTLPrefix+"*", func(roomCode string, dur time.Duration) {
		s.scheduleEmptyTTLTimer(roomCode, dur)
	})
}

// ScanExpired 扫描指定 key pattern，返回 TTL <=0 （或即将过期）对应的 roomCode 列表
// 供定时任务兜底调用：若本地 timer 已丢失，此时 Redis key 可能仍存在但 TTL 为负
// 本实现采用 SCAN + PTTL，单次扫描 limit 控制内存
func (s *MeetingLifecycleService) ScanExpired(ctx context.Context, keyPrefix string, limit int64) []string {
	funcName := "service.meeting_lifecycle_service.ScanExpired"

	expired := make([]string, 0, limit)
	var cursor uint64
	for {
		keys, next, err := s.redis.Scan(ctx, cursor, keyPrefix+"*", limit).Result()
		if err != nil {
			logs.Warn(ctx, funcName, "SCAN 失败", zap.String("pattern", keyPrefix), zap.Error(err))
			return expired
		}
		for _, k := range keys {
			pttl, err := s.redis.PTTL(ctx, k).Result()
			if err != nil {
				continue
			}
			// PTTL<=0（redis 过期延迟删除场景）；PTTL==-2 已不存在
			if pttl > 0 {
				continue
			}
			roomCode := keyToRoomCode(k, keyPrefix)
			if roomCode != "" {
				expired = append(expired, roomCode)
			}
		}
		if next == 0 {
			break
		}
		cursor = next
	}
	return expired
}

// HostGracePrefix / EmptyTTLPrefix 暴露 key 前缀供定时任务扫描使用
func (s *MeetingLifecycleService) HostGracePrefix() string { return redisKeyHostGracePrefix }
func (s *MeetingLifecycleService) EmptyTTLPrefix() string  { return redisKeyEmptyTTLPrefix }

// ========== 内部辅助 ==========

func (s *MeetingLifecycleService) scheduleHostGraceTimer(roomCode string, d time.Duration) {
	s.cancelTimer(&s.graceTimers, roomCode)
	timer := time.AfterFunc(d, func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		s.HandleHostGraceExpired(ctx, roomCode)
	})
	s.graceTimers.Store(roomCode, timer)
}

func (s *MeetingLifecycleService) scheduleEmptyTTLTimer(roomCode string, d time.Duration) {
	s.cancelTimer(&s.emptyTTLTimers, roomCode)
	timer := time.AfterFunc(d, func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		s.HandleEmptyRoomExpired(ctx, roomCode)
	})
	s.emptyTTLTimers.Store(roomCode, timer)
}

// ttlBuffer 返回 Redis key 相对本地 timer 的额外 buffer 时长
// 用于保证本地 timer 先触发（DEL 命中），避免 Redis 自动过期后 DEL 返回 0 被误判为重复触发
// 规则：max(CleanupIntervalSeconds * 2, 30s)，确保单机 cleanup 至少能扫到一轮兜底
func (s *MeetingLifecycleService) ttlBuffer() time.Duration {
	buf := time.Duration(s.cfg.CleanupIntervalSeconds*2) * time.Second
	if buf < 30*time.Second {
		buf = 30 * time.Second
	}
	return buf
}

// cancelTimer 取消本地 timer（幂等，不存在也安全）
func (s *MeetingLifecycleService) cancelTimer(store *sync.Map, roomCode string) {
	if raw, ok := store.LoadAndDelete(roomCode); ok {
		if t, ok := raw.(*time.Timer); ok {
			t.Stop()
		}
	}
}

// rescheduleByPattern 通用"按 pattern 扫描 Redis + 按 PTTL 重建本地 timer"逻辑
func (s *MeetingLifecycleService) rescheduleByPattern(
	ctx context.Context,
	funcName string,
	pattern string,
	schedule func(roomCode string, dur time.Duration),
) {
	var cursor uint64
	total := 0
	for {
		keys, next, err := s.redis.Scan(ctx, cursor, pattern, 100).Result()
		if err != nil {
			logs.Warn(ctx, funcName, "SCAN 失败", zap.String("pattern", pattern), zap.Error(err))
			return
		}
		for _, k := range keys {
			pttl, err := s.redis.PTTL(ctx, k).Result()
			if err != nil || pttl <= 0 {
				continue
			}
			prefix := pattern[:len(pattern)-1] // 去掉末尾 *
			roomCode := keyToRoomCode(k, prefix)
			if roomCode == "" {
				continue
			}
			schedule(roomCode, pttl)
			total++
		}
		if next == 0 {
			break
		}
		cursor = next
	}
	if total > 0 {
		logs.Info(ctx, funcName, "服务启动重建 timer 完成",
			zap.String("pattern", pattern), zap.Int("count", total))
	}
}

// keyToRoomCode 从完整 Redis key 中去掉前缀得到 roomCode
// 返回空字符串表示格式异常
func keyToRoomCode(fullKey, prefix string) string {
	if len(fullKey) <= len(prefix) {
		return ""
	}
	return fullKey[len(prefix):]
}

// formatErr 构造统一带上下文的错误（用于少数需要 return 的场景，避免裸字符串拼接）
// nolint:unused
func formatErr(funcName, msg string, err error) error {
	return fmt.Errorf("%s: %s: %w", funcName, msg, err)
}
