package service

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

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

// MeetingRecordingService 会议录制业务服务（Phase B 引入）
//
// 职责边界：
//   - 鉴权（仅 host 可启停）+ 状态机（DB 表 meeting_recordings）
//   - 选定 host 的活跃 Producer 列表 → 委托给 MediaOrchestrator.StartRecording
//   - 录制结束后从 media-server 本地拉取 mp4 → 上传到 MinIO → 标记 ready
//   - 通过 MeetingBroadcaster 把 started / stopped 事件广播给会议内全员
//
// 设计取舍（Phase B1）：
//   - StopRecording 内同步执行 ffmpeg 停止 + MinIO 上传：录制时长一般 ≤ 30 分钟、单文件 ≤ 数百 MB，
//     在 HTTP 处理协程内同步上传可接受；后续若需支持长会议再切换为后台 worker
//   - 文件路径规则：recordings/{roomCode}/{recordingID}-{remoteID}.mp4
//     便于按会议聚合 + 即使 DB 与对象存储错位也能从对象 Key 反查
//   - 复用全局 MinIO Bucket（与图片/语音同 bucket，public-read）；后续可拆分专用 bucket
type MeetingRecordingService struct {
	roomDAO        *dao.MeetingRoomDAO
	participantDAO *dao.MeetingParticipantDAO
	recordingDAO   *dao.MeetingRecordingDAO

	redis *redis.Client

	broadcaster       *MeetingBroadcaster
	mediaOrchestrator MediaOrchestrator

	minioClient *minio.Client
	minioCfg    *config.MinioConfig
	meetingCfg  *config.MeetingConfig
}

// NewMeetingRecordingService Wire Provider
func NewMeetingRecordingService(
	roomDAO *dao.MeetingRoomDAO,
	participantDAO *dao.MeetingParticipantDAO,
	recordingDAO *dao.MeetingRecordingDAO,
	rdb *redis.Client,
	broadcaster *MeetingBroadcaster,
	mediaOrchestrator MediaOrchestrator,
	minioClient *minio.Client,
	minioCfg *config.MinioConfig,
	meetingCfg *config.MeetingConfig,
) *MeetingRecordingService {
	return &MeetingRecordingService{
		roomDAO:           roomDAO,
		participantDAO:    participantDAO,
		recordingDAO:      recordingDAO,
		redis:             rdb,
		broadcaster:       broadcaster,
		mediaOrchestrator: mediaOrchestrator,
		minioClient:       minioClient,
		minioCfg:          minioCfg,
		meetingCfg:        meetingCfg,
	}
}

// 录制相关领域错误（与 MeetingService 错误并列，使用同一 errors.Is 链）
var (
	// ErrRecordingAlreadyActive 同一会议已有活跃录制（recording / uploading），禁止再次启动
	ErrRecordingAlreadyActive = errors.New("当前会议已有正在进行的录制")
	// ErrRecordingNoProducers host 在 Redis 资源追踪集合内找不到任何 producer
	// 通常意味着 host 尚未开启麦克风/摄像头。规则：必须至少有 1 个 producer 才允许启动录制
	ErrRecordingNoProducers = errors.New("无可录制的音视频流，请先开启麦克风或摄像头")
	// ErrRecordingNotFound 指定 recordingID 不存在 / 已删除
	ErrRecordingNotFound = errors.New("录制记录不存在")
	// ErrRecordingNotStoppable 录制不在 recording 状态，无法停止
	ErrRecordingNotStoppable = errors.New("录制已结束或正在收尾，无需重复操作")
)

// StartRecording 启动录制（host）
//
// 流程：
//  1. 鉴权：room 存在、Active、调用方=host
//  2. 防重：room 内不能已有活跃录制
//  3. 收集 host 自己持有的全部 Producer ID（来自 resourceTrackKey 的 set 成员）
//  4. mediaOrchestrator.StartRecording → 拿到 recordingId 与本地 outputPath
//  5. DB 落 recording 记录（写入 RemoteID + OutputPath via FailureReason 字段暂存）
//  6. 异步广播 meeting.recording.started 给所有活跃成员
//
// 返回：刚创建的 MeetingRecording 记录（业务主键 ID 即"录制 ID"）
func (s *MeetingRecordingService) StartRecording(ctx context.Context, userID int64, code string) (*model.MeetingRecording, error) {
	funcName := "service.meeting_recording_service.StartRecording"

	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 room.HostID != userID {
		return nil, ErrNotMeetingHost
	}

	// 防重：同一会议同时仅允许 1 条活跃录制
	if active, err := s.recordingDAO.GetActiveByRoom(ctx, room.ID); err != nil {
		return nil, err
	} else if active != nil {
		return nil, ErrRecordingAlreadyActive
	}

	// 收集 host 当前活跃的 Producer ID
	producerIDs, err := s.listHostProducerIDs(ctx, code, userID)
	if err != nil {
		return nil, err
	}
	if len(producerIDs) == 0 {
		return nil, ErrRecordingNoProducers
	}

	// 反查 Router；缺失时（go-service / media-server 重启且 Redis 也丢了）触发 CreateRouter 兜底重建
	// 与 JoinRoom 兜底保持一致语义：房间未结束 → mediasoup 侧没了就重建，对 Node 是新房间无冲突
	routerID, ok := s.mediaOrchestrator.ResolveRouterID(code)
	if !ok || 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.Error(mediaErr))
			return nil, ErrMediaServiceUnavailable
		}
		routerID = newID
	}

	// 调 media-server
	resp, err := s.mediaOrchestrator.StartRecording(ctx, &StartRecordingReq{
		RouterID:    routerID,
		RoomCode:    code,
		ProducerIDs: producerIDs,
	})
	if err != nil {
		logs.Error(ctx, funcName, "media-server 启动录制失败",
			zap.String("room_code", code), zap.Int64("user_id", userID), zap.Error(err))
		return nil, err
	}

	// 写 DB（使用 FailureReason 字段暂存 outputPath，stop 阶段读出来上传 MinIO 后清空）
	// 这样不需要为 outputPath 加额外列：Phase B1 取舍，stop 时 MarkUploading 会覆盖该字段
	now := time.Now()
	rec := &model.MeetingRecording{
		RoomID:        room.ID,
		RoomCode:      code,
		StartedBy:     userID,
		Status:        model.MeetingRecordingStatusRecording,
		RemoteID:      resp.RecordingID,
		FailureReason: resp.OutputPath, // 临时占位，stop 时清空
		StartedAt:     now,
	}
	if err := s.recordingDAO.Create(ctx, rec); err != nil {
		// DB 写失败必须回滚 media-server 上的录制，否则 ffmpeg 会一直写本地磁盘
		logs.Error(ctx, funcName, "录制 DB 写入失败，回滚 media-server",
			zap.String("remote_id", resp.RecordingID), zap.Error(err))
		if _, stopErr := s.mediaOrchestrator.StopRecording(ctx, resp.RecordingID); stopErr != nil {
			logs.Warn(ctx, funcName, "回滚停止录制失败（孤儿 ffmpeg 进程将由 media-server 自身 TTL 兜底）",
				zap.String("remote_id", resp.RecordingID), zap.Error(stopErr))
		}
		return nil, err
	}

	// WS 广播（detach context 避免 HTTP 取消导致广播半成品）
	go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventRecordingStarted, map[string]interface{}{
		"recording_id": rec.ID,
		"started_by":   userID,
		"started_at":   rec.StartedAt.Unix(),
	})

	logs.Info(ctx, funcName, "录制已启动",
		zap.Int64("recording_id", rec.ID),
		zap.String("room_code", code),
		zap.Int64("user_id", userID),
		zap.Int("producer_count", len(producerIDs)))
	return rec, nil
}

// StopRecording 停止录制（host）
//
// 流程：
//  1. 鉴权 + 状态校验：recording 必须存在、status=recording、调用方是 host（容忍 EndRoom 路径以系统 userID 调用）
//  2. media-server stop（拿到本地 outputPath / size / duration / exitCode）
//  3. DB MarkUploading（status: recording → uploading），同时清空临时 FailureReason
//  4. 上传本地 mp4 到 MinIO（recordings/{roomCode}/{id}-{remoteId}.mp4）
//  5. DB MarkReady + 广播 meeting.recording.stopped(status=ready, file_url)
//  6. 任一步失败：MarkFailed + 广播 stopped(status=failed, reason)
func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int64, code string, recordingID int64) (*model.MeetingRecording, error) {
	funcName := "service.meeting_recording_service.StopRecording"

	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return nil, err
	}
	if room == nil {
		return nil, ErrMeetingNotFound
	}
	if room.HostID != userID {
		return nil, ErrNotMeetingHost
	}

	rec, err := s.recordingDAO.GetByID(ctx, recordingID)
	if err != nil {
		return nil, err
	}
	if rec == nil || rec.RoomID != room.ID {
		return nil, ErrRecordingNotFound
	}
	if rec.Status != model.MeetingRecordingStatusRecording {
		return nil, ErrRecordingNotStoppable
	}

	// 调 media-server stop（404 视为已停止 → 用本地 DB 字段降级处理）
	stopResp, stopErr := s.mediaOrchestrator.StopRecording(ctx, rec.RemoteID)
	if stopErr != nil {
		if !errors.Is(stopErr, ErrMediaResourceNotFound) {
			logs.Error(ctx, funcName, "media-server 停止录制失败",
				zap.Int64("recording_id", recordingID), zap.String("remote_id", rec.RemoteID), zap.Error(stopErr))
			s.markFailedAndBroadcast(ctx, room.ID, rec, "media-server 停止录制失败")
			return nil, stopErr
		}
		logs.Warn(ctx, funcName, "media-server 录制已不存在，按本地状态降级处理",
			zap.Int64("recording_id", recordingID), zap.String("remote_id", rec.RemoteID))
		stopResp = &StopRecordingResp{RecordingID: rec.RemoteID, OutputPath: rec.FailureReason}
	}

	// 转入 uploading 状态
	durationSec := int(stopResp.DurationMS / 1000)
	if rows, err := s.recordingDAO.MarkUploading(ctx, rec.ID, time.Now(), durationSec); err != nil {
		logs.Error(ctx, funcName, "标记 uploading 失败", zap.Int64("recording_id", rec.ID), zap.Error(err))
		s.markFailedAndBroadcast(ctx, room.ID, rec, "数据库状态切换失败")
		return nil, err
	} else if rows == 0 {
		logs.Warn(ctx, funcName, "标记 uploading 未更新任何记录",
			zap.Int64("recording_id", rec.ID), zap.String("status", string(rec.Status)))
		return nil, ErrRecordingNotStoppable
	}
	finalizeCtx := logs.DetachContext(ctx)

	outputPath := stopResp.OutputPath
	if outputPath == "" {
		// 容忍 media-server 旧版本 / 404 降级：用 DB 暂存的本地路径
		outputPath = rec.FailureReason
	}

	// 方案 2：媒体管道仍出 .webm（VP8/Opus），go-service 同步用 ffmpeg 转码到 MP4(H264/AAC)
	// 转码成功 → 上传 MP4，浏览器/STT 通用兼容、体积更小
	// 转码失败 → 回退上传原始 WebM，保证用户拿得到回放（不让转码引入回放黑洞）
	uploadPath, uploadExt, uploadContentType, transcodedPath := s.prepareUploadArtifact(finalizeCtx, outputPath, rec.ID)
	objectKey := fmt.Sprintf("recordings/%s/%d-%s%s", strings.ToLower(code), rec.ID, rec.RemoteID, uploadExt)
	fileURL, sizeBytes, uploadErr := s.uploadRecording(finalizeCtx, uploadPath, objectKey, uploadContentType)
	if uploadErr != nil {
		logs.Error(finalizeCtx, funcName, "录制文件上传 MinIO 失败",
			zap.Int64("recording_id", rec.ID), zap.String("local_path", uploadPath), zap.Error(uploadErr))
		// 转码产物上传失败时残留 mp4 也清掉，避免下次轮询误判
		if transcodedPath != "" {
			_ = os.Remove(transcodedPath)
		}
		s.markFailedAndBroadcast(finalizeCtx, room.ID, rec, "录制文件上传失败")
		return nil, uploadErr
	}

	if rows, err := s.recordingDAO.MarkReady(finalizeCtx, rec.ID, fileURL, objectKey, sizeBytes); err != nil {
		logs.Error(finalizeCtx, funcName, "标记 ready 失败", zap.Int64("recording_id", rec.ID), zap.Error(err))
		s.markFailedAndBroadcast(finalizeCtx, room.ID, rec, "数据库状态切换失败（ready）")
		return nil, err
	} else if rows == 0 {
		err := fmt.Errorf("标记 ready 未更新任何记录")
		logs.Error(finalizeCtx, funcName, "标记 ready 未更新任何记录", zap.Int64("recording_id", rec.ID))
		s.markFailedAndBroadcast(finalizeCtx, room.ID, rec, "数据库状态切换失败（ready）")
		return nil, err
	}

	// 重新拉一份最终态
	final, _ := s.recordingDAO.GetByID(finalizeCtx, rec.ID)
	if final == nil {
		final = rec
		final.Status = model.MeetingRecordingStatusReady
		final.FileURL = fileURL
		final.FileObject = objectKey
		final.SizeBytes = sizeBytes
		final.DurationSec = durationSec
	}

	// 上传成功后再尝试删除本地文件（失败仅 Warn，不影响业务）
	for _, p := range []string{outputPath, transcodedPath} {
		if p == "" {
			continue
		}
		if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
			logs.Warn(finalizeCtx, funcName, "删除本地录制文件失败（忽略）",
				zap.String("local_path", p), zap.Error(err))
		}
	}

	// 广播 stopped(ready)
	go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventRecordingStopped, map[string]interface{}{
		"recording_id": final.ID,
		"status":       model.MeetingRecordingStatusReady,
		"file_url":     fileURL,
		"size_bytes":   sizeBytes,
		"duration_sec": durationSec,
	})

	logs.Info(ctx, funcName, "录制已结束并完成上传",
		zap.Int64("recording_id", final.ID),
		zap.String("room_code", code),
		zap.Int64("size_bytes", sizeBytes),
		zap.Int("duration_sec", durationSec))
	return final, nil
}

// ListRecordings 拉取某会议的全部录制记录（倒序）
// 仅会议参与者可见；前端用于"录制历史"入口
func (s *MeetingRecordingService) ListRecordings(ctx context.Context, userID int64, code string) ([]model.MeetingRecording, error) {
	room, err := s.roomDAO.GetByCode(ctx, code)
	if err != nil {
		return nil, err
	}
	if room == nil {
		return nil, ErrMeetingNotFound
	}

	// host 总是可见；其他人需在 participants 表里出现过即可（包括已离会）
	if room.HostID != userID {
		p, err := s.participantDAO.GetByRoomAndUser(ctx, room.ID, userID)
		if err != nil {
			return nil, err
		}
		if p == nil {
			return nil, ErrNotInMeeting
		}
	}

	return s.recordingDAO.ListByRoom(ctx, room.ID)
}

// ForceStopByAdmin 管理后台强制结束指定录制（兜底场景）
//
// 触发场景：
//   - host 离线/失联且会议仍在进行，但不希望长时间空录占用磁盘
//   - ffmpeg 异常退出但失败 webhook 未生效（INTERNAL_WEBHOOK_SECRET 未配/网络不通）
//   - 任意 admin 主动介入收尾
//
// 行为：
//   - 加载录制 → 加载所在房间（拿 HostID 满足 StopRecording 内部 host 校验）
//   - 终态（ready/failed/uploading）幂等返回当前记录，不重复 stop
//   - 内部直接复用 StopRecording 走完整 stop + 上传 MinIO + DB 状态机
//   - media-server 返回 404 时 StopRecording 已经会降级处理（用本地 outputPath 上传残片）
func (s *MeetingRecordingService) ForceStopByAdmin(ctx context.Context, recordingID int64) (*model.MeetingRecording, error) {
	funcName := "service.meeting_recording_service.ForceStopByAdmin"

	rec, err := s.recordingDAO.GetByID(ctx, recordingID)
	if err != nil {
		return nil, err
	}
	if rec == nil {
		return nil, ErrRecordingNotFound
	}
	// 终态幂等：admin 重复点击不应触发二次 stop
	if rec.Status != model.MeetingRecordingStatusRecording {
		logs.Info(ctx, funcName, "录制已非 recording 状态，直接返回",
			zap.Int64("recording_id", rec.ID), zap.String("status", string(rec.Status)))
		return rec, nil
	}

	room, err := s.roomDAO.GetByCode(ctx, rec.RoomCode)
	if err != nil {
		return nil, err
	}
	if room == nil {
		return nil, ErrMeetingNotFound
	}

	logs.Info(ctx, funcName, "管理员强制结束录制",
		zap.Int64("recording_id", rec.ID), zap.String("room_code", rec.RoomCode))
	stopped, stopErr := s.StopRecording(ctx, room.HostID, rec.RoomCode, rec.ID)
	if stopErr == nil {
		return stopped, nil
	}

	// admin 强制结束的语义是"让录制脱离 recording 状态"，而 StopRecording 内部
	// 在 media-server stop / MinIO 上传失败时已经调过 markFailedAndBroadcast：
	// DB 此刻多半已经是 failed（或 ready，极端竞态）。这种情况下应吞掉错误返回最新记录，
	// 否则 admin 会看到 500 但 UI 刷新发现录制已成终态，体验割裂。
	latest, lookupErr := s.recordingDAO.GetByID(ctx, recordingID)
	if lookupErr != nil {
		logs.Warn(ctx, funcName, "StopRecording 失败后回查录制记录失败，原始错误透传",
			zap.Int64("recording_id", recordingID), zap.Error(lookupErr))
		return nil, stopErr
	}
	if latest != nil && latest.Status != model.MeetingRecordingStatusRecording {
		logs.Warn(ctx, funcName, "StopRecording 报错但录制已进入终态，按成功返回",
			zap.Int64("recording_id", recordingID),
			zap.String("status", string(latest.Status)),
			zap.Error(stopErr))
		return latest, nil
	}
	return nil, stopErr
}

// StopActiveForEndRoom 在 EndRoom / EmptyTTL 路径上由系统强制停止当前活跃录制
//
// 与对外 StopRecording 不同：
//   - 跳过 host 校验（调用方是系统）
//   - 失败仅 Warn 不阻断会议结束流程
//   - 仍然走完整的 media-server stop + MinIO 上传 + DB 状态机
func (s *MeetingRecordingService) StopActiveForEndRoom(ctx context.Context, room *model.MeetingRoom) {
	funcName := "service.meeting_recording_service.StopActiveForEndRoom"
	if room == nil {
		return
	}
	active, err := s.recordingDAO.GetActiveByRoom(ctx, room.ID)
	if err != nil {
		logs.Warn(ctx, funcName, "查询活跃录制失败（忽略）",
			zap.Int64("room_id", room.ID), zap.Error(err))
		return
	}
	if active == nil || active.Status != model.MeetingRecordingStatusRecording {
		return
	}
	if _, err := s.StopRecording(ctx, room.HostID, room.RoomCode, active.ID); err != nil {
		logs.Warn(ctx, funcName, "EndRoom 路径停止录制失败（已记录，定时任务会兜底）",
			zap.Int64("recording_id", active.ID), zap.Error(err))
	}
}

// ====== 内部工具 ======

// listHostProducerIDs 从 host 的资源追踪 set 中取出全部 "producer:<id>" 的 id 部分
//
// 选取规则（Phase B1）：
//   - 录 host 自己持有的全部 Producer（含麦克风、摄像头、屏幕共享）
//   - 不包含 transport / consumer
//   - 数组顺序无关：media-server 内部会自动按 kind 处理
func (s *MeetingRecordingService) listHostProducerIDs(ctx context.Context, roomCode string, hostID int64) ([]string, error) {
	key := resourceTrackKey(roomCode, hostID)
	members, err := s.redis.SMembers(ctx, key).Result()
	if err != nil {
		return nil, err
	}
	out := make([]string, 0, len(members))
	for _, m := range members {
		if id, ok := strings.CutPrefix(m, "producer:"); ok && id != "" {
			out = append(out, id)
		}
	}
	return out, nil
}

// uploadRecording 把本地录制文件上传到 MinIO，返回可访问 URL 与文件大小
// contentType 由调用方根据实际产物（mp4 / webm）传入，避免错误的 Content-Type 让浏览器拒播
func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath, objectKey, contentType string) (string, int64, error) {
	if localPath == "" {
		return "", 0, fmt.Errorf("本地录制文件路径为空")
	}
	stat, err := os.Stat(localPath)
	if err != nil {
		return "", 0, fmt.Errorf("stat 本地录制文件失败: %w", err)
	}
	if stat.Size() == 0 {
		return "", 0, fmt.Errorf("本地录制文件为空（0 byte），疑似 ffmpeg 未写入任何数据")
	}

	f, err := os.Open(filepath.Clean(localPath))
	if err != nil {
		return "", 0, fmt.Errorf("打开本地录制文件失败: %w", err)
	}
	defer f.Close()

	if contentType == "" {
		contentType = "application/octet-stream"
	}
	if _, err := s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectKey, f, stat.Size(), minio.PutObjectOptions{
		ContentType: contentType,
	}); err != nil {
		return "", 0, fmt.Errorf("PutObject 失败: %w", err)
	}

	url := s.buildRecordingURL(objectKey)
	return url, stat.Size(), nil
}

func (s *MeetingRecordingService) buildRecordingURL(objectKey string) string {
	return storage.BuildObjectURL(s.minioCfg, objectKey)
}

// prepareUploadArtifact 决定最终上传到 MinIO 的产物
//
// 方案 2 同步转码：
//   - 输入是 media-server 写出的 .webm（VP8/Opus）
//   - 优先 ffmpeg 同步转 .mp4（H264/AAC + faststart），用 MP4 上传，体积更小、回放兼容性更好
//   - 转码失败（ffmpeg 不存在 / 退出非零 / 产物为空）→ 回退原 .webm，保证回放可用
//
// 返回：
//   - uploadPath     实际要上传的本地路径
//   - uploadExt      对应对象 Key 后缀（".mp4" 或 ".webm"），与文件容器一致
//   - contentType    MinIO Content-Type
//   - transcodedPath 转码产物的本地路径（成功才非空），用于上传后清理
func (s *MeetingRecordingService) prepareUploadArtifact(ctx context.Context, webmPath string, recordingID int64) (uploadPath, uploadExt, contentType, transcodedPath string) {
	funcName := "service.meeting_recording_service.prepareUploadArtifact"
	if webmPath == "" {
		return webmPath, ".webm", "video/webm", ""
	}
	if s.meetingCfg == nil || !s.meetingCfg.RecordingTranscodeEnabled {
		logs.Info(ctx, funcName, "录制转码未启用，直接上传 webm",
			zap.Int64("recording_id", recordingID), zap.String("webm_path", webmPath))
		return webmPath, ".webm", "video/webm", ""
	}

	mp4Path, err := s.transcodeWebmToMp4(ctx, webmPath)
	if err != nil {
		logs.Warn(ctx, funcName, "WebM→MP4 转码失败，回退上传原始 webm",
			zap.Int64("recording_id", recordingID), zap.String("webm_path", webmPath), zap.Error(err))
		return webmPath, ".webm", "video/webm", ""
	}
	webmStat, webmStatErr := os.Stat(webmPath)
	mp4Stat, mp4StatErr := os.Stat(mp4Path)
	if webmStatErr == nil && mp4StatErr == nil {
		if mp4Stat.Size() >= webmStat.Size() {
			logs.Warn(ctx, funcName, "MP4 转码产物未变小，回退上传原始 webm",
				zap.Int64("recording_id", recordingID),
				zap.String("webm_path", webmPath),
				zap.Int64("webm_size_bytes", webmStat.Size()),
				zap.String("mp4_path", mp4Path),
				zap.Int64("mp4_size_bytes", mp4Stat.Size()))
			return webmPath, ".webm", "video/webm", mp4Path
		}
		logs.Info(ctx, funcName, "WebM→MP4 转码完成",
			zap.Int64("recording_id", recordingID),
			zap.String("mp4_path", mp4Path),
			zap.Int64("webm_size_bytes", webmStat.Size()),
			zap.Int64("mp4_size_bytes", mp4Stat.Size()))
		return mp4Path, ".mp4", "video/mp4", mp4Path
	}
	logs.Info(ctx, funcName, "WebM→MP4 转码完成",
		zap.Int64("recording_id", recordingID),
		zap.String("mp4_path", mp4Path),
		zap.NamedError("webm_stat_error", webmStatErr),
		zap.NamedError("mp4_stat_error", mp4StatErr))
	return mp4Path, ".mp4", "video/mp4", mp4Path
}

// transcodeWebmToMp4 同步调用 ffmpeg 把本地 webm 转成 mp4(H264/AAC + faststart)
//
// 失败条件（均返回 error，调用方回退原 webm 上传）：
//   - 系统找不到 ffmpeg
//   - ffmpeg 退出码非零（stderr 尾部写入 error 字段便于排障）
//   - 转码产物不存在或大小为 0
//
// 参数选型：
//   - -c:v libx264 -preset medium -crf 30：压缩优先，避免 WebM 转 MP4 后体积变大
//   - -c:a aac -b:a 64k：会议语音场景的体积/清晰度折中
//   - -movflags +faststart：moov 前置，HTTP Range 即点即播
//   - -y：覆盖目标文件，避免重复 stop 时 EEXIST
func (s *MeetingRecordingService) transcodeWebmToMp4(ctx context.Context, webmPath string) (string, error) {
	ffmpegPath := "ffmpeg"
	timeoutSec := 600
	if s.meetingCfg != nil {
		if strings.TrimSpace(s.meetingCfg.RecordingTranscodeFFmpegPath) != "" {
			ffmpegPath = strings.TrimSpace(s.meetingCfg.RecordingTranscodeFFmpegPath)
		}
		if s.meetingCfg.RecordingTranscodeTimeoutSeconds > 0 {
			timeoutSec = s.meetingCfg.RecordingTranscodeTimeoutSeconds
		}
	}
	if _, err := exec.LookPath(ffmpegPath); err != nil {
		return "", fmt.Errorf("ffmpeg 未安装或不在 PATH: %w", err)
	}
	mp4Path := strings.TrimSuffix(webmPath, filepath.Ext(webmPath)) + ".mp4"
	logs.Info(ctx, "service.meeting_recording_service.transcodeWebmToMp4", "开始 WebM→MP4 转码",
		zap.String("ffmpeg_path", ffmpegPath),
		zap.Int("timeout_seconds", timeoutSec),
		zap.String("webm_path", webmPath),
		zap.String("mp4_path", mp4Path))

	// 转码上限：单文件最长容忍 10 分钟 CPU 时间，超时则视为失败回退
	tctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
	defer cancel()

	cmd := exec.CommandContext(tctx, ffmpegPath,
		"-y",
		"-i", webmPath,
		"-c:v", "libx264", "-preset", "medium", "-crf", "30",
		"-c:a", "aac", "-b:a", "64k",
		"-movflags", "+faststart",
		mp4Path,
	)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		// 失败时清理半成品，避免后续误用
		_ = os.Remove(mp4Path)
		tail := stderr.String()
		if len(tail) > 2048 {
			tail = tail[len(tail)-2048:]
		}
		return "", fmt.Errorf("ffmpeg 转码失败: %w | stderr_tail=%s", err, tail)
	}

	stat, err := os.Stat(mp4Path)
	if err != nil {
		return "", fmt.Errorf("转码产物不存在: %w", err)
	}
	if stat.Size() == 0 {
		_ = os.Remove(mp4Path)
		return "", fmt.Errorf("转码产物为 0 字节")
	}
	return mp4Path, nil
}

// GetByRemoteID 反查本地录制记录（按 media-server 的 remoteId 字符串）
// webhook sink 入口前置查询，定位 Go 端主键
func (s *MeetingRecordingService) GetByRemoteID(ctx context.Context, remoteID string) (*model.MeetingRecording, error) {
	return s.recordingDAO.GetByRemoteID(ctx, remoteID)
}

// HandleMediaServerFailure media-server 异常退出回调入口（webhook sink）
//
// 触发条件：media-server 端 ffmpeg 在 Go 主动 Stop 之前自行退出，
// media-server 通过 RECORDING_FAILURE_WEBHOOK_URL 上报到本接口
//
// 行为：
//  1. 按 recordingID 查记录；若已是终态（ready / failed），幂等返回 nil
//  2. 调 markFailedAndBroadcast 落 DB failed + 广播 meeting.recording.stopped(failed)
//
// 上层（webhook controller）需自行鉴权（共享密钥 / 仅本地访问等）
func (s *MeetingRecordingService) HandleMediaServerFailure(ctx context.Context, recordingID int64, reason string) error {
	funcName := "service.meeting_recording_service.HandleMediaServerFailure"
	if recordingID <= 0 {
		return fmt.Errorf("invalid recording id")
	}
	rec, err := s.recordingDAO.GetByID(ctx, recordingID)
	if err != nil {
		logs.Warn(ctx, funcName, "查询录制记录失败",
			zap.Int64("recording_id", recordingID), zap.Error(err))
		return err
	}
	if rec == nil {
		logs.Warn(ctx, funcName, "录制记录不存在，忽略 webhook",
			zap.Int64("recording_id", recordingID))
		return nil
	}
	// 终态幂等
	if rec.Status == model.MeetingRecordingStatusReady || rec.Status == model.MeetingRecordingStatusFailed {
		logs.Info(ctx, funcName, "录制已处终态，忽略 webhook",
			zap.Int64("recording_id", recordingID), zap.String("status", string(rec.Status)))
		return nil
	}
	if reason == "" {
		reason = "media-server 上报：ffmpeg 异常退出"
	}
	s.markFailedAndBroadcast(ctx, rec.RoomID, rec, reason)
	logs.Info(ctx, funcName, "已处理 media-server 失败 webhook",
		zap.Int64("recording_id", recordingID), zap.String("reason", reason))
	return nil
}

// markFailedAndBroadcast 标记失败并广播 stopped(failed)
// 内部容错：DB / 广播任一失败仅 Warn，不二次抛出
func (s *MeetingRecordingService) markFailedAndBroadcast(ctx context.Context, roomID int64, rec *model.MeetingRecording, reason string) {
	if _, err := s.recordingDAO.MarkFailed(ctx, rec.ID, reason); err != nil {
		logs.Warn(ctx, "service.meeting_recording_service.markFailedAndBroadcast",
			"标记 failed 失败（忽略）",
			zap.Int64("recording_id", rec.ID), zap.Error(err))
	}
	go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), roomID, constants.MeetingWSEventRecordingStopped, map[string]interface{}{
		"recording_id":   rec.ID,
		"status":         model.MeetingRecordingStatusFailed,
		"failure_reason": reason,
	})
}
