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

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	authModel "github.com/echochat/backend/app/auth/model"
	"github.com/echochat/backend/app/constants"
	"github.com/echochat/backend/app/dto"
	"github.com/echochat/backend/app/meeting/dao"
	"github.com/echochat/backend/app/meeting/model"
	notifyService "github.com/echochat/backend/app/notify/service"
	"github.com/echochat/backend/pkg/logs"
	"github.com/echochat/backend/pkg/utils"
	"github.com/google/uuid"
	"github.com/redis/go-redis/v9"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

// Meeting 模块领域错误
// 命名与 group/notify 模块的 Err* 风格一致，Controller 通过 errors.Is 识别后映射 HTTP 状态码
var (
	ErrMeetingNotFound       = errors.New("会议不存在")
	ErrMeetingEnded          = errors.New("会议已结束")
	ErrMeetingFull           = errors.New("会议已满员")
	ErrMeetingPasswordWrong  = errors.New("会议密码错误")
	ErrMeetingPasswordLocked = errors.New("密码连续错误次数过多，请稍后重试")
	ErrMeetingPasswordReq    = errors.New("此会议需要密码")
	ErrNotInMeeting          = errors.New("你不在此会议中")
	ErrAlreadyInMeeting      = errors.New("你已在此会议中")
	ErrAlreadyInOtherMeeting = errors.New("你当前已在其他会议中")
	ErrNotMeetingHost        = errors.New("仅主持人可执行此操作")
	ErrInviteTokenInvalid    = errors.New("邀请链接已失效")
	ErrRoomCodeConflict      = errors.New("会议号生成冲突，请稍后重试")
	ErrKickSelfForbidden     = errors.New("不能踢出自己")
	ErrTransferToSelf        = errors.New("不能将主持人转让给自己")
	ErrTransferTargetInvalid = errors.New("目标用户不在会议中")
	// ErrResourceNotOwned 媒体资源归属校验失败：用户试图操作不属于自己的 transport/producer/consumer
	// 发生场景：Web 端用户抓到他人 producerID 后调 meeting.producer.close、或跨用户挂 consumer 等横向越权尝试
	ErrResourceNotOwned = errors.New("媒体资源归属校验失败，禁止操作他人资源")
	// ErrMediaServiceUnavailable 媒体服务当前不可用（Router 创建失败 / Node 宕机等）
	// 用于 CreateRoom / JoinRoom 的补偿路径，将前台错误与"用户输入错误"区分开
	ErrMediaServiceUnavailable     = errors.New("媒体服务暂时不可用，请稍后重试")
	ErrCloudLawCallerPhoneRequired = errors.New("发起人手机号不能为空")
	ErrCloudLawCallerPhoneInvalid  = errors.New("发起人手机号长度超过限制")
	// Task 16 P2-7：会议聊天服务端限流
	ErrChatContentEmpty   = errors.New("消息内容不能为空")
	ErrChatContentTooLong = errors.New("消息长度超过上限")
	ErrChatRateLimited    = errors.New("发送过于频繁，请稍后再试")
)

// Redis key 前缀（设计文档 §5.4 - Redis 数据结构）
const (
	redisKeyInvitePrefix       = "echo:meeting:invite:"       // 邀请 Token
	redisKeyPasswordLockPrefix = "echo:meeting:lock:"         // 密码错误锁（code:user_id）
	redisPasswordAttemptPrefix = "echo:meeting:pwd_attempt:"  // 密码错误计数
	CloudLawDefaultPassword    = "123456"
	// Task 16 P2-7：会议聊天限流计数键，格式 "echo:meeting:chat_rate:<room>:<user_id>"
	redisKeyChatRatePrefix = "echo:meeting:chat_rate:"
)

// MeetingService 会议业务服务
// Task 5 完成：会议生命周期、主持人管理、邀请、会议内聊天 12 个 REST API 全部落地
// Task 6 新增：广播能力抽离到 MeetingBroadcaster；后续 WS 信令事件由 MeetingSignalService 处理
// Task 7 会把 mediaOrchestrator 的 Noop 实现替换为真实 Node HTTP Client
type MeetingService struct {
	roomDAO        *dao.MeetingRoomDAO
	participantDAO *dao.MeetingParticipantDAO
	chatDAO        *dao.MeetingChatDAO

	db    *gorm.DB
	redis *redis.Client

	broadcaster       *MeetingBroadcaster
	notifyPusher      NotifyPusher
	userResolver      UserInfoResolver
	onlineChecker     OnlineChecker
	mediaOrchestrator MediaOrchestrator
	lifecycleSvc      *MeetingLifecycleService

	// recordingStopper 由 NewApp 在 Wire 注入完成后通过 SetRecordingStopper 注入
	// 用 setter 而非构造函数参数：避免与 MeetingRecordingService 形成构造期循环依赖
	// nil 时 EndRoom 跳过录制清理（向后兼容，生产路径始终非 nil）
	recordingStopper RecordingStopper
}

// RecordingStopper EndRoom / 空房 TTL 等系统路径强制停止活跃录制的钩子
// 由 *MeetingRecordingService 实现，通过 setter 注入，避免循环依赖
type RecordingStopper interface {
	StopActiveForEndRoom(ctx context.Context, room *model.MeetingRoom)
}

// SetRecordingStopper 注入录制停止钩子（在 NewApp 完成 Wire 后调用）
func (s *MeetingService) SetRecordingStopper(stopper RecordingStopper) {
	s.recordingStopper = stopper
}

// NewMeetingService 创建 MeetingService 实例
// 依赖通过构造函数注入；接口依赖由上游 Wire 绑定到具体实现
// Task 7 (2026-04-21) 起 mediaOrchestrator 默认绑定 HTTPMediaOrchestrator
// Task 8 (2026-04-21) 起新增 lifecycleSvc 依赖：JoinRoom/LeaveRoom 的空房/复活场景由 lifecycleSvc 托管
func NewMeetingService(
	roomDAO *dao.MeetingRoomDAO,
	participantDAO *dao.MeetingParticipantDAO,
	chatDAO *dao.MeetingChatDAO,
	db *gorm.DB,
	redis *redis.Client,
	broadcaster *MeetingBroadcaster,
	notifyPusher NotifyPusher,
	userResolver UserInfoResolver,
	onlineChecker OnlineChecker,
	mediaOrchestrator MediaOrchestrator,
	lifecycleSvc *MeetingLifecycleService,
) *MeetingService {
	return &MeetingService{
		roomDAO:           roomDAO,
		participantDAO:    participantDAO,
		chatDAO:           chatDAO,
		db:                db,
		redis:             redis,
		broadcaster:       broadcaster,
		notifyPusher:      notifyPusher,
		userResolver:      userResolver,
		onlineChecker:     onlineChecker,
		mediaOrchestrator: mediaOrchestrator,
		lifecycleSvc:      lifecycleSvc,
	}
}

// ====== 辅助：鉴权 / 会议号 / 广播 ======

// assertIsActiveParticipant 确认用户是会议的活跃参会者；返回其 participant 记录供调用方复用
func (s *MeetingService) assertIsActiveParticipant(ctx context.Context, roomID, userID int64) (*model.MeetingParticipant, error) {
	p, err := s.participantDAO.GetByRoomAndUser(ctx, roomID, userID)
	if err != nil {
		return nil, err
	}
	if p == nil || !p.IsActive() {
		return nil, ErrNotInMeeting
	}
	return p, nil
}

// assertIsHost 确认用户是会议当前主持人
func (s *MeetingService) assertIsHost(ctx context.Context, room *model.MeetingRoom, userID int64) error {
	if room == nil {
		return ErrMeetingNotFound
	}
	if room.HostID != userID {
		return ErrNotMeetingHost
	}
	return nil
}

// generateUniqueRoomCode 生成唯一的 XXX-XXX-XXX 会议号，冲突最多重试 MeetingRoomCodeRetryMax 次
// P2-6（代码审查 2026-04-23）：
//   - 重试上限由 constants.MeetingRoomCodeRetryMax 控制（当前=3），避免死循环
//   - 只要命中第 2 次及以后就打 Warn（正常情况下几乎不会碰撞，连续碰撞通常是码空间
//     / 生成器被外部污染的信号，需要及早告警）
func (s *MeetingService) generateUniqueRoomCode(ctx context.Context) (string, error) {
	funcName := "service.meeting_service.generateUniqueRoomCode"
	for i := 0; i < constants.MeetingRoomCodeRetryMax; i++ {
		code, err := utils.GenerateMeetingRoomCode()
		if err != nil {
			return "", err
		}
		exists, err := s.roomDAO.ExistsCode(ctx, code)
		if err != nil {
			return "", err
		}
		if !exists {
			if i > 0 {
				logs.Warn(ctx, funcName, "会议号生成多次冲突后成功，请关注码空间健康度",
					zap.Int("retry_count", i),
					zap.String("code", code))
			}
			return code, nil
		}
	}
	logs.Error(ctx, funcName, "会议号生成连续冲突达到上限，疑似码空间异常或并发洪水",
		zap.Int("retry_max", constants.MeetingRoomCodeRetryMax))
	return "", ErrRoomCodeConflict
}

// broadcastToActiveParticipants 向房间内所有活跃参会者广播 WS 事件
// Task 6 起实现已迁移至 MeetingBroadcaster.BroadcastToMeeting，本方法作为兼容壳保留
// 以减少调用侧改动；未来可逐步替换为直接调用 s.broadcaster.BroadcastToMeeting
func (s *MeetingService) broadcastToActiveParticipants(ctx context.Context, roomID int64, event string, data interface{}, excludeUserIDs ...int64) {
	s.broadcaster.BroadcastToMeeting(ctx, roomID, event, data, excludeUserIDs...)
}

// UserDisplayInfo 用户展示信息（昵称优先，降级为用户名；头像可空）
type UserDisplayInfo struct {
	Name   string
	Avatar string
}

// resolveUserDisplay 查单个用户的展示信息；查询失败/未命中时返回空字符串
// 仅服务层内部使用，调用方失败时降级为 user_name="" 即可
func (s *MeetingService) resolveUserDisplay(ctx context.Context, userID int64) (name string, avatar string) {
	if s.userResolver == nil || userID <= 0 {
		return "", ""
	}
	users, err := s.userResolver.GetUsersByIDs(ctx, []int64{userID})
	if err != nil || len(users) == 0 {
		return "", ""
	}
	u := users[0]
	if u.Nickname != "" {
		return u.Nickname, u.Avatar
	}
	return u.Username, u.Avatar
}

// ResolveUsersDisplay 批量查询用户展示信息，controller 端用于填充 DTO
// 返回 map[userID]UserDisplayInfo，查询失败时返回空 map（调用方按降级处理）
func (s *MeetingService) ResolveUsersDisplay(ctx context.Context, userIDs []int64) map[int64]UserDisplayInfo {
	out := make(map[int64]UserDisplayInfo, len(userIDs))
	if s.userResolver == nil || len(userIDs) == 0 {
		return out
	}
	// 去重
	seen := make(map[int64]struct{}, len(userIDs))
	unique := make([]int64, 0, len(userIDs))
	for _, id := range userIDs {
		if id <= 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		unique = append(unique, id)
	}
	users, err := s.userResolver.GetUsersByIDs(ctx, unique)
	if err != nil {
		return out
	}
	for _, u := range users {
		name := u.Nickname
		if name == "" {
			name = u.Username
		}
		out[u.ID] = UserDisplayInfo{Name: name, Avatar: u.Avatar}
	}
	return out
}

func (s *MeetingService) EnsureCloudLawUserByPhone(ctx context.Context, phone string, nickname string) (*authModel.User, bool, error) {
	funcName := "service.meeting_service.EnsureCloudLawUserByPhone"
	phone = strings.TrimSpace(phone)
	nickname = strings.TrimSpace(nickname)
	if phone == "" {
		return nil, false, ErrCloudLawCallerPhoneRequired
	}
	if utf8.RuneCountInString(phone) > 20 {
		return nil, false, ErrCloudLawCallerPhoneInvalid
	}

	var existing authModel.User
	err := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&existing).Error
	if err == nil {
		s.ensureCloudLawDefaultPassword(ctx, &existing)
		return &existing, false, nil
	}
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, false, err
	}

	username := cloudLawUsername(phone)
	passwordHash, err := utils.HashPassword(CloudLawDefaultPassword)
	if err != nil {
		return nil, false, err
	}
	user := &authModel.User{
		Username:     username,
		Email:        username + "@cloud-law.local",
		PasswordHash: passwordHash,
		Nickname:     cloudLawNickname(nickname, phone),
		Phone:        &phone,
		Status:       constants.UserStatusActive,
	}
	if err = s.db.WithContext(ctx).Create(user).Error; err != nil {
		var retry authModel.User
		if retryErr := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&retry).Error; retryErr == nil {
			s.ensureCloudLawDefaultPassword(ctx, &retry)
			return &retry, false, nil
		}
		return nil, false, err
	}

	s.assignCloudLawDefaultRole(ctx, user.ID)
	logs.Info(ctx, funcName, "创建云律 EchoChat 用户成功",
		zap.Int64("user_id", user.ID),
		zap.String("phone", logs.MaskPhone(phone)),
	)
	return user, true, nil
}

func (s *MeetingService) ensureCloudLawDefaultPassword(ctx context.Context, user *authModel.User) {
	if user == nil || user.ID <= 0 || utils.CheckPassword(CloudLawDefaultPassword, user.PasswordHash) {
		return
	}
	passwordHash, err := utils.HashPassword(CloudLawDefaultPassword)
	if err != nil {
		logs.Warn(ctx, "service.meeting_service.ensureCloudLawDefaultPassword", "生成云律默认密码哈希失败",
			zap.Int64("user_id", user.ID), zap.Error(err))
		return
	}
	if err = s.db.WithContext(ctx).Model(&authModel.User{}).Where("id = ?", user.ID).
		Update("password_hash", passwordHash).Error; err != nil {
		logs.Warn(ctx, "service.meeting_service.ensureCloudLawDefaultPassword", "更新云律默认密码失败",
			zap.Int64("user_id", user.ID), zap.Error(err))
		return
	}
	user.PasswordHash = passwordHash
}

func (s *MeetingService) assignCloudLawDefaultRole(ctx context.Context, userID int64) {
	funcName := "service.meeting_service.assignCloudLawDefaultRole"
	var role authModel.Role
	if err := s.db.WithContext(ctx).Where("code = ?", constants.RoleUser).First(&role).Error; err != nil {
		logs.Warn(ctx, funcName, "查找默认角色失败，跳过角色分配", zap.Int64("user_id", userID), zap.Error(err))
		return
	}
	if err := s.db.WithContext(ctx).Create(&authModel.UserRole{UserID: userID, RoleID: role.ID}).Error; err != nil {
		logs.Warn(ctx, funcName, "分配默认角色失败", zap.Int64("user_id", userID), zap.Error(err))
	}
}

func cloudLawUsername(phone string) string {
	accountKey := strings.NewReplacer("+", "", "-", "", " ", "", "(", "", ")", "").Replace(phone)
	accountKey = strings.TrimSpace(accountKey)
	if accountKey == "" {
		accountKey = strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
	}
	return "cloudlaw_" + accountKey
}

func cloudLawNickname(nickname string, phone string) string {
	if nickname == "" {
		nickname = "云律用户" + cloudLawPhoneSuffix(phone)
	}
	return truncateCloudLawText(nickname, 50)
}

func cloudLawPhoneSuffix(phone string) string {
	runes := []rune(phone)
	if len(runes) <= 4 {
		return phone
	}
	return string(runes[len(runes)-4:])
}

func truncateCloudLawText(value string, limit int) string {
	if limit <= 0 || utf8.RuneCountInString(value) <= limit {
		return value
	}
	runes := []rune(value)
	return string(runes[:limit])
}

// ====== 会议生命周期 ======

// CreateRoom 创建即时会议
// 流程：生成唯一会议号 → bcrypt 密码 → 写入 meeting_rooms（status=Active + started_at=now）→ 主持人落 participant 表 → 驱动 mediasoup Router 创建
func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto.CreateMeetingRoomRequest) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
	return s.createRoom(ctx, hostID, req, true, true, "service.meeting_service.CreateRoom")
}

func (s *MeetingService) CreateRoomForInternal(ctx context.Context, hostID int64, req *dto.CreateMeetingRoomRequest) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
	return s.createRoom(ctx, hostID, req, false, true, "service.meeting_service.CreateRoomForInternal")
}

func (s *MeetingService) JoinRoomForInternal(ctx context.Context, userID int64, code string) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
	funcName := "service.meeting_service.JoinRoomForInternal"
	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return nil, nil, "", err
	}
	if room == nil {
		return nil, nil, "", ErrMeetingNotFound
	}
	if room.Status == constants.MeetingStatusEnded {
		return nil, nil, "", ErrMeetingEnded
	}
	if existing, pErr := s.participantDAO.GetByRoomAndUser(ctx, room.ID, userID); pErr != nil {
		return nil, nil, "", pErr
	} else if existing != nil && existing.IsActive() {
		routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
		return room, existing, routerID, nil
	}
	participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
	if err != nil {
		if errors.Is(err, dao.ErrAlreadyInMeeting) {
			existing, getErr := s.participantDAO.GetByRoomAndUser(ctx, room.ID, userID)
			if getErr != nil {
				return nil, nil, "", getErr
			}
			routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
			return room, existing, routerID, nil
		}
		return nil, nil, "", err
	}
	if s.lifecycleSvc != nil {
		s.lifecycleSvc.CancelEmptyTTL(ctx, code)
	}
	routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
	if routerID == "" {
		newID, mediaErr := s.mediaOrchestrator.CreateRouter(ctx, code)
		if mediaErr != nil {
			if _, leaveErr := s.participantDAO.LeaveRoom(ctx, room.ID, userID, constants.MeetingLeftReasonSelf); leaveErr != nil {
				logs.Warn(ctx, funcName, "内部入会补偿 LeaveRoom 失败", zap.Int64("room_id", room.ID), zap.Int64("user_id", userID), zap.Error(leaveErr))
			}
			return nil, nil, "", ErrMediaServiceUnavailable
		}
		routerID = newID
	}
	logs.Info(ctx, funcName, "云律接单人加入会议成功", zap.String("room_code", code), zap.Int64("user_id", userID))
	return room, participant, routerID, nil
}

func (s *MeetingService) createRoom(ctx context.Context, hostID int64, req *dto.CreateMeetingRoomRequest, enforceSingleActive bool, addHostParticipant bool, funcName string) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
	var err error
	defer func() {
		if err != nil {
			logs.Warn(ctx, funcName, "创建会议失败", zap.Int64("host_id", hostID), zap.Error(err))
		}
	}()

	if enforceSingleActive {
		active, err := s.participantDAO.FindActiveByUser(ctx, hostID)
		if err != nil {
			return nil, nil, "", err
		}
		if active != nil {
			err = ErrAlreadyInOtherMeeting
			return nil, nil, "", err
		}
	}

	code, err := s.generateUniqueRoomCode(ctx)
	if err != nil {
		return nil, nil, "", err
	}

	var passwordHash *string
	if req.Password != "" {
		hash, hErr := utils.HashPassword(req.Password)
		if hErr != nil {
			err = fmt.Errorf("密码哈希失败: %w", hErr)
			return nil, nil, "", err
		}
		passwordHash = &hash
	}

	maxMembers := req.MaxMembers
	if maxMembers <= 0 || maxMembers > constants.MeetingMVPMaxMembers {
		maxMembers = constants.MeetingMVPMaxMembers
	}

	now := time.Now()
	room := &model.MeetingRoom{
		RoomCode:     code,
		Title:        req.Title,
		HostID:       hostID,
		Type:         constants.MeetingTypeInstant,
		PasswordHash: passwordHash,
		MaxMembers:   maxMembers,
		Status:       constants.MeetingStatusActive,
		StartedAt:    &now,
		Settings:     "{}",
	}
	if err = s.roomDAO.Create(ctx, room); err != nil {
		return nil, nil, "", err
	}

	var participant *model.MeetingParticipant
	if addHostParticipant {
		participant, err = s.participantDAO.JoinRoom(ctx, room.ID, hostID, constants.MeetingRoleHost)
		if err != nil {
			return nil, nil, "", err
		}
	}

	// Task 7 起 CreateRouter 对接真实 mediasoup；Task 8 起仅在会议首次创建时调一次（房间级资源）
	// P0-3 修复（审计报告）：
	// 旧版在 Router 创建失败时仅 `logs.Warn` 继续返回成功，导致 DB 存在 active 房间但 mediasoup 端无 Router，
	// 所有入会者后续 transport.create 必失败；同时占用"一人一会议"名额，用户无法新建。
	// 现改为 fail-closed：Router 失败 → 补偿 LeaveRoom + MarkEnded(system_error) → 返回 ErrMediaServiceUnavailable 让前端提示重试。
	routerID, mediaErr := s.mediaOrchestrator.CreateRouter(ctx, code)
	if mediaErr != nil {
		logs.Warn(ctx, funcName, "mediasoup Router 创建失败，执行补偿回滚",
			zap.String("room_code", code), zap.Int64("host_id", hostID), zap.Error(mediaErr))

		if participant != nil {
			if _, leaveErr := s.participantDAO.LeaveRoom(ctx, room.ID, hostID, constants.MeetingLeftReasonSelf); leaveErr != nil {
				logs.Warn(ctx, funcName, "补偿阶段 LeaveRoom 失败（已记录，清理任务会兜底）",
					zap.Int64("room_id", room.ID), zap.Int64("host_id", hostID), zap.Error(leaveErr))
			}
		}
		if _, markErr := s.roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonSystemError, time.Now()); markErr != nil {
			logs.Warn(ctx, funcName, "补偿阶段 MarkEnded 失败（已记录，清理任务会兜底）",
				zap.Int64("room_id", room.ID), zap.Error(markErr))
		}

		err = ErrMediaServiceUnavailable
		return nil, nil, "", err
	}

	logs.Info(ctx, funcName, "会议创建成功",
		zap.String("room_code", code), zap.Int64("host_id", hostID), zap.String("router_id", routerID))
	return room, participant, routerID, nil
}

// GetRoomByCode 获取会议详情（当前用户必须为活跃参会者）
func (s *MeetingService) GetRoomByCode(ctx context.Context, userID int64, code string) (*model.MeetingRoom, []model.MeetingParticipant, int64, error) {
	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return nil, nil, 0, err
	}
	if room == nil {
		return nil, nil, 0, ErrMeetingNotFound
	}
	if _, err := s.assertIsActiveParticipant(ctx, room.ID, userID); err != nil {
		return nil, nil, 0, err
	}
	participants, err := s.participantDAO.ListByRoom(ctx, room.ID)
	if err != nil {
		return nil, nil, 0, err
	}
	activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
	if err != nil {
		return nil, nil, 0, err
	}
	return room, participants, activeCount, nil
}

func (s *MeetingService) GetRoomForInternal(ctx context.Context, code string) (*model.MeetingRoom, []model.MeetingParticipant, int64, error) {
	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return nil, nil, 0, err
	}
	if room == nil {
		return nil, nil, 0, ErrMeetingNotFound
	}
	participants, err := s.participantDAO.ListByRoom(ctx, room.ID)
	if err != nil {
		return nil, nil, 0, err
	}
	activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
	if err != nil {
		return nil, nil, 0, err
	}
	return room, participants, activeCount, nil
}

func (s *MeetingService) EndRoomForInternal(ctx context.Context, code string) error {
	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return err
	}
	if room == nil {
		return ErrMeetingNotFound
	}
	if room.Status == constants.MeetingStatusEnded {
		return nil
	}
	return s.EndRoom(ctx, room.HostID, code)
}

// JoinRoom 加入会议
// 校验顺序：房间存在 → 未结束 → 单点参会 → 密码锁定 → 密码校验 → 容量 → 写 participant → 广播 meeting.member.joined
func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, password string) (*model.MeetingRoom, *model.MeetingParticipant, string, error) {
	funcName := "service.meeting_service.JoinRoom"

	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return nil, nil, "", err
	}
	if room == nil {
		return nil, nil, "", ErrMeetingNotFound
	}
	if room.Status == constants.MeetingStatusEnded {
		return nil, nil, "", ErrMeetingEnded
	}

	if existing, pErr := s.participantDAO.GetByRoomAndUser(ctx, room.ID, userID); pErr != nil {
		return nil, nil, "", pErr
	} else if existing != nil && existing.IsActive() {
		return nil, nil, "", ErrAlreadyInMeeting
	}

	if active, aErr := s.participantDAO.FindActiveByUser(ctx, userID); aErr != nil {
		return nil, nil, "", aErr
	} else if active != nil && active.RoomID != room.ID {
		return nil, nil, "", ErrAlreadyInOtherMeeting
	}

	if room.PasswordHash != nil && *room.PasswordHash != "" {
		lockKey := redisKeyPasswordLockPrefix + code + ":" + strconv.FormatInt(userID, 10)
		if locked, _ := s.redis.Exists(ctx, lockKey).Result(); locked > 0 {
			return nil, nil, "", ErrMeetingPasswordLocked
		}
		if password == "" {
			return nil, nil, "", ErrMeetingPasswordReq
		}
		if !utils.CheckPassword(password, *room.PasswordHash) {
			attemptKey := redisPasswordAttemptPrefix + code + ":" + strconv.FormatInt(userID, 10)
			attempts, _ := s.redis.Incr(ctx, attemptKey).Result()
			if attempts == 1 {
				s.redis.Expire(ctx, attemptKey, time.Duration(constants.MeetingPasswordLockSeconds)*time.Second)
			}
			if attempts >= int64(constants.MeetingPasswordMaxAttempts) {
				s.redis.Set(ctx, lockKey, 1, time.Duration(constants.MeetingPasswordLockSeconds)*time.Second)
				s.redis.Del(ctx, attemptKey)
			}
			return nil, nil, "", ErrMeetingPasswordWrong
		}
		s.redis.Del(ctx, redisPasswordAttemptPrefix+code+":"+strconv.FormatInt(userID, 10))
	}

	participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
	if err != nil {
		return nil, nil, "", err
	}

	// Task 8：空房 TTL 复活
	// 若该房间正处于 empty_ttl 阶段（全员离开后的 5 分钟窗口），新成员加入立即取消销毁
	if s.lifecycleSvc != nil {
		s.lifecycleSvc.CancelEmptyTTL(ctx, code)
	}

	// Task 8：JoinRoom 不再主动调 CreateRouter（Router 在 CreateRoom 时创建、由 HTTPMediaOrchestrator 本地缓存）
	// 从缓存读取 routerID；缺失时（go-service / media-server 重启且 Redis 也丢了 / 历史房间无缓存）走兜底重建：
	// 房间状态仍为 Active，但 mediasoup 侧已无该 Router → 直接 CreateRouter 重新拉起，对 Node 是新房间不会冲突。
	// 失败则与 CreateRoom 保持一致：返回 ErrMediaServiceUnavailable，前端提示重试。
	routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
	if routerID == "" {
		logs.Warn(ctx, funcName, "RouterID 缓存缺失，触发 CreateRouter 兜底重建",
			zap.String("room_code", code))
		newID, mediaErr := s.mediaOrchestrator.CreateRouter(ctx, code)
		if mediaErr != nil {
			logs.Error(ctx, funcName, "兜底 CreateRouter 失败",
				zap.String("room_code", code), zap.Int64("user_id", userID), zap.Error(mediaErr))
			// 已写入 participant 行：补偿离开，避免占用"一人一会议"名额
			if _, leaveErr := s.participantDAO.LeaveRoom(ctx, room.ID, userID, constants.MeetingLeftReasonSelf); leaveErr != nil {
				logs.Warn(ctx, funcName, "兜底失败后 LeaveRoom 失败（清理任务会兜底）",
					zap.Int64("room_id", room.ID), zap.Int64("user_id", userID), zap.Error(leaveErr))
			}
			return nil, nil, "", ErrMediaServiceUnavailable
		}
		routerID = newID
	}

	// 广播 payload 附带 user_name / user_avatar，前端 _onMemberJoined 直接落库，无需二次拉取
	name, avatar := s.resolveUserDisplay(ctx, userID)
	go s.broadcastToActiveParticipants(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberJoined, map[string]interface{}{
		"room_code":   code,
		"user_id":     userID,
		"user_name":   name,
		"user_avatar": avatar,
		"joined_at":   participant.JoinedAt.Format("2006-01-02 15:04:05"),
	}, userID)

	logs.Info(ctx, funcName, "用户加入会议成功", zap.String("room_code", code), zap.Int64("user_id", userID))
	return room, participant, routerID, nil
}

// isCloudLawRoom 判断是否为云律视频咨询房间：host 为云律自动创建的用户（邮箱后缀 @cloud-law.local）。
// 云律房间语义是「发起人(用户)↔接单人(律师)」一对一咨询，发起人挂断即咨询结束。
func (s *MeetingService) isCloudLawRoom(ctx context.Context, hostID int64) bool {
	if s.userResolver == nil || hostID <= 0 {
		return false
	}
	users, err := s.userResolver.GetUsersByIDs(ctx, []int64{hostID})
	if err != nil || len(users) == 0 {
		return false
	}
	return strings.HasSuffix(users[0].Email, "@cloud-law.local")
}

// LeaveRoom 用户主动离会
// 若离开者为 host 且房间内还有其他活跃成员：事务内转让给最早加入者；若为空房：关闭房间（status=Ended, reason=empty_ttl）
// 云律房间例外：发起人(host)离会即结束整场会议（见下方说明）
func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code string) (int, error) {
	funcName := "service.meeting_service.LeaveRoom"

	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return 0, err
	}
	if room == nil {
		return 0, ErrMeetingNotFound
	}
	participant, err := s.assertIsActiveParticipant(ctx, room.ID, userID)
	if err != nil {
		return 0, err
	}

	affected, err := s.participantDAO.LeaveRoom(ctx, room.ID, userID, constants.MeetingLeftReasonSelf)
	if err != nil {
		return 0, err
	}
	if affected == 0 {
		return 0, ErrNotInMeeting
	}

	updated, err := s.participantDAO.GetByRoomAndUser(ctx, room.ID, userID)
	duration := 0
	if err == nil && updated != nil {
		duration = updated.Duration
	}

	actives, err := s.participantDAO.ListActiveByRoom(ctx, room.ID)
	if err != nil {
		return duration, err
	}

	if participant.Role == constants.MeetingRoleHost && len(actives) > 0 {
		// 云律视频咨询：发起人(host)即咨询者，挂断/离会即代表咨询结束，
		// 应直接结束整场会议，而不是把主持人转让给律师让会议继续
		//（否则表现为「用户已挂断、律师端会议仍在、云律 status 一直 accepted」）。
		if s.isCloudLawRoom(ctx, room.HostID) {
			if endErr := s.EndRoomForInternal(ctx, code); endErr != nil {
				logs.Warn(ctx, funcName, "云律发起人离会自动结束会议失败",
					zap.String("room_code", code), zap.Int64("host_id", room.HostID), zap.Error(endErr))
			}
			return duration, nil
		}
		newHost := actives[0]
		// P1-1：TransferHost 内部事务已同时更新 meeting_rooms.host_id，不再需要单独 UpdateHost
		if txErr := s.participantDAO.TransferHost(ctx, room.ID, userID, newHost.UserID); txErr != nil {
			logs.Warn(ctx, funcName, "主持人自动转让失败", zap.Error(txErr))
		} else {
			go s.broadcastToActiveParticipants(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventHostChanged, map[string]interface{}{
				"room_code":    code,
				"old_host_id":  userID,
				"new_host_id":  newHost.UserID,
				"auto_reason":  "host_left",
			})
		}
	}

	// Task 8：全员离会改走生命周期状态机（空房 TTL），不再立即销毁房间
	// 在 TTL 窗口内如有新成员加入，房间会被 CancelEmptyTTL 复活；TTL 到期由 HandleEmptyRoomExpired 兜底销毁
	if len(actives) == 0 && s.lifecycleSvc != nil {
		s.lifecycleSvc.OnAllMembersLeft(ctx, code)
	}

	go s.broadcastToActiveParticipants(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberLeft, map[string]interface{}{
		"room_code": code,
		"user_id":   userID,
		"reason":    constants.MeetingLeftReasonSelf,
	}, userID)

	logs.Info(ctx, funcName, "用户离会成功",
		zap.String("room_code", code), zap.Int64("user_id", userID), zap.Int("duration", duration))
	return duration, nil
}

// EndRoom 主持人结束会议
// 将房间 status=Ended + reason=host_ended + 所有活跃成员 LeaveAllActive + 广播 meeting.room.ended + 关闭 mediasoup Router
// EndRoom 主持人主动结束会议
// Task 16 P1-4 强化为行锁 + 单事务：
//  1. 事务内 SELECT meeting_rooms FOR UPDATE 行锁，避免与并发 JoinRoom 的 status 竞争
//     （旧版 JoinRoom 在乐观读 status=active 之后才写 participant 行，可能出现
//     "JoinRoom 刚读 status=active → EndRoom 改 status=ended + LeaveAll → JoinRoom 写入活跃 participant"
//     的僵尸参与者现象，导致用户被卡在"我在会议中"的状态但房间已结束）
//  2. 事务内依序执行 状态校验 / 活跃快照 / MarkEnded / LeaveAllActive，全部成功才提交
//  3. 事务成功后再做 mediasoup CloseRouter 与 WS 广播（这些是外部 IO，不应拉长锁持有时间）
func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string) error {
	funcName := "service.meeting_service.EndRoom"

	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return err
	}
	if room == nil {
		return ErrMeetingNotFound
	}
	if room.Status == constants.MeetingStatusEnded {
		return ErrMeetingEnded
	}
	if err := s.assertIsHost(ctx, room, userID); err != nil {
		return err
	}

	var (
		activesBefore []model.MeetingParticipant
		endedAt       = time.Now()
	)

	txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		roomDAO := s.roomDAO.WithTx(tx)
		participantDAO := s.participantDAO.WithTx(tx)

		// 行锁：事务内持有 meeting_rooms 行级 X 锁，直到事务结束才释放
		locked, err := roomDAO.GetByIDForUpdate(ctx, room.ID)
		if err != nil {
			return err
		}
		if locked == nil {
			return ErrMeetingNotFound
		}
		// 行锁之后复检 status，避免"同时多个 host 点结束"时第二请求重复执行
		if locked.Status == constants.MeetingStatusEnded {
			return ErrMeetingEnded
		}

		snapshot, err := participantDAO.ListActiveByRoom(ctx, room.ID)
		if err != nil {
			return err
		}
		activesBefore = snapshot

		if _, err := roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonHostEnded, endedAt); err != nil {
			return err
		}
		if _, err := participantDAO.LeaveAllActive(ctx, room.ID, constants.MeetingLeftReasonHostEnd); err != nil {
			return err
		}
		return nil
	})
	if txErr != nil {
		if errors.Is(txErr, ErrMeetingEnded) || errors.Is(txErr, ErrMeetingNotFound) {
			return txErr
		}
		logs.Error(ctx, funcName, "结束会议事务失败",
			zap.String("room_code", code), zap.Int64("room_id", room.ID), zap.Error(txErr))
		return txErr
	}

	payload := map[string]interface{}{
		"room_code":    code,
		"ended_reason": constants.MeetingEndedReasonHostEnded,
		"ended_at":     endedAt.Format("2006-01-02 15:04:05"),
	}
	// activesBefore 已经是结束前的活跃成员快照，此时 participantDAO.ListActiveByRoom 查会返回空集合
	// 因此使用 broadcaster.PublishToUser 逐人定向推送（而非 BroadcastToMeeting 基于当前状态查库）
	for _, p := range activesBefore {
		_ = s.broadcaster.PublishToUser(ctx, p.UserID, constants.MeetingWSEventRoomEnded, payload)
	}

	// Phase B：先停掉活跃录制，再关 Router；否则 CloseRouter 会触发录制 Consumer 异常退出，
	// 录制文件可能丢失最后几秒。stopper 同步执行（含 ffmpeg 优雅退出 + MinIO 上传），失败仅 Warn 不阻断
	if s.recordingStopper != nil {
		s.recordingStopper.StopActiveForEndRoom(ctx, room)
	}

	if err := s.mediaOrchestrator.CloseRouter(ctx, code); err != nil {
		logs.Warn(ctx, funcName, "关闭 mediasoup Router 失败", zap.Error(err))
	}

	// Task 16 资源清理专项：EndRoom 场景下 WS 断开钩子尚未触发（用户客户端可能仍在会议页），
	// 需主动清每个活跃成员在 Redis 的资源追踪集合 + 音视频状态 Hash，避免短期反复开会累积垃圾条目
	userIDs := make([]int64, 0, len(activesBefore))
	for _, p := range activesBefore {
		userIDs = append(userIDs, p.UserID)
	}
	cleanupRoomRedisResidual(ctx, s.redis, code, userIDs)
	// 取消生命周期 timer + 清 host_grace / empty_ttl / handling 锁 key（幂等）
	if s.lifecycleSvc != nil {
		s.lifecycleSvc.OnRoomEnded(ctx, code)
	}

	logs.Info(ctx, funcName, "会议已结束", zap.String("room_code", code), zap.Int64("host_id", userID))
	return nil
}

// ====== 主持人管理 ======

// TransferHost 主持人主动转让
func (s *MeetingService) TransferHost(ctx context.Context, operatorID int64, code string, targetUserID int64) error {
	funcName := "service.meeting_service.TransferHost"

	if operatorID == targetUserID {
		return ErrTransferToSelf
	}

	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return err
	}
	if room == nil {
		return ErrMeetingNotFound
	}
	if room.Status == constants.MeetingStatusEnded {
		return ErrMeetingEnded
	}
	if err := s.assertIsHost(ctx, room, operatorID); err != nil {
		return err
	}
	target, err := s.assertIsActiveParticipant(ctx, room.ID, targetUserID)
	if err != nil {
		if errors.Is(err, ErrNotInMeeting) {
			return ErrTransferTargetInvalid
		}
		return err
	}

	// P1-1：TransferHost 内部事务同时更新 meeting_rooms.host_id，无需单独 UpdateHost
	if err := s.participantDAO.TransferHost(ctx, room.ID, operatorID, target.UserID); err != nil {
		return err
	}

	go s.broadcastToActiveParticipants(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventHostChanged, map[string]interface{}{
		"room_code":   code,
		"old_host_id": operatorID,
		"new_host_id": target.UserID,
		"auto_reason": "manual",
	})

	logs.Info(ctx, funcName, "主持人转让成功",
		zap.String("room_code", code), zap.Int64("old_host", operatorID), zap.Int64("new_host", target.UserID))
	return nil
}

// KickMember 主持人踢出成员
func (s *MeetingService) KickMember(ctx context.Context, operatorID int64, code string, targetUserID int64) error {
	funcName := "service.meeting_service.KickMember"

	if operatorID == targetUserID {
		return ErrKickSelfForbidden
	}
	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return err
	}
	if room == nil {
		return ErrMeetingNotFound
	}
	if room.Status == constants.MeetingStatusEnded {
		return ErrMeetingEnded
	}
	if err := s.assertIsHost(ctx, room, operatorID); err != nil {
		return err
	}
	if _, err := s.assertIsActiveParticipant(ctx, room.ID, targetUserID); err != nil {
		return err
	}

	affected, err := s.participantDAO.LeaveRoom(ctx, room.ID, targetUserID, constants.MeetingLeftReasonKicked)
	if err != nil {
		return err
	}
	if affected == 0 {
		return ErrNotInMeeting
	}

	_ = s.broadcaster.PublishToUser(ctx, targetUserID, constants.MeetingWSEventMemberKicked, map[string]interface{}{
		"room_code": code,
		"user_id":   targetUserID,
		"by":        operatorID,
	})
	go s.broadcastToActiveParticipants(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberLeft, map[string]interface{}{
		"room_code": code,
		"user_id":   targetUserID,
		"reason":    constants.MeetingLeftReasonKicked,
	}, targetUserID)

	logs.Info(ctx, funcName, "踢出成员成功",
		zap.String("room_code", code), zap.Int64("target_user_id", targetUserID))
	return nil
}

// ListMyMeetings 我参与过的会议列表（包含主持）
// 基于 MeetingParticipantDAO.ListByUser 获取参会记录后批量查询对应 Room
// MVP 场景下数据量小（单用户 30 天内会议通常 <50 条），内存合并与状态过滤可接受
// 后续 Phase 2f 观察量级后若有必要再落 DAO 层 JOIN 优化
func (s *MeetingService) ListMyMeetings(ctx context.Context, userID int64, statusFilter *int, beforeID int64, limit int) ([]model.MeetingRoom, bool, error) {
	if limit <= 0 {
		limit = 20
	}
	if limit > 50 {
		limit = 50
	}

	// Task 16 P1-3：改走 participantDAO.ListJoinedRoomsByUser 的 JOIN 查询
	// 旧版两轮 (ListByUser O(N) → ListByIDs O(M))、N 放大 6 倍 → 单次 JOIN DISTINCT O(limit+1)
	rooms, err := s.participantDAO.ListJoinedRoomsByUser(ctx, userID, statusFilter, beforeID, limit+1)
	if err != nil {
		return nil, false, err
	}

	hasMore := false
	if len(rooms) > limit {
		hasMore = true
		rooms = rooms[:limit]
	}
	return rooms, hasMore, nil
}

// ====== 邀请链接与邀请推送 ======

// invitePayload 存入 Redis 的邀请 Token 载荷
type invitePayload struct {
	RoomCode  string `json:"room_code"`
	InviterID int64  `json:"inviter_id"`
	InviteeID int64  `json:"invitee_id"` // 0 表示通用链接（当前 MVP 不用）
	CreatedAt int64  `json:"created_at"`
}

// InviteUsers 主持人或参会者邀请用户
// 对每个 invitee 生成独立 Token 写 Redis（TTL 600s），并通过 NotifyPusher 推送 meeting_invite 通知
// 离线用户走通知入库（NotifyService 内部负责 WS 推送或未读补偿）
func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code string, inviteeIDs []int64) (int, int, error) {
	funcName := "service.meeting_service.InviteUsers"

	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return 0, 0, err
	}
	if room == nil {
		return 0, 0, ErrMeetingNotFound
	}
	if room.Status == constants.MeetingStatusEnded {
		return 0, 0, ErrMeetingEnded
	}
	if _, err := s.assertIsActiveParticipant(ctx, room.ID, inviterID); err != nil {
		return 0, 0, err
	}

	// 邀请人展示信息：extra 里一并带上，前端卡片不必再查 actor_* 字段就能渲染
	inviterName, inviterAvatar := s.resolveUserDisplay(ctx, inviterID)

	pushed := 0
	skipped := 0
	seen := make(map[int64]struct{}, len(inviteeIDs))
	payloads := make([]*notifyService.PushPayload, 0, len(inviteeIDs))

	for _, invitee := range inviteeIDs {
		if invitee <= 0 || invitee == inviterID {
			skipped++
			continue
		}
		if _, dup := seen[invitee]; dup {
			skipped++
			continue
		}
		seen[invitee] = struct{}{}

		if p, _ := s.participantDAO.GetByRoomAndUser(ctx, room.ID, invitee); p != nil && p.IsActive() {
			skipped++
			continue
		}

		token, err := utils.GenerateMeetingInviteToken()
		if err != nil {
			logs.Warn(ctx, funcName, "生成邀请 Token 失败", zap.Error(err))
			skipped++
			continue
		}
		now := time.Now()
		expiredAt := now.Add(time.Duration(constants.MeetingInviteTokenTTL) * time.Second).Unix()
		payload := invitePayload{
			RoomCode:  code,
			InviterID: inviterID,
			InviteeID: invitee,
			CreatedAt: now.Unix(),
		}
		buf, _ := json.Marshal(payload)
		if err := s.redis.Set(ctx, redisKeyInvitePrefix+token, string(buf),
			time.Duration(constants.MeetingInviteTokenTTL)*time.Second).Err(); err != nil {
			logs.Warn(ctx, funcName, "写入邀请 Token 到 Redis 失败", zap.Error(err))
			skipped++
			continue
		}

		roomID := room.ID
		actor := inviterID
		// extra 字段约定（Phase 2e-2 Task 13 / design §10.1）：
		//   room_code / invite_token / room_title / has_password：进会所需参数
		//   inviter_id / inviter_name / inviter_avatar：前端卡片渲染"XX 邀请你加入..."
		//   expired_at：Unix 秒，与 Redis TTL 同步，前端据此把按钮灰显
		extra := map[string]interface{}{
			"room_code":       code,
			"invite_token":    token,
			"room_title":      room.Title,
			"has_password":    room.PasswordHash != nil,
			"inviter_id":      inviterID,
			"inviter_name":    inviterName,
			"inviter_avatar":  inviterAvatar,
			"expired_at":      expiredAt,
		}
		payloads = append(payloads, &notifyService.PushPayload{
			UserID:     invitee,
			Type:       constants.NotifyTypeMeetingInvite,
			Title:      "会议邀请",
			Content:    fmt.Sprintf("邀请你加入会议：%s", room.Title),
			ActorID:    &actor,
			TargetType: "meeting",
			TargetID:   &roomID,
			Extra:      extra,
		})
		pushed++
	}

	if len(payloads) > 0 {
		s.notifyPusher.PushBatch(ctx, payloads)
	}

	logs.Info(ctx, funcName, "会议邀请推送完成",
		zap.String("room_code", code), zap.Int("pushed", pushed), zap.Int("skipped", skipped))
	return pushed, skipped, nil
}

// RedeemInviteToken 点击邀请链接时兑换 Token
// 成功：返回会议号 + 邀请人 ID + 是否有密码，前端据此决定弹出密码输入框并调 JoinRoom
// Token 兑换后不立即删除，保留 60 秒冗余（用户可能刷新页面）；过期走 Redis 原生 TTL
func (s *MeetingService) RedeemInviteToken(ctx context.Context, userID int64, token string) (*dto.RedeemInviteTokenResponse, error) {
	raw, err := s.redis.Get(ctx, redisKeyInvitePrefix+token).Result()
	if errors.Is(err, redis.Nil) {
		return nil, ErrInviteTokenInvalid
	}
	if err != nil {
		return nil, err
	}

	var payload invitePayload
	if err := json.Unmarshal([]byte(raw), &payload); err != nil {
		return nil, ErrInviteTokenInvalid
	}

	// InviteeID > 0 表示定向邀请：仅允许该用户兑换（防止链接转发给非预期收件人）
	if payload.InviteeID > 0 && payload.InviteeID != userID {
		return nil, ErrInviteTokenInvalid
	}

	room, err := s.roomDAO.GetByCode(ctx, payload.RoomCode)
	if err != nil {
		return nil, err
	}
	if room == nil || room.Status == constants.MeetingStatusEnded {
		return nil, ErrInviteTokenInvalid
	}

	return &dto.RedeemInviteTokenResponse{
		RoomCode:    payload.RoomCode,
		InviterID:   payload.InviterID,
		HasPassword: room.PasswordHash != nil && *room.PasswordHash != "",
	}, nil
}

// ====== 会议内聊天 ======

// SendChatMessage 会议内发送文本消息
// Task 16 P2-7：加入服务端校验 + Redis 滑窗限流
//   - 内容去除首尾空白后必须非空
//   - Unicode rune 计数不得超过 MeetingChatMaxContentLen
//   - 单用户单会议 MeetingChatRateLimitWindowS 秒内最多 MeetingChatRateLimitPerMin 条
func (s *MeetingService) SendChatMessage(ctx context.Context, userID int64, code, content string) (*model.MeetingChat, error) {
	funcName := "service.meeting_service.SendChatMessage"

	trimmed := strings.TrimSpace(content)
	if trimmed == "" {
		return nil, ErrChatContentEmpty
	}
	if runeCount := utf8.RuneCountInString(trimmed); runeCount > constants.MeetingChatMaxContentLen {
		logs.Debug(ctx, funcName, "聊天消息超长，拒绝",
			zap.String("room_code", code), zap.Int64("user_id", userID),
			zap.Int("rune_count", runeCount), zap.Int("limit", constants.MeetingChatMaxContentLen))
		return nil, ErrChatContentTooLong
	}

	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return nil, err
	}
	if room == nil {
		return nil, ErrMeetingNotFound
	}
	if room.Status == constants.MeetingStatusEnded {
		return nil, ErrMeetingEnded
	}
	if _, err := s.assertIsActiveParticipant(ctx, room.ID, userID); err != nil {
		return nil, err
	}

	// Redis 滑窗限流（INCR + EXPIRE 组合；首条消息初始化窗口）
	// 失败时仅 Warn 并放行，避免 Redis 抖动影响用户发消息
	rateKey := fmt.Sprintf("%s%s:%d", redisKeyChatRatePrefix, code, userID)
	cnt, rErr := s.redis.Incr(ctx, rateKey).Result()
	if rErr != nil {
		logs.Warn(ctx, funcName, "聊天限流 INCR 失败（放行）",
			zap.String("key", rateKey), zap.Error(rErr))
	} else {
		if cnt == 1 {
			_ = s.redis.Expire(ctx, rateKey, time.Duration(constants.MeetingChatRateLimitWindowS)*time.Second).Err()
		}
		if cnt > int64(constants.MeetingChatRateLimitPerMin) {
			logs.Warn(ctx, funcName, "聊天限流触发",
				zap.String("room_code", code), zap.Int64("user_id", userID),
				zap.Int64("count_in_window", cnt), zap.Int("limit", constants.MeetingChatRateLimitPerMin))
			return nil, ErrChatRateLimited
		}
	}

	chat := &model.MeetingChat{
		RoomID:  room.ID,
		UserID:  userID,
		Content: trimmed,
	}
	if err := s.chatDAO.Create(ctx, chat); err != nil {
		return nil, err
	}

	// 附带 user_name / user_avatar，前端聊天面板无需额外拉取
	userName, userAvatar := s.resolveUserDisplay(ctx, userID)

	go s.broadcastToActiveParticipants(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventChatMessage, map[string]interface{}{
		"room_code":   code,
		"message_id":  chat.ID,
		"user_id":     userID,
		"user_name":   userName,
		"user_avatar": userAvatar,
		"content":     trimmed,
		"created_at":  chat.CreatedAt.Format("2006-01-02 15:04:05"),
	}, userID)

	logs.Debug(ctx, funcName, "会议聊天已发送",
		zap.String("room_code", code), zap.Int64("user_id", userID), zap.Int64("message_id", chat.ID))
	return chat, nil
}

// ListChatMessages 加载会议聊天历史（游标分页，按 created_at ASC 升序返回）
func (s *MeetingService) ListChatMessages(ctx context.Context, userID int64, code string, beforeID int64, limit int) ([]model.MeetingChat, bool, error) {
	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return nil, false, err
	}
	if room == nil {
		return nil, false, ErrMeetingNotFound
	}
	if _, err := s.assertIsActiveParticipant(ctx, room.ID, userID); err != nil {
		return nil, false, err
	}

	if limit <= 0 {
		limit = 30
	}
	if limit > 100 {
		limit = 100
	}

	// Task 16 P1-2：改走 chatDAO.ListByRoomBefore（反向游标），避免 service 层直接拼 SQL 破坏分层
	chats, err := s.chatDAO.ListByRoomBefore(ctx, room.ID, beforeID, limit+1)
	if err != nil {
		return nil, false, err
	}

	hasMore := false
	if len(chats) > limit {
		hasMore = true
		chats = chats[:limit]
	}
	return chats, hasMore, nil
}

// ResolveRouterInfo 代理 mediaOrchestrator.ResolveRouterInfo
// Task 9 引入：JoinRoom 响应需把 rtpCapabilities 一并回给前端，避免前端再拉一次
// 返回 (routerID, rtpCapabilities, ok)；未命中缓存返回 ("", nil, false)
func (s *MeetingService) ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool) {
	if s.mediaOrchestrator == nil {
		return "", nil, false
	}
	return s.mediaOrchestrator.ResolveRouterInfo(roomCode)
}
