/**
 * 录制服务：基于 mediasoup PlainTransport + ffmpeg 子进程
 *
 * 录制管线：
 *   ┌─────────────┐  RTP/RTCP    ┌──────────────┐    SDP+stdin    ┌────────┐    fs    ┌─────────┐
 *   │ Producer X  │ ───────────> │ PlainTransport│ ───────────────> │ ffmpeg │ ──────> │ *.webm  │
 *   │ (audio/video)│              │ + Consumer    │ (UDP loopback)   │ (子进程)│         └─────────┘
 *   └─────────────┘              └──────────────┘                  └────────┘
 *
 * 启动顺序（关键）：
 *   1) 为每个 Producer 创建 Consumer 拿到 negotiated rtpParameters
 *      （Consumer 立即 pause，避免 RTP 在 ffmpeg 起来前空打）
 *   2) 给每个 Consumer 分配一对 UDP 端口（ffmpeg 监听端）
 *   3) 写 SDP 文件描述所有流
 *   4) spawn ffmpeg 进程，listen 在那些端口
 *   5) 创建对应 PlainTransport（rtcpMux=false, comedia=false）
 *      并 connect 到 ffmpeg 监听端口
 *   6) resume Consumer，RTP 开始流向 ffmpeg
 *
 * 停止顺序：
 *   1) SIGINT ffmpeg（让 ffmpeg 关闭 mp4 moov box；SIGKILL 会导致文件损坏）
 *   2) 等 ffmpeg 进程退出（超时 5s 兜底 SIGKILL）
 *   3) close Consumer / PlainTransport
 *   4) 释放端口
 *   5) stat 输出文件，返回大小 + 时长
 *
 * 关键约束：
 *   - PlainTransport 用 rtcpMux=false：避免 RTCP 与 RTP 同端口，ffmpeg 解析更可靠
 *   - comedia=false：由 server 主动 connect，IP/Port 是已知的本地回环
 *   - 不开启 SRTP（loopback 不需要加密，降低 CPU 与配置复杂度）
 *   - Consumer 必须设置 paused=true 创建，否则 RTP 会先于 ffmpeg 启动到达
 */
import { spawn, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import type {
  Consumer,
  PlainTransport,
  RtpCodecParameters,
  RtpParameters,
} from 'mediasoup/types';

import { config } from '../config.js';
import { AppError, notFound } from '../utils/errors.js';
import { childLogger } from '../utils/logger.js';
import { allocatePortPair, releasePortPair } from '../utils/port-pool.js';
import { assertTestOnly } from '../utils/test-guard.js';

import { getProducer } from './producer.service.js';
import { getRouter } from './router.service.js';

const log = childLogger({ module: 'services.recording' });

// ============ 类型 ============

interface RecordingTrack {
  /** 来源 producer ID（供日志/排障） */
  producerId: string;
  /** Consumer ID（停止时关闭） */
  consumerId: string;
  /** PlainTransport ID（停止时关闭） */
  transportId: string;
  /** ffmpeg 监听端口（RTP） */
  ffmpegRtpPort: number;
  /** ffmpeg 监听端口（RTCP） */
  ffmpegRtcpPort: number;
  kind: 'audio' | 'video';
  /** payload type，用于 SDP / 跨进程串联 */
  payloadType: number;
  /** Codec mime，例如 audio/opus / video/VP8 */
  codecMime: string;
  /** Codec clock rate */
  clockRate: number;
  /** Codec channels（音频专用） */
  channels?: number;
  /** Codec sdpFmtp 字段（如 minptime=10;useinbandfec=1） */
  sdpFmtp?: string;
}

interface RecordingEntry {
  id: string;
  roomCode: string;
  routerId: string;
  startedAt: number;
  /** 各路 producer 对应的 track */
  tracks: RecordingTrack[];
  /** mediasoup Consumer 句柄（停止时 close） */
  consumers: Consumer[];
  /** mediasoup PlainTransport 句柄（停止时 close） */
  transports: PlainTransport[];
  /** ffmpeg 子进程 */
  ffmpeg: ChildProcess;
  /** SDP 临时文件 */
  sdpFilePath: string;
  /** 输出 webm 文件（VP8 + Opus 原生容器，-c copy 零重编码） */
  outputPath: string;
  /** ffmpeg 退出 promise，stop 时 await */
  ffmpegExited: Promise<void>;
  /** 停止时回填，避免重复 stop */
  stopping: boolean;
  /** 退出码，stop 后填充供 callback 返回 */
  exitCode: number | null;
  /** 失败原因（spawn 阶段或 stop 超时），供 finalize 上报 */
  failureReason: string | null;
  /** ffmpeg 是否在 stop 之前异常退出（watchdog 标记，避免重复触发回调） */
  unexpectedExit: boolean;
}

// ==================== Watchdog / 失败回调 ====================

/**
 * ffmpeg 异常退出回调签名
 *
 * 触发条件：ffmpeg 在 stop 调用之前自行退出（非零退出码或 spawn 失败）
 * 调用方可注册 webhook、上报告警、写入 DB 标记失败等
 *
 * 注意：回调在 ffmpeg 'exit' 事件后异步触发（setImmediate），不会阻塞退出处理；
 * 实现方需保证内部错误不抛出到 Node 主循环，否则会被全局 unhandledRejection 接住。
 */
export type RecordingFailureCallback = (info: {
  recordingId: string;
  roomCode: string;
  routerId: string;
  exitCode: number | null;
  failureReason: string;
}) => void | Promise<void>;

let failureCallback: RecordingFailureCallback | null = null;

/**
 * 注册录制失败回调（覆盖式：只保留最近一次注册）
 * 传 null 取消注册
 */
export function setRecordingFailureCallback(cb: RecordingFailureCallback | null): void {
  failureCallback = cb;
}

/**
 * 默认 webhook 实现：当 process.env.RECORDING_FAILURE_WEBHOOK_URL 设置时，
 * 通过 fetch POST JSON 上报失败。Node 18+ 自带 fetch
 */
async function defaultWebhookCallback(info: Parameters<RecordingFailureCallback>[0]): Promise<void> {
  const url = process.env.RECORDING_FAILURE_WEBHOOK_URL;
  if (!url) return;
  // go-service 端校验 X-Internal-Secret 与其 INTERNAL_WEBHOOK_SECRET 是否一致，
  // 缺失/不匹配会直接 403。生产部署务必两侧使用相同值
  const secret = process.env.INTERNAL_WEBHOOK_SECRET ?? '';
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  if (secret) headers['X-Internal-Secret'] = secret;
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers,
      body: JSON.stringify(info),
    });
    if (!res.ok) {
      log.warn(
        { recordingId: info.recordingId, status: res.status },
        'recording failure webhook returned non-2xx',
      );
    }
  } catch (err) {
    log.warn(
      { recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
      'recording failure webhook POST failed',
    );
  }
}

/**
 * 实际异步派发失败回调：同时跑 default webhook + 用户注册的 callback
 * 抽出此函数是为了让 spawn 阶段（entry 还没进 recordingMap）也能复用
 */
function dispatchFailureInfo(info: Parameters<RecordingFailureCallback>[0]): void {
  setImmediate(() => {
    void defaultWebhookCallback(info);
    if (failureCallback) {
      try {
        const r = failureCallback(info);
        if (r && typeof (r as Promise<void>).catch === 'function') {
          (r as Promise<void>).catch((err) => {
            log.warn(
              { recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
              'recording failure callback rejected',
            );
          });
        }
      } catch (err) {
        log.warn(
          { recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
          'recording failure callback threw',
        );
      }
    }
  });
}

/**
 * 内部触发失败回调：异步、错误吞掉
 * 入口语义"已 active 的 ffmpeg 进程异常退出"，依赖已落到 recordingMap 的 entry
 */
function fireFailureCallback(entry: RecordingEntry): void {
  if (entry.unexpectedExit) return; // 幂等：每条录制最多触发一次
  entry.unexpectedExit = true;
  dispatchFailureInfo({
    recordingId: entry.id,
    roomCode: entry.roomCode,
    routerId: entry.routerId,
    exitCode: entry.exitCode,
    failureReason: entry.failureReason ?? 'ffmpeg exited unexpectedly',
  });
}

/**
 * spawn 阶段失败兜底：ffmpeg 二进制找不到 / 无权限 / 启动后立刻退出
 * 此时 entry 可能还没入 recordingMap（startRecording 在末尾才插入），
 * 依旧需要把失败上报给 go-service，否则 startRecording 同步 throw 之后
 * 万一上层 DB 行已经存在（异常时序、重试场景）就会卡在 recording 状态。
 *
 * 与 fireFailureCallback 互斥：只要其中一个跑过就标记 entry.unexpectedExit=true（如果有 entry）
 */
function fireSpawnFailure(args: {
  recordingId: string;
  roomCode: string;
  routerId: string;
  exitCode: number | null;
  failureReason: string;
}): void {
  const entry = recordingMap.get(args.recordingId);
  if (entry) {
    if (entry.unexpectedExit) return;
    entry.unexpectedExit = true;
    if (!entry.failureReason) entry.failureReason = args.failureReason;
    if (entry.exitCode === null) entry.exitCode = args.exitCode;
  }
  dispatchFailureInfo(args);
}

const recordingMap = new Map<string, RecordingEntry>();

// 默认输出目录；由 RECORDING_OUTPUT_DIR 环境变量覆盖，便于 docker volume 挂载
const OUTPUT_DIR = process.env.RECORDING_OUTPUT_DIR
  ? process.env.RECORDING_OUTPUT_DIR
  : join(tmpdir(), 'echochat-recordings');

const STOP_GRACE_MS = 5000;

// ============ 工具函数 ============

async function ensureOutputDir(): Promise<void> {
  await fs.mkdir(OUTPUT_DIR, { recursive: true });
}

function recordingId(): string {
  // crypto.randomUUID 在 node 20+ 可用；移除连字符以让文件名更紧凑、URL-safe
  return randomUUID().replace(/-/g, '');
}

/**
 * 从 Consumer 的 rtpParameters 提取录制需要的关键字段
 * - 取第一个 codec：mediasoup consumer 默认每个 Consumer 只协商一个主 codec
 * - sdpFmtp：把 codec.parameters 拼成 SDP a=fmtp 行格式
 */
function pickCodec(rtpParameters: RtpParameters): {
  payloadType: number;
  codecMime: string;
  clockRate: number;
  channels?: number;
  sdpFmtp?: string;
} {
  const codec: RtpCodecParameters | undefined = rtpParameters.codecs[0];
  if (!codec) {
    throw new AppError('MEDIASOUP_ERROR', 'consumer has no codecs in rtpParameters');
  }
  const params = codec.parameters || {};
  const fmtpParts = Object.entries(params)
    .filter(([, v]) => v !== undefined && v !== null && v !== '')
    .map(([k, v]) => `${k}=${typeof v === 'boolean' ? (v ? 1 : 0) : String(v)}`);
  return {
    payloadType: codec.payloadType,
    codecMime: codec.mimeType,
    clockRate: codec.clockRate,
    channels: codec.channels,
    sdpFmtp: fmtpParts.length > 0 ? fmtpParts.join(';') : undefined,
  };
}

/**
 * 把 mime（audio/opus / video/VP8）转成 SDP rtpmap 编码名
 * SDP a=rtpmap 不接受 `video/` 前缀，要单独取编码名
 */
function mimeToCodecName(mime: string): string {
  const idx = mime.indexOf('/');
  return idx >= 0 ? mime.slice(idx + 1) : mime;
}

/**
 * 生成 SDP 内容
 *
 * 注意 SDP 是从 ffmpeg 视角写的：c= 行的地址、m= 行的端口都是 ffmpeg 监听处。
 * mediasoup PlainTransport 之后 connect({ ip, port: ffmpegPort, rtcpPort }) 把流送过来。
 *
 * 一份 SDP 描述了一个会话里 1~N 条流（每条对应一个 producer 的 consumer）
 */
function buildSdp(tracks: RecordingTrack[], localIp: string): string {
  const lines: string[] = [
    'v=0',
    `o=- 0 0 IN IP4 ${localIp}`,
    's=echochat-recording',
    `c=IN IP4 ${localIp}`,
    't=0 0',
  ];
  for (const t of tracks) {
    const codecName = mimeToCodecName(t.codecMime);
    const rtpmapValue =
      t.kind === 'audio'
        ? `${codecName}/${t.clockRate}/${t.channels ?? 2}`
        : `${codecName}/${t.clockRate}`;
    lines.push(`m=${t.kind} ${t.ffmpegRtpPort} RTP/AVP ${t.payloadType}`);
    lines.push(`a=rtcp:${t.ffmpegRtcpPort}`);
    lines.push(`a=rtpmap:${t.payloadType} ${rtpmapValue}`);
    if (t.sdpFmtp) {
      lines.push(`a=fmtp:${t.payloadType} ${t.sdpFmtp}`);
    }
    lines.push('a=recvonly');
  }
  // SDP 必须以 \r\n 分隔，ffmpeg 解析行为更稳定
  return lines.join('\r\n') + '\r\n';
}

/**
 * 构造 ffmpeg 命令行参数
 *
 * -protocol_whitelist file,udp,rtp：默认只允许 file，需要显式开 udp/rtp
 * -fflags +genpts：从 RTP 时间戳生成 PTS
 * -use_wallclock_as_timestamps 1：避免 RTP 时间戳跳变导致 mp4 时长异常
 * -c copy：不重编码，直接复用 RTP 载荷
 *   - 优点：CPU 占用极低
 *   - 限制：输出容器必须能装 VP8/Opus。mp4 muxer 不接受 VP8（ffmpeg 报
 *     "Could not find tag for codec vp8" 后 exit code=1）。改用 webm 恶不必重编码
 *     （VP8+Opus 是 webm 原生组合）。后续如需 mp4 可在 go-service 上传后异步 ffmpeg 转一次
 * -f webm：显式指定 webm muxer（依赖后缀推断也可，但显式更稳）
 * -y：覆盖已存在文件（防止 recordingId 碰撞）
 */
function buildFfmpegArgs(sdpFilePath: string, outputPath: string): string[] {
  return [
    '-loglevel',
    'info',
    '-protocol_whitelist',
    'file,udp,rtp',
    '-fflags',
    '+genpts',
    '-use_wallclock_as_timestamps',
    '1',
    '-i',
    sdpFilePath,
    '-map',
    '0:a?',
    '-map',
    '0:v?',
    '-c',
    'copy',
    '-f',
    'webm',
    '-y',
    outputPath,
  ];
}

// ============ 对外 API ============

export interface StartRecordingInput {
  routerId: string;
  roomCode: string;
  /**
   * 要录制的 producer ID 列表。
   * 通常是 host 的 audio + video，或者屏幕共享 video。
   * Phase B1 阶段只录这些指定的 producer，不混合所有成员；后续 Phase B3 再扩展。
   */
  producerIds: string[];
}

export interface StartRecordingOutput {
  recordingId: string;
  outputPath: string;
  tracks: Array<{
    producerId: string;
    kind: 'audio' | 'video';
  }>;
}

/**
 * 启动录制
 *
 * 失败处理：
 *   - 任一步骤失败都会回滚已创建的资源（端口 / Transport / Consumer / ffmpeg 进程）
 *   - 不在 recordingMap 中留下"半态"条目，调用方可直接重试
 */
export async function startRecording(input: StartRecordingInput): Promise<StartRecordingOutput> {
  const router = getRouter(input.routerId);
  if (input.producerIds.length === 0) {
    throw new AppError('CONFLICT', 'producerIds is empty');
  }
  await ensureOutputDir();

  const id = recordingId();
  const startedAt = Date.now();
  const localIp = '127.0.0.1';
  // 输出后缀 .webm：VP8 不能装进 mp4，webm 是 VP8+Opus 的原生容器，-c copy 零重编码
  const outputPath = join(OUTPUT_DIR, `${id}.webm`);
  const sdpFilePath = join(OUTPUT_DIR, `${id}.sdp`);

  // 已分配资源，用于失败时回滚
  const allocatedPortPairs: Array<{ rtp: number; rtcp: number }> = [];
  const createdTransports: PlainTransport[] = [];
  const createdConsumers: Consumer[] = [];
  let ffmpegProc: ChildProcess | null = null;

  const rollback = async () => {
    for (const c of createdConsumers) {
      try {
        c.close();
      } catch {
        /* ignored */
      }
    }
    for (const t of createdTransports) {
      try {
        t.close();
      } catch {
        /* ignored */
      }
    }
    for (const p of allocatedPortPairs) {
      releasePortPair(p.rtp, p.rtcp);
    }
    if (ffmpegProc && ffmpegProc.exitCode === null) {
      try {
        ffmpegProc.kill('SIGKILL');
      } catch {
        /* ignored */
      }
    }
    try {
      await fs.unlink(sdpFilePath);
    } catch {
      /* ignored */
    }
    try {
      await fs.unlink(outputPath);
    } catch {
      /* ignored */
    }
  };

  try {
    // ---------- Step 1: 预校验所有 producer 存在且可消费 ----------
    // 不在这里建 Consumer：Consumer 必须依附 PlainTransport，留到 Step 2 一起做
    // 但 canConsume 失败要尽早抛错，避免分配资源后再失败导致回滚开销
    const tracks: RecordingTrack[] = [];
    for (const producerId of input.producerIds) {
      // getProducer 不存在会抛 notFound，等价于校验 producerId 合法
      getProducer(producerId);
      if (!router.canConsume({ producerId, rtpCapabilities: router.rtpCapabilities })) {
        throw new AppError(
          'CONFLICT',
          `router cannot consume producer ${producerId} with its own rtpCapabilities`,
        );
      }
    }

    // ---------- Step 2: 为每个 producer 分配端口对 + 创建 PlainTransport ----------
    for (const producerId of input.producerIds) {
      const producer = getProducer(producerId);
      const portPair = await allocatePortPair();
      allocatedPortPairs.push(portPair);

      const transport = await router.createPlainTransport({
        listenIp: { ip: config.mediasoup.listenIp, announcedIp: undefined },
        rtcpMux: false,
        comedia: false,
        enableSrtp: false,
        appData: {
          purpose: 'recording',
          recordingId: id,
          producerId,
        },
      });
      createdTransports.push(transport);

      // 创建 consumer：必须传 router 自己的 rtpCapabilities，consumer 会与 producer 协商
      const consumer = await transport.consume({
        producerId,
        rtpCapabilities: router.rtpCapabilities,
        paused: true,
      });
      createdConsumers.push(consumer);

      const codec = pickCodec(consumer.rtpParameters);
      tracks.push({
        producerId,
        consumerId: consumer.id,
        transportId: transport.id,
        ffmpegRtpPort: portPair.rtp,
        ffmpegRtcpPort: portPair.rtcp,
        kind: producer.kind as 'audio' | 'video',
        ...codec,
      });
    }

    // ---------- Step 3: 写 SDP 文件 ----------
    const sdp = buildSdp(tracks, localIp);
    await fs.writeFile(sdpFilePath, sdp, 'utf-8');
    log.info({ recordingId: id, sdp }, 'SDP file generated');

    // ---------- Step 4: spawn ffmpeg ----------
    const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
    const args = buildFfmpegArgs(sdpFilePath, outputPath);
    ffmpegProc = spawn(ffmpegBin, args, { stdio: ['ignore', 'pipe', 'pipe'] });

    // ffmpeg stderr 环形缓冲：正常运行时 debug 级 + 不落盘，
    // 进程异常退出时把最后 N KB stderr 一次性以 error 级 dump 出来，方便定位 code=1 类问题
    // 上限 16 KB 足以覆盖典型 ffmpeg 启动失败诊断（codec/端口/SDP 解析等），且容量可控
    const STDERR_TAIL_LIMIT = 16 * 1024;
    let stderrTail = '';
    const appendStderrTail = (text: string) => {
      stderrTail += text;
      if (stderrTail.length > STDERR_TAIL_LIMIT) {
        stderrTail = stderrTail.slice(stderrTail.length - STDERR_TAIL_LIMIT);
      }
    };

    const exited = new Promise<void>((resolve) => {
      ffmpegProc!.once('exit', (code, signal) => {
        const entry = recordingMap.get(id);
        if (entry) {
          entry.exitCode = code;
          if (!entry.stopping && code !== 0) {
            entry.failureReason = `ffmpeg exited unexpectedly code=${code} signal=${signal ?? ''}`;
            // 异常退出：把 stderr tail 落 error 级，便于线上排查 ffmpeg 失败根因
            log.error(
              { recordingId: id, code, signal, stderrTail: stderrTail.trim() },
              'ffmpeg exited unexpectedly before stop',
            );
            // Phase B watchdog：异步触发失败回调（webhook + 用户回调），不阻塞 exit 处理
            fireFailureCallback(entry);
          }
        } else if (code !== 0) {
          // entry 还没入 map（startRecording 末尾才插入）→ 走 spawn 失败兜底，避免 webhook 漏报
          // startRecording 同步路径会自己 throw + rollback，这里上报只为防止上层 DB 行已存在的边缘场景
          log.error(
            { recordingId: id, code, signal, stderrTail: stderrTail.trim() },
            'ffmpeg exited before recording entry registered',
          );
          fireSpawnFailure({
            recordingId: id,
            roomCode: input.roomCode,
            routerId: input.routerId,
            exitCode: code,
            failureReason: `ffmpeg exited during spawn code=${code} signal=${signal ?? ''}; stderr_tail=${stderrTail.trim().slice(-512)}`,
          });
        }
        resolve();
      });
    });

    ffmpegProc.stderr?.on('data', (chunk: Buffer) => {
      const text = chunk.toString('utf-8');
      // 1) 永远写入环形缓冲，供进程异常退出时 dump
      appendStderrTail(text);
      // 2) 正常实时日志仍走 debug 级，避免高频日志爆炸
      log.debug({ recordingId: id }, `[ffmpeg] ${text.trim()}`);
    });

    ffmpegProc.once('error', (err) => {
      // spawn 阶段错误：ffmpeg 二进制找不到 / 无权限 / OS 资源耗尽等
      // 此前仅记日志，go-service 完全感知不到。这里立刻触发 failure webhook 兜底，
      // 让上层（即使 DB 已写入 recording 行）能把状态推进到 failed
      log.error({ recordingId: id, err: err.message }, 'ffmpeg spawn error');
      fireSpawnFailure({
        recordingId: id,
        roomCode: input.roomCode,
        routerId: input.routerId,
        exitCode: null,
        failureReason: `ffmpeg spawn error: ${err.message}`,
      });
    });

    // 等 ffmpeg 进入监听就绪：实践经验 200ms 足够 ffmpeg 解析 SDP + 绑定端口
    // 这里加一个 250ms 的小延迟，避免过早 connect 导致前几个 RTP 包丢失
    await new Promise((r) => setTimeout(r, 250));

    if (ffmpegProc.exitCode !== null) {
      throw new AppError(
        'MEDIASOUP_ERROR',
        `ffmpeg exited prematurely with code ${ffmpegProc.exitCode}`,
      );
    }

    // ---------- Step 5: PlainTransport.connect 到 ffmpeg ----------
    for (let i = 0; i < input.producerIds.length; i++) {
      const transport = createdTransports[i];
      const t = tracks[i];
      await transport.connect({
        ip: localIp,
        port: t.ffmpegRtpPort,
        rtcpPort: t.ffmpegRtcpPort,
      });
    }

    // ---------- Step 6: resume consumers，RTP 开始流向 ffmpeg ----------
    for (const c of createdConsumers) {
      await c.resume();
    }

    const entry: RecordingEntry = {
      id,
      roomCode: input.roomCode,
      routerId: input.routerId,
      startedAt,
      tracks,
      consumers: createdConsumers,
      transports: createdTransports,
      ffmpeg: ffmpegProc,
      sdpFilePath,
      outputPath,
      ffmpegExited: exited,
      stopping: false,
      exitCode: null,
      failureReason: null,
      unexpectedExit: false,
    };
    recordingMap.set(id, entry);

    log.info(
      {
        recordingId: id,
        roomCode: input.roomCode,
        producerCount: input.producerIds.length,
        outputPath,
      },
      'recording started',
    );

    return {
      recordingId: id,
      outputPath,
      tracks: tracks.map((t) => ({ producerId: t.producerId, kind: t.kind })),
    };
  } catch (err) {
    log.error(
      { recordingId: id, err: err instanceof Error ? err.message : String(err) },
      'startRecording failed, rolling back',
    );
    await rollback();
    if (err instanceof AppError) throw err;
    throw new AppError(
      'MEDIASOUP_ERROR',
      `startRecording failed: ${err instanceof Error ? err.message : String(err)}`,
    );
  }
}

export interface StopRecordingOutput {
  recordingId: string;
  outputPath: string;
  sizeBytes: number;
  durationMs: number;
  exitCode: number | null;
  failureReason: string | null;
}

/**
 * 停止录制
 *
 * 幂等：重复调用同一 recordingId 仅第一次执行清理，后续返回 notFound
 *
 * 时间线：
 *   - t=0: SIGINT ffmpeg
 *   - t≤5s: ffmpeg 优雅退出，写完 moov box
 *   - t>5s: 强制 SIGKILL，文件可能损坏（罕见，记录 failureReason）
 *   - 之后 close consumers/transports/释放端口
 */
export async function stopRecording(id: string): Promise<StopRecordingOutput> {
  const entry = recordingMap.get(id);
  if (!entry) {
    throw notFound('recording', id);
  }
  if (entry.stopping) {
    // 并发重复 stop：返回当前快照，避免重复 SIGINT
    log.warn({ recordingId: id }, 'stopRecording called while already stopping');
  }
  entry.stopping = true;

  // 1) SIGINT ffmpeg
  let killed = false;
  if (entry.ffmpeg.exitCode === null) {
    try {
      entry.ffmpeg.kill('SIGINT');
      killed = true;
    } catch (err) {
      log.warn(
        { recordingId: id, err: err instanceof Error ? err.message : String(err) },
        'failed to send SIGINT to ffmpeg',
      );
    }
  }

  // 2) 等 ffmpeg 退出 + 超时兜底
  if (killed) {
    const timeout = new Promise<void>((resolve) => {
      setTimeout(() => {
        if (entry.ffmpeg.exitCode === null) {
          try {
            entry.ffmpeg.kill('SIGKILL');
          } catch {
            /* ignored */
          }
          entry.failureReason = `ffmpeg did not exit within ${STOP_GRACE_MS}ms, forced SIGKILL`;
          log.error({ recordingId: id }, entry.failureReason);
        }
        resolve();
      }, STOP_GRACE_MS);
    });
    await Promise.race([entry.ffmpegExited, timeout]);
    // race 后再 await exited 拿到最终退出码
    await entry.ffmpegExited;
  }

  // 3) close consumers + transports
  for (const c of entry.consumers) {
    try {
      c.close();
    } catch {
      /* ignored */
    }
  }
  for (const t of entry.transports) {
    try {
      t.close();
    } catch {
      /* ignored */
    }
  }

  // 4) 释放端口
  for (const track of entry.tracks) {
    releasePortPair(track.ffmpegRtpPort, track.ffmpegRtcpPort);
  }

  // 5) 删除 SDP 临时文件（保留 mp4）
  try {
    await fs.unlink(entry.sdpFilePath);
  } catch {
    /* ignored */
  }

  // 6) stat 输出文件
  let sizeBytes = 0;
  try {
    const st = await fs.stat(entry.outputPath);
    sizeBytes = st.size;
  } catch (err) {
    if (!entry.failureReason) {
      entry.failureReason = `output file missing: ${err instanceof Error ? err.message : String(err)}`;
    }
  }

  const durationMs = Date.now() - entry.startedAt;
  recordingMap.delete(id);

  log.info(
    {
      recordingId: id,
      durationMs,
      sizeBytes,
      exitCode: entry.exitCode,
      failureReason: entry.failureReason,
    },
    'recording stopped',
  );

  return {
    recordingId: id,
    outputPath: entry.outputPath,
    sizeBytes,
    durationMs,
    exitCode: entry.exitCode,
    failureReason: entry.failureReason,
  };
}

export interface RecordingInfo {
  recordingId: string;
  roomCode: string;
  routerId: string;
  startedAt: number;
  durationMs: number;
  outputPath: string;
  trackCount: number;
  ffmpegAlive: boolean;
}

export function getRecording(id: string): RecordingInfo {
  const entry = recordingMap.get(id);
  if (!entry) {
    throw notFound('recording', id);
  }
  return {
    recordingId: entry.id,
    roomCode: entry.roomCode,
    routerId: entry.routerId,
    startedAt: entry.startedAt,
    durationMs: Date.now() - entry.startedAt,
    outputPath: entry.outputPath,
    trackCount: entry.tracks.length,
    ffmpegAlive: entry.ffmpeg.exitCode === null,
  };
}

export function getRecordingStats(): { total: number } {
  return { total: recordingMap.size };
}

/**
 * 列出指定 roomCode 下的所有活跃录制（一般 ≤1）
 * 供 Go 后端 EndRoom / 资源清理时反查
 */
export function listRecordingsByRoom(roomCode: string): RecordingInfo[] {
  const out: RecordingInfo[] = [];
  for (const entry of recordingMap.values()) {
    if (entry.roomCode === roomCode) {
      out.push({
        recordingId: entry.id,
        roomCode: entry.roomCode,
        routerId: entry.routerId,
        startedAt: entry.startedAt,
        durationMs: Date.now() - entry.startedAt,
        outputPath: entry.outputPath,
        trackCount: entry.tracks.length,
        ffmpegAlive: entry.ffmpeg.exitCode === null,
      });
    }
  }
  return out;
}

/** 测试专用：复位 recordingMap（停止所有进行中的录制） */
export async function _clearRecordings(): Promise<void> {
  assertTestOnly('_clearRecordings');
  const ids = [...recordingMap.keys()];
  for (const id of ids) {
    try {
      await stopRecording(id);
    } catch {
      /* ignored */
    }
  }
  recordingMap.clear();
}
