//
//  AoqEngineManager.swift
//  aoqdemo
//
//  Wrapper around AoqClientSdk.AoqClientEngine for SwiftUI consumption.
//

import Foundation
import UIKit
import Combine
import ReplayKit
import AoqClientSdk

// MARK: - Data Msg Models

struct IncomingDataMsg: Identifiable, Equatable {
    let id = UUID()
    let text: String
    let timestamp: Date = Date()
}

struct OutgoingDataMsg: Identifiable, Equatable {
    let id = UUID()
    let text: String
    let timestamp: Date = Date()
}

// MARK: - Conversation State

enum ConversationState: Equatable {
    case idle
    case listening
    case thinking
    case responding
    case error(ConversationError)
}

enum ConversationError: Equatable {
    case dataInputInspectionFailed
    case unknown
}

// MARK: - Log Entry

struct LogEntry: Identifiable, Equatable {
    let id = UUID()
    let time: Date
    let message: String

    var timestamp: String {
        let fmt = DateFormatter()
        fmt.dateFormat = "HH:mm:ss"
        return fmt.string(from: time)
    }
}

// MARK: - Video Frame Stats

/// 单一回调位置的累计与最近一帧元信息。
struct VideoFrameStats: Equatable {
    var totalFrames: Int = 0
    var fpsLastSecond: Double = 0
    var width: Int = 0
    var height: Int = 0
    var format: AoqVideoPixelFormat = .unknown
    var lastTimeStamp: Int64 = 0
    /// 当前秒内累计帧数，每秒由 fps timer 取走后清零。
    fileprivate var framesThisSecond: Int = 0
}

// MARK: - Engine Manager

final class AoqEngineManager: NSObject, ObservableObject {
    static let shared = AoqEngineManager()

    @Published var sdkVersion: String = ""
    @Published var isEngineCreated: Bool = false
    @Published var isConnected: Bool = false
    @Published var isSpeakerOn: Bool = false
    @Published var lastError: String = ""
    @Published var videoDeviceStateLabel: String = "--"
    @Published var events: [LogEntry] = []
    @Published var lastDataMsg: Data?
    let incomingDataMsg = PassthroughSubject<IncomingDataMsg, Never>()
    let outgoingDataMsg = PassthroughSubject<OutgoingDataMsg, Never>()
    let sessionCreated = PassthroughSubject<String, Never>()
    let conversationStateChanged = PassthroughSubject<ConversationState, Never>()

    /// 每个 AoqVideoSource 位置的统计,由 fps timer 每秒在主线程更新。
    @Published var videoFrameStats: [AoqVideoSource: VideoFrameStats] = [
        .captured:  VideoFrameStats(),
        .preEncode: VideoFrameStats(),
        .remote:    VideoFrameStats(),
    ]
    /// 至少有一个位置开启回调时为 true。
    @Published var isVideoFrameObserverEnabled: Bool = false

    /// 回调线程在 videoStatsLock 下累加的私有镜像。
    private var videoFrameStatsRaw: [AoqVideoSource: VideoFrameStats] = [
        .captured:  VideoFrameStats(),
        .preEncode: VideoFrameStats(),
        .remote:    VideoFrameStats(),
    ]

    private var engine: AoqClientEngine?

    private override init() {
        super.init()
        DispatchQueue.global().async { [weak self] in
            let ver = AoqClientEngine.getVersion()
            DispatchQueue.main.async { self?.sdkVersion = ver }
        }
    }

    // MARK: Lifecycle

    func createEngine(workDir: String, enableDumpAudio: Bool, extras: String) {
        guard engine == nil else {
            appendLog("engine already created, skip")
            return
        }
        let config = AoqCreateConfig()
        config.workDir = workDir
        config.enableDumpAudio = enableDumpAudio
        config.extras = extras
        engine = AoqClientEngine.createEngine(config, delegate: self)
        isEngineCreated = (engine != nil)
        appendLog("createEngine dumpAudio=\(enableDumpAudio) -> \(isEngineCreated ? "OK" : "FAIL")")
    }

    func destroyEngine() {
        guard engine != nil else {
            appendLog("engine is nil, skip destroy")
            return
        }
        stopPushAudioFile()
        stopExternalFrameTimer()
        unregisterVideoFrameObserver()
        _ = AoqClientEngine.destroy()
        engine = nil
        isEngineCreated = false
        isConnected = false
        videoDeviceStateLabel = "--"
        appendLog("destroyEngine")
    }

    // MARK: Connection

    func connect(token: String, sid: String, certificate: String,
                 relayEndpoints: [AoqRelayEndpoint],
                 workspaceIdHash: String = "",
                 publishTracks: [AoqTrackParam], subscribeTracks: [AoqTrackParam]) {
        guard let engine = engine else {
            appendLog("connect ignored: engine not created")
            return
        }
        let config = AoqConnectConfig()
        config.token = token
        config.sid = sid
        config.certFingerprint = certificate
        config.relayEndpoints = relayEndpoints
        config.workspaceIdHash = workspaceIdHash
        config.publishTracks = publishTracks
        config.subscribeTracks = subscribeTracks

        // connect 前默认关闭音视频发送，待收到 session.updated 消息后再开启
        enableSendMediaStream(trackType: .audio, enable: false)
        enableSendMediaStream(trackType: .video, enable: false)

        let ret = engine.connect(config)
        let epDesc = relayEndpoints.map { "\($0.endpoint):\($0.port)" }.joined(separator: ",")
        appendLog("connect relay=[\(epDesc)] pub=\(publishTracks.count) sub=\(subscribeTracks.count) ret=\(ret)")
    }

    /// 控制本地音视频媒体流的发送
    func enableSendMediaStream(trackType: AoqTrackType, enable: Bool) {
        guard let engine = engine else {
            appendLog("enableSendMediaStream ignored: engine not created")
            return
        }
        let ret = engine.enableSendMediaStream(trackType, enable: enable)
        appendLog("enableSendMediaStream track=\(trackType.rawValue) enable=\(enable) ret=\(ret)")
    }

    func disconnect() {
        guard let engine = engine else {
            appendLog("disconnect ignored: engine not created")
            return
        }
        let ret = engine.disconnect()
        isConnected = false
        appendLog("disconnect ret=\(ret)")
    }

    // MARK: Audio Capture

    func startAudioCapture(channel: Int, isExternal: Bool, isVoipMode: Bool) {
        guard let engine = engine else { return }
        let config = AoqAudioCaptureConfig()
        config.channel = channel
        config.isExternal = isExternal
        config.isVoipMode = isVoipMode
        let ret = engine.startAudioCapture(config)
        appendLog("startAudioCapture ret=\(ret)")
    }

    func stopAudioCapture() {
        guard let engine = engine else { return }
        let ret = engine.stopAudioCapture()
        appendLog("stopAudioCapture ret=\(ret)")
    }

    func muteAudioCapture(_ mute: Bool) {
        guard let engine = engine else { return }
        let ret = engine.muteAudioCapture(mute)
        appendLog("muteAudioCapture(\(mute)) ret=\(ret)")
    }

    // MARK: Audio Player

    func startAudioPlayer(channel: Int, isExternal: Bool, isDefaultSpeaker: Bool = true, isVoipMode: Bool = false) {
        guard let engine = engine else { return }
        let config = AoqAudioPlaybackConfig()
        config.isVoipMode = isVoipMode
        config.isDefaultSpeaker = isDefaultSpeaker
        config.channel = channel
        config.isExternal = isExternal
        let ret = engine.startAudioPlayer(config)
        appendLog("startAudioPlayer ret=\(ret)")
    }

    func stopAudioPlayer() {
        guard let engine = engine else { return }
        let ret = engine.stopAudioPlayer()
        appendLog("stopAudioPlayer ret=\(ret)")
    }

    func pauseAudioPlayer(fadeMs: Int = 0) {
        guard let engine = engine else { return }
        let ret = engine.pauseAudioPlayer(Int(fadeMs))
        appendLog("pauseAudioPlayer fadeMs=\(fadeMs) ret=\(ret)")
    }

    func resumeAudioPlayer(fadeMs: Int = 0) {
        guard let engine = engine else { return }
        let ret = engine.resumeAudioPlayer(Int(fadeMs))
        appendLog("resumeAudioPlayer fadeMs=\(fadeMs) ret=\(ret)")
    }

    func interruptAudioPlayer(trackType: AoqTrackType = .audio, fadeMs: Int = 0) {
        guard let engine = engine else { return }
        let ret = engine.interruptAudioPlayer(trackType, fadeMs: Int(fadeMs))
        appendLog("interruptAudioPlayer trackType=\(trackType.rawValue) fadeMs=\(fadeMs) ret=\(ret)")
    }

    func enableSpeakerphone(_ enable: Bool) {
        guard let engine = engine else { return }
        let ret = engine.enableSpeakerphone(enable)
        appendLog("enableSpeakerphone(\(enable)) ret=\(ret)")
    }

    func isSpeakerphoneEnabled() -> Bool {
        guard let engine = engine else { return false }
        let enabled = engine.isSpeakerphoneEnabled()
        appendLog("isSpeakerphoneEnabled=\(enabled)")
        return enabled
    }

    func setAudioEncoderConfig(_ config: AoqAudioCodecConfig) {
        guard let engine = engine else { return }
        let ret = engine.setAudioEncoderConfig(config)
        appendLog("setAudioEncoderConfig codec=\(config.codecType.rawValue) sr=\(config.sampleRate) ch=\(config.channel) br=\(config.bitrate) ret=\(ret)")
    }

    func setAudioDecoderConfig(_ config: AoqAudioCodecConfig) {
        guard let engine = engine else { return }
        let ret = engine.setAudioDecoderConfig(config)
        appendLog("setAudioDecoderConfig codec=\(config.codecType.rawValue) sr=\(config.sampleRate) ch=\(config.channel) br=\(config.bitrate) ret=\(ret)")
    }

    // MARK: AudioExternalStream

    /// 本地音频文件推送线程句柄
    private var audioPushThread: Thread?
    /// 推送线程运行标记
    private var audioPushRunning: Bool = false
    @Published var isAudioFilePushing: Bool = false
    @Published var audioFileDurationMs: Int64 = 0
    @Published var audioFileCurrentPosMs: Int64 = 0
    @Published var audioFilePublishVolumeGet: Int = 0
    @Published var audioFilePlayoutVolumeGet: Int = 0

    func addAudioExternalStream(streamId: String, config: AoqAudioExternalStreamConfig) {
        guard let engine = engine else { return }
        let ret = engine.addAudioExternalStream(streamId, config: config)
        appendLog("addAudioExternalStream id=\(streamId) sr=\(config.sampleRate) ch=\(config.channels) ret=\(ret)")
    }

    func removeAudioExternalStream(streamId: String) {
        guard let engine = engine else { return }
        let ret = engine.removeAudioExternalStream(streamId)
        appendLog("removeAudioExternalStream id=\(streamId) ret=\(ret)")
    }

    func setAudioExternalStreamVolume(streamId: String, type: AoqAudioStreamDirection, volume: Int) {
        guard let engine = engine else { return }
        let ret = engine.setAudioExternalStreamVolume(streamId, type: type, volume: volume)
        appendLog("setAudioExternalStreamVolume id=\(streamId) type=\(type.rawValue) vol=\(volume) ret=\(ret)")
    }

    func getAudioExternalStreamVolume(streamId: String, type: AoqAudioStreamDirection) -> Int {
        guard let engine = engine else { return -1 }
        let ret = Int(engine.getAudioExternalStreamVolume(streamId, type: type))
        appendLog("getAudioExternalStreamVolume id=\(streamId) type=\(type.rawValue) ret=\(ret)")
        return ret
    }

    func clearAudioExternalStreamBuffer(streamId: String, fadeoutMs: Int) {
        guard let engine = engine else { return }
        engine.clearAudioExternalStreamBuffer(streamId, fadeoutMs: fadeoutMs)
        appendLog("clearAudioExternalStreamBuffer id=\(streamId) fadeoutMs=\(fadeoutMs)")
    }

    /// 从本地音频文件以 10ms 为一包调用 push 接口。
    /// 默认按 S16LE 划分，每包字节数 = sampleRate * channels * 2 / 100 。
    /// 文件本身不限制后缀，只要与该参数匹配的裸二进制采样数据即可。
    func startPushAudioFile(streamId: String, fileURL: URL, sampleRate: Int, channels: Int) {
        guard engine != nil else { return }
        guard !audioPushRunning else {
            appendLog("startPushAudioFile ignored: already running")
            return
        }
        guard let handle = try? FileHandle(forReadingFrom: fileURL) else {
            appendLog("startPushAudioFile open failed: \(fileURL.path)")
            return
        }
        audioPushRunning = true
        DispatchQueue.main.async { [weak self] in self?.isAudioFilePushing = true }
        appendLog("startPushAudioFile id=\(streamId) file=\(fileURL.lastPathComponent) sr=\(sampleRate) ch=\(channels)")

        let bytesPerSample = 2
        let duration = 40
        let samplesPer10ms = duration * sampleRate / 100
        let bytesPer10ms   = samplesPer10ms * channels * bytesPerSample

        let thread = Thread { [weak self] in
            guard let self = self else { return }
            var seq: Int32 = 0
            let startTs = Date().timeIntervalSince1970 * 1000.0
            var nextTickMs: Double = 0
            while self.audioPushRunning {
                var chunk = handle.readData(ofLength: bytesPer10ms)
                if chunk.count == 0 {
                    // 到达文件尾，从头循环读取
                    self.appendLog("audio file push reach EOF, rewind to begin")
                    try? handle.seek(toOffset: 0)
                    chunk = handle.readData(ofLength: bytesPer10ms)
                    if chunk.count == 0 {
                        self.appendLog("audio file empty, stop")
                        break
                    }
                }
                let payload: Data = (chunk.count < bytesPer10ms)
                    ? chunk + Data(count: bytesPer10ms - chunk.count)
                    : chunk

                let frame = AoqAudioFrameData()
                frame.numOfSamples   = samplesPer10ms
                frame.bytesPerSample = bytesPerSample
                frame.numOfChannels  = channels
                frame.samplesPerSec  = sampleRate
                frame.pushSequence   = seq
                frame.timeStamp      = Int64(Date().timeIntervalSince1970 * 1000.0)
                // 推送帧；若返回 buffer full(110) 则 sleep 后重试，直到成功或停止
                payload.withUnsafeBytes { rawBuf in
                    frame.dataPtr  = UnsafeMutableRawPointer(mutating: rawBuf.baseAddress)
                    frame.dataSize = payload.count
                    while self.audioPushRunning {
                        guard let engine = self.engine else { break }
                        let ret = engine.pushAudioExternalStreamData(streamId, data: frame)
                        if Int(ret) == AoqErrorCode.ecAudioExternalBufferFull.rawValue {
                            Thread.sleep(forTimeInterval: 0.01)
                            continue
                        }
                        break
                    }
                }
                seq &+= 1

                nextTickMs += Double(duration)
                let nowMs = Date().timeIntervalSince1970 * 1000.0
                let sleepMs = nextTickMs - (nowMs - startTs)
                if sleepMs > 0 {
                    Thread.sleep(forTimeInterval: sleepMs / 1000.0)
                }
            }
            try? handle.close()
            self.audioPushRunning = false
            DispatchQueue.main.async { [weak self] in self?.isAudioFilePushing = false }
            self.appendLog("audio file push thread exit")
        }
        thread.name = "aoq.audio.push"
        thread.qualityOfService = .userInitiated
        audioPushThread = thread
        thread.start()
    }

    func stopPushAudioFile() {
        guard audioPushRunning else { return }
        audioPushRunning = false
        appendLog("stopPushAudioFile requested")
    }

    // MARK: Audio File

    func startAudioFile(fileId: String, config: AoqAudioFileMixConfig) {
        guard let engine = engine else { return }
        let ret = engine.startAudioFile(fileId, config: config)
        appendLog("startAudioFile id=\(fileId) file=\(config.fileName) ret=\(ret)")
    }

    func stopAudioFile(fileId: String) {
        guard let engine = engine else { return }
        let ret = engine.stopAudioFile(fileId)
        appendLog("stopAudioFile id=\(fileId) ret=\(ret)")
    }

    func pauseAudioFile(fileId: String) {
        guard let engine = engine else { return }
        let ret = engine.pauseAudioFile(fileId)
        appendLog("pauseAudioFile id=\(fileId) ret=\(ret)")
    }

    func resumeAudioFile(fileId: String) {
        guard let engine = engine else { return }
        let ret = engine.resumeAudioFile(fileId)
        appendLog("resumeAudioFile id=\(fileId) ret=\(ret)")
    }

    func getAudioFileDuration(fileId: String) {
        guard let engine = engine else { return }
        let duration = engine.getAudioFileDuration(fileId)
        audioFileDurationMs = Int64(duration)
        appendLog("getAudioFileDuration id=\(fileId) duration=\(duration)ms")
    }

    func getAudioFileCurrentPosition(fileId: String) {
        guard let engine = engine else { return }
        let pos = engine.getAudioFileCurrentPosition(fileId)
        audioFileCurrentPosMs = Int64(pos)
        appendLog("getAudioFileCurrentPosition id=\(fileId) pos=\(pos)ms")
    }

    func setAudioFilePositionMillis(fileId: String, positionMillis: Int64) {
        guard let engine = engine else { return }
        let ret = engine.setAudioFilePositionMillis(fileId, positionMillis: positionMillis)
        appendLog("setAudioFilePositionMillis id=\(fileId) pos=\(positionMillis)ms ret=\(ret)")
    }

    func setAudioFileVolume(fileId: String, type: AoqAudioStreamDirection, volume: Int) {
        guard let engine = engine else { return }
        let ret = engine.setAudioFileVolume(fileId, type: type, volume: volume)
        appendLog("setAudioFileVolume id=\(fileId) type=\(type.rawValue) vol=\(volume) ret=\(ret)")
    }

    func getAudioFileVolume(fileId: String, type: AoqAudioStreamDirection) {
        guard let engine = engine else { return }
        let vol = engine.getAudioFileVolume(fileId, type: type)
        if type == .publish {
            audioFilePublishVolumeGet = Int(vol)
        } else {
            audioFilePlayoutVolumeGet = Int(vol)
        }
        appendLog("getAudioFileVolume id=\(fileId) type=\(type.rawValue) vol=\(vol)")
    }

    // MARK: AudioFrameObserver

    /// 音频帧回调写入的文件句柄
    private var audioFileHandle: FileHandle?
    /// 音频帧回调写入的文件路径（用于日志提示）
    private var audioDumpFilePath: String = ""
    /// 文件写入队列（保证线程安全）
    private let audioWriteQueue = DispatchQueue(label: "aoq.audio.frame.write", qos: .userInitiated)
    @Published var isAudioFrameObserverEnabled: Bool = false

    func setAudioFrameObserver(_ delegate: AoqAudioFrameDelegate?) {
        guard let engine = engine else { return }
        let ret: Int
        if let delegate = delegate {
            ret = Int(engine.setAudioFrameObserver(delegate))
        } else {
            ret = Int(engine.setAudioFrameObserver(nil))
        }
        appendLog("setAudioFrameObserver(\(delegate == nil ? "nil" : "delegate")) ret=\(ret)")
    }

    func enableAudioFrameObserver(_ enabled: Bool, audioSource: AoqAudioSource, config: AoqAudioObserverConfig) {
        guard let engine = engine else { return }
        let ret = engine.enableAudioFrameObserver(enabled, audioSource: audioSource, config: config)
        dispatchOnMain { [weak self] in self?.isAudioFrameObserverEnabled = enabled }
        appendLog("enableAudioFrameObserver(\(enabled)) source=\(audioSource.rawValue) sr=\(config.sampleRate) ch=\(config.channels) mode=\(config.mode.rawValue) ret=\(ret)")
    }

    /// 开启音频帧回调：注册 delegate → 创建 PCM 文件 → 开启回调
    func startAudioFrameObserver(audioSource: AoqAudioSource, sampleRate: Int, channels: Int, mode: AoqAudioObserverMode) {
        // 1. 注册 delegate
        setAudioFrameObserver(self)

        // 2. 创建 PCM 文件
        let docsDir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first ?? NSTemporaryDirectory()
        let timestamp = Int(Date().timeIntervalSince1970)
        let fileName = "audio_frame_s\(audioSource.rawValue)_sr\(sampleRate)_ch\(channels)_m\(mode.rawValue)_\(timestamp).pcm"
        let filePath = (docsDir as NSString).appendingPathComponent(fileName)
        audioDumpFilePath = filePath

        if FileManager.default.createFile(atPath: filePath, contents: nil) {
            audioFileHandle = try? FileHandle(forWritingTo: URL(fileURLWithPath: filePath))
            appendLog("audio frame dump file: \(filePath)")
        } else {
            appendLog("failed to create audio frame dump file: \(filePath)")
        }

        // 3. 开启回调
        let config = AoqAudioObserverConfig()
        config.sampleRate = sampleRate
        config.channels = channels
        config.mode = mode
        enableAudioFrameObserver(true, audioSource: audioSource, config: config)
    }

    /// 关闭音频帧回调：关闭回调 → 注销 delegate → 关闭文件
    func stopAudioFrameObserver(audioSource: AoqAudioSource) {
        // 1. 关闭回调
        let config = AoqAudioObserverConfig()
        enableAudioFrameObserver(false, audioSource: audioSource, config: config)

        // 2. 注销 delegate
        setAudioFrameObserver(nil)

        // 3. 关闭文件
        audioWriteQueue.async { [weak self] in
            guard let self = self else { return }
            try? self.audioFileHandle?.close()
            self.audioFileHandle = nil
        }
        appendLog("audio frame observer stopped, dump file closed")
    }

    // MARK: VideoFrameObserver

    /// 当前已开启的位置集合，用于决定 fps timer 启停。
    private var videoActiveSources: Set<AoqVideoSource> = []
    /// 保护 videoFrameStatsRaw 在回调线程写、fps timer 读的并发安全。
    private let videoStatsLock = NSLock()
    /// fps 计时器。
    private var videoFpsTimer: Timer?

    // dump 文件支持
    /// 每个开启位置当前持有的 dump 文件句柄。
    private var videoFileHandles: [AoqVideoSource: FileHandle] = [:]
    private var videoDumpPaths:   [AoqVideoSource: String]    = [:]
    /// 写文件队列，避免回调线程做磁盘 IO。
    private let videoWriteQueue = DispatchQueue(label: "aoq.video.frame.write", qos: .userInitiated)

    func setVideoFrameObserver(_ delegate: AoqVideoFrameDelegate?) {
        guard let engine = engine else { return }
        let ret: Int = (delegate != nil)
            ? Int(engine.setVideoFrameObserver(delegate))
            : Int(engine.setVideoFrameObserver(nil))
        appendLog("setVideoFrameObserver(\(delegate == nil ? "nil" : "delegate")) ret=\(ret)")
    }

    func enableVideoFrameObserver(_ enabled: Bool, videoSource: AoqVideoSource,
                                   config: AoqVideoObserverConfig) {
        guard let engine = engine else { return }
        let ret = engine.enableVideoFrameObserver(enabled, videoSource: videoSource, config: config)
        appendLog("enableVideoFrameObserver(\(enabled)) source=\(videoSource.rawValue) "
                  + "format=\(config.format.rawValue) align=\(config.alignment.rawValue) "
                  + "mode=\(config.mode.rawValue) mirror=\(config.mirrorApplied) ret=\(ret)")
    }

    /// 注册视频帧 Observer delegate，对应 SDK setVideoFrameObserver(self)。
    func registerVideoFrameObserver() {
        setVideoFrameObserver(self)
    }

    /// 注销 delegate，对应 SDK setVideoFrameObserver(nil)；注销前先 disable 所有活跃位置。
    func unregisterVideoFrameObserver() {
        // 拷贝一份再迭代，避免 disableVideoFrameObserverFor 修改 set 时 invalidate。
        for source in Array(videoActiveSources) {
            disableVideoFrameObserverFor(videoSource: source)
        }
        setVideoFrameObserver(nil)
    }

    /// 开启某位置回调：创建 dump 文件（如启用）→ enable → 维护 active set + fps timer。
    func enableVideoFrameObserverFor(videoSource: AoqVideoSource,
                                      format: AoqVideoPixelFormat,
                                      alignment: AoqVideoObserverAlignment,
                                      mode: AoqVideoObserverMode,
                                      mirror: Bool,
                                      dumpEnabled: Bool) {
        // 创建 dump 文件（按需；CVPixelBuffer 跳过）
        if dumpEnabled {
            if format == .cvPixelBuffer {
                appendLog("video frame dump skipped for CVPixelBuffer (V1 limitation)")
            } else {
                openVideoDumpFile(source: videoSource, format: format)
            }
        }

        let config = AoqVideoObserverConfig()
        config.format        = format
        config.alignment     = alignment
        config.mode          = mode
        config.mirrorApplied = mirror
        enableVideoFrameObserver(true, videoSource: videoSource, config: config)

        let wasEmpty = videoActiveSources.isEmpty
        videoActiveSources.insert(videoSource)
        if wasEmpty {
            startVideoFpsTimer()
        }
        dispatchOnMain { [weak self] in self?.isVideoFrameObserverEnabled = true }
    }

    /// 关闭某位置回调。幂等：重复调用是 no-op。
    func disableVideoFrameObserverFor(videoSource: AoqVideoSource) {
        guard videoActiveSources.contains(videoSource) else { return }

        let config = AoqVideoObserverConfig()
        enableVideoFrameObserver(false, videoSource: videoSource, config: config)
        closeVideoDumpFile(source: videoSource)

        videoActiveSources.remove(videoSource)
        if videoActiveSources.isEmpty {
            stopVideoFpsTimer()
            dispatchOnMain { [weak self] in self?.isVideoFrameObserverEnabled = false }
        }
    }

    /// 清空指定位置的统计字段
    func resetVideoFrameStats(_ videoSource: AoqVideoSource) {
        videoStatsLock.lock()
        videoFrameStatsRaw[videoSource] = VideoFrameStats()
        videoStatsLock.unlock()
        dispatchOnMain { [weak self] in
            guard let self = self else { return }
            self.videoFrameStats[videoSource] = VideoFrameStats()
        }
    }

    // MARK: VideoFrameObserver - internals

    private func startVideoFpsTimer() {
        guard videoFpsTimer == nil else { return }
        let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.tickVideoFps()
        }
        RunLoop.main.add(timer, forMode: .common)
        videoFpsTimer = timer
    }

    private func stopVideoFpsTimer() {
        videoFpsTimer?.invalidate()
        videoFpsTimer = nil
    }

    private func tickVideoFps() {
        videoStatsLock.lock()
        var snapshot: [AoqVideoSource: VideoFrameStats] = [:]
        for src in [AoqVideoSource.captured, .preEncode, .remote] {
            var s = videoFrameStatsRaw[src] ?? VideoFrameStats()
            s.fpsLastSecond = Double(s.framesThisSecond)
            s.framesThisSecond = 0
            videoFrameStatsRaw[src] = s
            snapshot[src] = s
        }
        videoStatsLock.unlock()
        dispatchOnMain { [weak self] in
            guard let self = self else { return }
            for (src, s) in snapshot {
                self.videoFrameStats[src] = s
            }
        }
    }

    private func openVideoDumpFile(source: AoqVideoSource, format: AoqVideoPixelFormat) {
        let docsDir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first
            ?? NSTemporaryDirectory()
        let timestamp = Int(Date().timeIntervalSince1970)
        let srcTag = videoSourceTag(source)
        let fmtTag = videoFormatTag(format)
        let ext: String = (format == .I420 || format == .NV12 || format == .NV21) ? "yuv" : "rgba"
        let stats = videoFrameStats[source] ?? VideoFrameStats()
        let sizeTag = (stats.width > 0 && stats.height > 0) ? "_\(stats.width)x\(stats.height)" : ""
        let fileName = "video_frame_s\(srcTag)_f\(fmtTag)\(sizeTag)_\(timestamp).\(ext)"
        let filePath = (docsDir as NSString).appendingPathComponent(fileName)
        videoDumpPaths[source] = filePath

        if FileManager.default.createFile(atPath: filePath, contents: nil) {
            videoFileHandles[source] = try? FileHandle(forWritingTo: URL(fileURLWithPath: filePath))
            appendLog("video frame dump file: \(filePath)")
            appendLog("playback: \(playbackHint(format: format, path: filePath))")
        } else {
            appendLog("failed to create video frame dump file: \(filePath)")
        }
    }

    private func closeVideoDumpFile(source: AoqVideoSource) {
        let handle = videoFileHandles.removeValue(forKey: source)
        let path   = videoDumpPaths.removeValue(forKey: source)
        videoWriteQueue.async {
            try? handle?.close()
        }
        if path != nil {
            appendLog("video frame dump closed: source=\(videoSourceTag(source))")
        }
    }

    fileprivate func writeVideoFrameToDump(source: AoqVideoSource, frame: AoqVideoFrame) {
        guard let handle = videoFileHandles[source] else { return }
        var combined = Data()
        switch frame.format {
        case .I420:
            if let y = frame.dataY { combined.append(y as Data) }
            if let u = frame.dataU { combined.append(u as Data) }
            if let v = frame.dataV { combined.append(v as Data) }
        case .NV12, .NV21, .BGRA, .RGBA:
            if let d = frame.data { combined.append(d as Data) }
        case .cvPixelBuffer, .unknown:
            return
        @unknown default:
            return
        }
        guard !combined.isEmpty else { return }
        videoWriteQueue.async {
            handle.write(combined)
        }
    }

    fileprivate func updateVideoFrameStats(source: AoqVideoSource, frame: AoqVideoFrame) {
        videoStatsLock.lock()
        var s = videoFrameStatsRaw[source] ?? VideoFrameStats()
        s.totalFrames     &+= 1
        s.framesThisSecond &+= 1
        s.width            = frame.width
        s.height           = frame.height
        s.format           = frame.format
        s.lastTimeStamp    = frame.timeStamp
        videoFrameStatsRaw[source] = s
        videoStatsLock.unlock()
    }

    // MARK: Video Capture

    func startVideoCapture(width: Int = 1280, height: Int = 720, fps: Int = 15,
                           isExternal: Bool = false,
                           cameraDirection: AoqCameraDirection = .front) {
        guard let engine = engine else { return }
        let config = AoqVideoCaptureConfig()
        config.width           = width
        config.height          = height
        config.fps             = fps
        config.isExternal      = isExternal
        config.cameraDirection = cameraDirection
        let ret = engine.startVideoCapture(config)
        appendLog("startVideoCapture \(width)x\(height)@\(fps)fps external=\(isExternal) dir=\(cameraDirection.rawValue) ret=\(ret)")
    }

    func stopVideoCapture() {
        guard let engine = engine else { return }
        stopExternalFrameTimer()
        let ret = engine.stopVideoCapture()
        appendLog("stopVideoCapture ret=\(ret)")
    }

    func switchCamera(direction: AoqCameraDirection) -> Int {
        guard let engine = engine else {
            appendLog("switchCamera ignored: engine not created")
            return -1
        }
        let ret = Int(engine.switchCamera(direction))
        appendLog("switchCamera dir=\(direction.rawValue) ret=\(ret)")
        return ret
    }

    // MARK: 外部采集（测试图推帧）

    private var externalFrameTimer: DispatchSourceTimer?
    private var externalPushSequence: Int64 = 0

    func pushExternalVideoCapturedFrame(_ trackType: AoqTrackType, frame: AoqVideoFrame) {
        guard let engine = engine else { return }
        let ret = engine.pushExternalVideoCapturedFrame(trackType, frame: frame)
        if ret != 0 {
            appendLog("pushExternalVideoCapturedFrame ret=\(ret)")
        }
    }

    func startExternalBGRATestPattern(width: Int = 720, height: Int = 480, fps: Int = 15) {
        startExternalRawTestPattern(format: .BGRA, width: width, height: height, fps: fps)
    }

    /// 启动 Raw 测试图推帧器，支持 BGRA / I420 / NV12 / NV21
    func startExternalRawTestPattern(format: AoqVideoPixelFormat,
                                      width: Int = 720, height: Int = 480, fps: Int = 15) {
        stopExternalFrameTimer()
        let interval: TimeInterval = 1.0 / Double(fps)
        let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .userInitiated))
        timer.schedule(deadline: .now(), repeating: interval)
        timer.setEventHandler { [weak self] in
            guard let self = self else { return }
            self.externalPushSequence &+= 1
            let seq = self.externalPushSequence
            let r = Int((seq * 13) & 0xFF)
            let g = Int((seq * 11) & 0xFF)
            let b = Int((seq * 7)  & 0xFF)

            let frame = AoqVideoFrame()
            frame.format    = format
            frame.width     = width
            frame.height    = height
            frame.timeStamp = Int64(Date().timeIntervalSince1970 * 1000)

            if format == .BGRA {
                let total = width * height * 4
                var bytes = [UInt8](repeating: 0, count: total)
                for i in stride(from: 0, to: total, by: 4) {
                    bytes[i]     = UInt8(b)
                    bytes[i + 1] = UInt8(g)
                    bytes[i + 2] = UInt8(r)
                    bytes[i + 3] = 0xFF
                }
                frame.data = Data(bytes)
            } else {
                let yVal = ( 66 * r + 129 * g +  25 * b + 128) / 256 + 16
                let uVal = (-38 * r -  74 * g + 112 * b + 128) / 256 + 128
                let vVal = (112 * r -  94 * g -  18 * b + 128) / 256 + 128
                let y = UInt8(max(0, min(255, yVal)))
                let u = UInt8(max(0, min(255, uVal)))
                let v = UInt8(max(0, min(255, vVal)))

                let ySize  = width * height
                let uvSize = (width / 2) * (height / 2)

                if format == .I420 {
                    frame.dataY   = Data(repeating: y, count: ySize)
                    frame.dataU   = Data(repeating: u, count: uvSize)
                    frame.dataV   = Data(repeating: v, count: uvSize)
                    frame.strideY = width
                    frame.strideU = width / 2
                    frame.strideV = width / 2
                } else {
                    let total = ySize + uvSize * 2
                    var buf = [UInt8](repeating: y, count: ySize) + [UInt8](repeating: 0, count: uvSize * 2)
                    let uvStart = ySize
                    if format == .NV12 {
                        for i in stride(from: uvStart, to: total, by: 2) {
                            buf[i]     = u
                            buf[i + 1] = v
                        }
                    } else {
                        for i in stride(from: uvStart, to: total, by: 2) {
                            buf[i]     = v
                            buf[i + 1] = u
                        }
                    }
                    frame.data = Data(buf)
                }
            }
            self.pushExternalVideoCapturedFrame(.video, frame: frame)
        }
        timer.resume()
        externalFrameTimer = timer
        appendLog("external \(format) test pattern started \(width)x\(height)@\(fps)fps")
    }

    func stopExternalFrameTimer() {
        if let t = externalFrameTimer {
            t.cancel()
            externalFrameTimer = nil
            appendLog("external test pattern stopped")
        }
    }

    func pushExternalVideoEncodedFrame(_ trackType: AoqTrackType, frame: AoqVideoEncodedFrame) {
        guard let engine = engine else { return }
        let ret = engine.pushExternalVideoEncodedFrame(trackType, frame: frame)
        if ret != 0 {
            appendLog("pushExternalVideoEncodedFrame ret=\(ret)")
        }
    }

    /// 启动 JPEG 编码帧直推测试（生成纯色 → JPEG Data → pushExternalVideoEncodedFrame）
    func startExternalJPEGEncodedTestPattern(width: Int = 720, height: Int = 480, fps: Int = 15) {
        stopExternalFrameTimer()
        let interval: TimeInterval = 1.0 / Double(fps)
        let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .userInitiated))
        timer.schedule(deadline: .now(), repeating: interval)
        timer.setEventHandler { [weak self] in
            guard let self = self else { return }
            self.externalPushSequence &+= 1
            let seq = self.externalPushSequence
            let r = CGFloat((seq * 13) & 0xFF) / 255.0
            let g = CGFloat((seq * 11) & 0xFF) / 255.0
            let b = CGFloat((seq * 7)  & 0xFF) / 255.0
            let color = UIColor(red: r, green: g, blue: b, alpha: 1.0)

            let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height))
            let image = renderer.image { ctx in
                color.setFill()
                ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
            }
            guard let jpegData = image.jpegData(compressionQuality: 0.85) else { return }

            let frame = AoqVideoEncodedFrame()
            frame.codec     = .JPEG
            frame.data      = jpegData
            frame.width     = width
            frame.height    = height
            frame.timeStamp = Int64(Date().timeIntervalSince1970 * 1000)
            self.pushExternalVideoEncodedFrame(.video, frame: frame)
        }
        timer.resume()
        externalFrameTimer = timer
        appendLog("external JPEG encoded test pattern started \(width)x\(height)@\(fps)fps")
    }

    func setVideoEncoderConfig(_ config: AoqVideoCodecConfig) {
        guard let engine = engine else { return }
        let ret = engine.setVideoEncoderConfig(config)
        appendLog("setVideoEncoderConfig codec=\(config.codecType.rawValue) \(config.width)x\(config.height)@\(config.fps) br=\(config.bitrate) ret=\(ret)")
    }

    func setLocalView(_ view: UIView?, trackType: AoqTrackType = .video, renderMode: AoqRenderMode = .auto) {
        guard let engine = engine else { return }
        let canvas = AoqVideoCanvas()
        canvas.view = view
        canvas.renderMode = renderMode
        let ret = engine.setLocalView(trackType, canvas: canvas)
        appendLog("setLocalView(\(view == nil ? "nil" : "view"), \(Self.trackLabel(trackType))) ret=\(ret)")
    }

    func setRemoteView(_ view: UIView?, trackType: AoqTrackType = .video, renderMode: AoqRenderMode = .auto) {
        guard let engine = engine else { return }
        let canvas = AoqVideoCanvas()
        canvas.view = view
        canvas.renderMode = renderMode
        let ret = engine.setRemoteView(trackType, canvas: canvas)
        appendLog("setRemoteView(\(view == nil ? "nil" : "view"), \(Self.trackLabel(trackType))) ret=\(ret)")
    }

    private static func trackLabel(_ t: AoqTrackType) -> String {
        switch t {
        case .audio:  return "Audio"
        case .video:  return "Video"
        case .data:   return "Data"
        @unknown default: return "Unknown(\(t.rawValue))"
        }
    }

    // MARK: DataMsg

    func sendDataMsg(text: String) {
        guard let engine = engine else { return }
        let msg = AoqDataMsg()
        msg.data = text.data(using: .utf8) ?? Data()
        let ret = engine.send(msg)
        print("[DataMsg][SEND] ret=\(ret) size=\(msg.data.count) \(text)")
        appendLog("sendDataMsg(\(text)) ret=\(ret)")
        let entry = OutgoingDataMsg(text: text)
        dispatchOnMain { [weak self] in
            self?.outgoingDataMsg.send(entry)
        }
    }

    func clearLog() {
        events.removeAll()
    }

    // MARK: Helpers

    fileprivate func appendLog(_ message: String) {
        let entry = LogEntry(time: Date(), message: message)
        dispatchOnMain { [weak self] in
            guard let self = self else { return }
            self.events.append(entry)
            if self.events.count > 500 {
                self.events.removeFirst(self.events.count - 500)
            }
        }
    }

    fileprivate func dispatchOnMain(_ block: @escaping () -> Void) {
        if Thread.isMainThread {
            block()
        } else {
            DispatchQueue.main.async(execute: block)
        }
    }
}

// MARK: - AoqAudioFrameDelegate

extension AoqEngineManager: AoqAudioFrameDelegate {
    func onCapturedAudioFrame(_ frame: AoqAudioFrameData) {
        writeAudioFrame(frame)
    }

    func onProcessCapturedAudioFrame(_ frame: AoqAudioFrameData) {
        writeAudioFrame(frame)
    }

    func onPublishAudioFrame(_ trackType: AoqTrackType, frame: AoqAudioFrameData) {
        writeAudioFrame(frame)
    }

    func onPlaybackAudioFrame(_ frame: AoqAudioFrameData) {
        writeAudioFrame(frame)
    }



    private func writeAudioFrame(_ frame: AoqAudioFrameData) {
        guard let ptr = frame.dataPtr else { return }
        let size = Int(frame.numOfSamples) * Int(frame.bytesPerSample) * Int(frame.numOfChannels)
        guard size > 0 else { return }
        let data = Data(bytes: ptr, count: size)
        audioWriteQueue.async { [weak self] in
            self?.audioFileHandle?.write(data)
        }
    }
}

// MARK: - AoqVideoFrameDelegate

extension AoqEngineManager: AoqVideoFrameDelegate {
    func onCapturedVideoFrame(_ frame: AoqVideoFrame) -> Bool {
        updateVideoFrameStats(source: .captured, frame: frame)
        writeVideoFrameToDump(source: .captured, frame: frame)
        return false
    }

    func onPreEncodeVideoFrame(_ trackType: AoqTrackType, frame: AoqVideoFrame) -> Bool {
        updateVideoFrameStats(source: .preEncode, frame: frame)
        writeVideoFrameToDump(source: .preEncode, frame: frame)
        return false
    }

    func onRemoteVideoFrame(_ trackType: AoqTrackType, frame: AoqVideoFrame) -> Bool {
        updateVideoFrameStats(source: .remote, frame: frame)
        writeVideoFrameToDump(source: .remote, frame: frame)
        return false
    }
}

// MARK: - Video helpers (file-level)

func videoSourceTag(_ s: AoqVideoSource) -> String {
    switch s {
    case .captured:  return "Cap"
    case .preEncode: return "PreEnc"
    case .remote:    return "Remote"
    @unknown default: return "?"
    }
}

func videoFormatTag(_ f: AoqVideoPixelFormat) -> String {
    switch f {
    case .I420:          return "I420"
    case .NV12:          return "NV12"
    case .NV21:          return "NV21"
    case .BGRA:          return "BGRA"
    case .RGBA:          return "RGBA"
    case .cvPixelBuffer: return "CVPB"
    case .unknown:       return "Unk"
    @unknown default:    return "?"
    }
}

/// 给 dump 文件一个回放命令提示（仅用于 log，不执行）。
fileprivate func playbackHint(format: AoqVideoPixelFormat, path: String) -> String {
    let pixFmt: String
    switch format {
    case .I420: pixFmt = "yuv420p"
    case .NV12: pixFmt = "nv12"
    case .NV21: pixFmt = "nv21"
    case .BGRA: pixFmt = "bgra"
    case .RGBA: pixFmt = "rgba"
    default:    pixFmt = "?"
    }
    return "ffplay -f rawvideo -pixel_format \(pixFmt) -video_size <W>x<H> -framerate 30 \(path)"
}

// MARK: - AoqEngineDelegate

extension AoqEngineManager: AoqEngineDelegate {
    func onError(_ code: Int, message: String) {
        dispatchOnMain { [weak self] in
            self?.lastError = "[\(code)] \(message)"
        }
        appendLog("onError code=\(code) msg=\(message)")
    }

    func onWarning(_ code: Int, message: String) {
        appendLog("onWarning code=\(code) msg=\(message)")
    }

    func onConnectionStatusChange(_ status: AoqConnectionStatus) {
        dispatchOnMain { [weak self] in
            guard let self = self else { return }
            self.isConnected = (status == .connected)
        }
        let tag: String
        switch status {
        case .connected:    tag = "connected"
        case .connecting:   tag = "connecting"
        case .disconnected: tag = "disconnected"
        case .failed:       tag = "failed"
        @unknown default:   tag = "unknown(\(status.rawValue))"
        }
        appendLog("onConnectionStatusChange status=\(tag)")
    }
    
    /// 收到 session.created 后通过 data 通道发送 session.update 配置
    private func sendSessionUpdate() {
        let voiceValue = SessionConfig.currentVoice
        let json = """
        {
            "event_id": "event_ToPZqeobitzUJnt3QqtWg",
            "type": "session.update",
            "session": {
                "modalities": ["text", "audio"],
                "voice": "\(voiceValue)",
                "input_audio_format": "pcm16",
                "output_audio_format": "pcm24",
                "instructions": "你是某五星级酒店的AI客服专员，请准确且友好地解答客户关于房型、设施、价格、预订政策的咨询。请始终以专业和乐于助人的态度回应，杜绝提供未经证实或超出酒店服务范围的信息。",
                "turn_detection": {"type": "server_vad", "threshold": 0.5, "silence_duration_ms": 800},
                "seed": 1314,
                "max_tokens": 16384,
                "repetition_penalty": 1.05,
                "presence_penalty": 0.0,
                "top_k": 50,
                "top_p": 1.0,
                "temperature": 0.9
            }
        }
        """
        sendDataMsg(text: json)
    }

    func onAudioDeviceStateChanged(_ state: AoqAudioDeviceState) {
        appendLog("AudioDeviceState: state=\(state.state.rawValue) reason=\(state.reason)")
    }

    func onVideoDeviceStateChanged(_ state: AoqVideoDeviceState) {
        let label: String
        switch state.state {
        case .captureStarting: label = "采集启动中"
        case .captureStarted:  label = "采集已启动"
        case .captureStopping: label = "采集停止中"
        case .captureStopped:  label = "采集已停止"
        case .captureFail:     label = "采集失败"
        default:               label = "未知(\(state.state.rawValue))"
        }
        dispatchOnMain { [weak self] in
            self?.videoDeviceStateLabel = label
        }
        appendLog("VideoDeviceState: state=\(state.state.rawValue)(\(label)) reason=\(state.reason)")
    }

    func onAudioDeviceRouteChanged(_ routeType: Int) {
        appendLog("onAudioDeviceRouteChanged routeType=\(routeType)")
    }

    func onAudioDeviceInterrupted(_ interrupt: Bool) {
        appendLog("onAudioDeviceInterrupted interrupt=\(interrupt)")
    }

    func onDataMsg(_ msg: AoqDataMsg) {
        let text = String(data: msg.data, encoding: .utf8) ?? "<\(msg.data.count) bytes>"
        print("[DataMsg][RECV] size=\(msg.data.count) \(text)")
        appendLog("onDataMsg size=\(msg.data.count) text=\(text)")
        let entry = IncomingDataMsg(text: text)
        dispatchOnMain { [weak self] in
            guard let self = self else { return }
            self.lastDataMsg = msg.data
            self.incomingDataMsg.send(entry)
        }
        handleIncomingDataMsg(msg.data)
    }

    /// 解析下行 data 消息；根据消息类型驱动状态变化
    private func handleIncomingDataMsg(_ data: Data) {
        guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let type = obj["type"] as? String else {
            return
        }

        print("[ConversationState] recv type: \(type)")

        switch type {
        case "session.created":
            if let session = obj["session"] as? [String: Any],
               let sessionId = session["id"] as? String {
                debugPrint(sessionId)
                dispatchOnMain { [weak self] in
                    self?.sessionCreated.send(sessionId)
                }
            }
            sendSessionUpdate()

        case "session.updated":
            appendLog("recv session.updated -> enable send audio/video")
            enableSendMediaStream(trackType: .audio, enable: true)
            enableSendMediaStream(trackType: .video, enable: true)
            print("[ConversationState] -> .listening (session.updated)")
            dispatchOnMain { [weak self] in
                self?.conversationStateChanged.send(.listening)
            }

        case "input_audio_buffer.speech_started":
            print("[ConversationState] -> .listening (speech_started)")
            dispatchOnMain { [weak self] in
                self?.conversationStateChanged.send(.listening)
            }

        case "input_audio_buffer.committed":
            print("[ConversationState] -> .thinking (committed)")
            dispatchOnMain { [weak self] in
                self?.conversationStateChanged.send(.thinking)
            }

        case "response.created":
            print("[ConversationState] -> .responding (response.created)")
            dispatchOnMain { [weak self] in
                self?.conversationStateChanged.send(.responding)
            }

        case "response.audio_transcript.done":
            print("[ConversationState] audio_transcript.done")

        case "response.output_item.done":
            print("[ConversationState] output_item.done")

        case "response.audio.done":
            print("[ConversationState] response.audio.done")

        case "response.done":
            print("[ConversationState] -> .listening (response.done, audio played)")
            dispatchOnMain { [weak self] in
                self?.conversationStateChanged.send(.listening)
            }

        case "error":
            let errorObj = obj["error"] as? [String: Any]
            let code = errorObj?["code"] as? String ?? ""
            if code == "data_input_inspection_failed" {
                dispatchOnMain { [weak self] in
                    self?.conversationStateChanged.send(.error(.dataInputInspectionFailed))
                }
            } else {
                dispatchOnMain { [weak self] in
                    self?.conversationStateChanged.send(.error(.unknown))
                }
            }

        default:
            break
        }
    }

    func onAudioFileState(_ state: AoqAudioFileState) {
        appendLog("AudioFileState: fileId=\(state.fileId) state=\(state.stateCode.rawValue) error=\(state.errorCode.rawValue)")
    }

    func onStats(_ stats: AoqStats) {
        var log = "[Stats]"
        if let pubs = stats.audioPublishStats {
            for s in pubs {
                log += " aPub(track=\(s.trackType.rawValue) br=\(s.bitrate) bytes=\(s.bytes) vol=\(s.encodeVolume))"
            }
        }
        if let pubs = stats.videoPublishStats {
            for s in pubs {
                log += " vPub(track=\(s.trackType.rawValue) br=\(s.bitrate) bytes=\(s.bytes) encodeFps=\(s.encodeFps))"
            }
        }
        if let pubs = stats.dataMsgPublishStats {
            for s in pubs {
                log += " dPub(track=\(s.trackType.rawValue) br=\(s.bitrate) bytes=\(s.bytes))"
            }
        }
        if let subs = stats.audioSubscribeStats {
            for s in subs {
                log += " aSub(track=\(s.trackType.rawValue) br=\(s.bitrate) bytes=\(s.bytes) vol=\(s.playVolume))"
            }
        }
        if let subs = stats.videoSubscribeStats {
            for s in subs {
                log += " vSub(track=\(s.trackType.rawValue) br=\(s.bitrate) bytes=\(s.bytes) dec=\(s.decodeFps) render=\(s.renderFps))"
            }
        }
        if let subs = stats.dataMsgSubscribeStats {
            for s in subs {
                log += " dSub(track=\(s.trackType.rawValue) br=\(s.bitrate) bytes=\(s.bytes))"
            }
        }
        if let n = stats.networkStats {
            log += " net(txBr=\(n.sendBitrate) txBytes=\(n.sendBytes) rxBr=\(n.recvBitrate) rxBytes=\(n.recvBytes) loss=\(n.loss) rtt=\(n.rtt))"
        }
        appendLog(log)
    }
}
