//
//  MPVClient.swift
//  Yattee
//
//  Low-level wrapper around libmpv for video playback.
//

import Foundation
import Libmpv
import Metal

#if os(macOS)
import OpenGL.GL
#endif

// MARK: - EDL Builder

/// Builds MPV EDL (Edit Decision List) URLs for combining separate video and audio streams.
/// EDL allows MPV to treat multiple streams as a single virtual file with unified caching.
enum EDLBuilder {
    /// Escape URL for EDL format using length-prefix encoding.
    /// Format: %<length>%<url> (e.g., %45%https://example.com/video.mp4)
    /// This is the same format used by mpv's internal ytdl_hook.lua.
    static func escape(_ url: URL) -> String {
        let urlString = url.absoluteString
        return "%\(urlString.count)%\(urlString)"
    }

    /// Build combined EDL string from separate video and audio stream URLs.
    /// The EDL format tells MPV to load both streams together with unified caching.
    ///
    /// Format: edl://!new_stream;!no_clip;!no_chapters;%<video_len>%<video_url>;!new_stream;%<audio_len>%<audio_url>
    ///
    /// Note: Returns a String, not a URL, because the EDL format contains characters
    /// that make it invalid as a Swift URL (e.g., embedded percent signs from YouTube URLs).
    /// MPV accepts this string directly in the loadfile command.
    ///
    /// - Parameters:
    ///   - video: The video stream URL
    ///   - audio: The audio stream URL
    /// - Returns: A combined EDL string for mpv's loadfile command
    static func combinedString(video: URL, audio: URL) -> String {
        "edl://!new_stream;!no_clip;!no_chapters;\(escape(video));!new_stream;\(escape(audio))"
    }
}

// MARK: - MPV Error

enum MPVError: LocalizedError {
    case initializationFailed
    case commandFailed(String)
    case propertyError(String)
    case renderContextFailed

    var errorDescription: String? {
        switch self {
        case .initializationFailed:
            return "Failed to initialize MPV"
        case .commandFailed(let cmd):
            return "MPV command failed: \(cmd)"
        case .propertyError(let prop):
            return "MPV property error: \(prop)"
        case .renderContextFailed:
            return "Failed to create MPV render context"
        }
    }
}

// MARK: - MPV Property Observer

/// Cache state information from MPV's demuxer-cache-state property.
struct MPVCacheState: Sendable {
    /// Bytes buffered ahead of current position.
    let forwardBytes: Int64
    /// Total bytes in cache.
    let totalBytes: Int64
    /// Network input rate in bytes per second.
    let inputRate: Int64
    /// Whether end of file is cached.
    let eofCached: Bool
    /// Cache duration in seconds ahead of current position.
    let cacheDuration: Double?
}

/// Version and build information from MPV.
struct MPVVersionInfo: Sendable {
    /// MPV version string (e.g., "mpv 0.37.0").
    let mpvVersion: String?
    /// FFmpeg version string.
    let ffmpegVersion: String?
    /// MPV compilation configuration/flags.
    let configuration: String?
    /// libmpv API version number.
    let apiVersion: UInt
}

protocol MPVClientDelegate: AnyObject {
    func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?)
    func mpvClient(_ client: MPVClient, didReceiveEvent event: mpv_event_id)
    func mpvClient(_ client: MPVClient, didUpdateCacheState cacheState: MPVCacheState)
    func mpvClient(_ client: MPVClient, didUpdateTrackList tracks: [MPVTrack])
    func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason, errorCode: Int32, errorString: String?)
}

enum MPVEndFileReason {
    case eof
    case stop
    case quit
    case error
    case redirect
    case unknown
}

/// A captured mpv log message. `level` matches mpv's level strings: "fatal", "error", "warn", "info", "v", "debug", "trace".
struct MPVLogLine {
    let prefix: String
    let level: String
    let text: String

    var formatted: String { "[\(prefix)/\(level)] \(text)" }

    /// Numeric severity for ranking. Higher = more severe.
    var severity: Int {
        switch level {
        case "fatal": return 5
        case "error": return 4
        case "warn":  return 3
        case "info":  return 2
        case "v":     return 1
        default:      return 0
        }
    }
}

// MARK: - MPV Client

/// Thread-safe wrapper around libmpv.
/// All mpv operations are performed on a dedicated dispatch queue.
final class MPVClient: @unchecked Sendable {
    // MARK: - Properties

    private var mpv: OpaquePointer?
    private var renderContext: OpaquePointer?
    private let mpvQueue = DispatchQueue(label: "stream.yattee.mpv.client", qos: .userInteractive)

    /// Protects `renderContext` lifecycle vs use. Render-context calls are
    /// thread-safe relative to the client API per libmpv docs, so they must NOT
    /// serialize through `mpvQueue`: any slow queue work (e.g. a batched debug
    /// property fetch) would block `render()` and visibly stall playback
    /// (~190ms hitch every 10s on tvOS, build 264 regression).
    private let renderContextLock = NSLock()

    /// Serial queue for render-update callback dispatch. Deliberately separate
    /// from `mpvQueue` so frame-ready notifications are never delayed behind
    /// property/command work.
    private let renderEventQueue = DispatchQueue(label: "stream.yattee.mpv.render-events", qos: .userInteractive)
    private var isDestroyed = false

    #if os(macOS)
    /// CGL context for thread-safe OpenGL access (macOS only).
    private var openGLContext: CGLContextObj?
    #endif

    weak var delegate: MPVClientDelegate?

    /// Whether the dedicated event-loop thread has been started (mpvQueue-guarded).
    /// The loop itself exits via `isDestroyed` + `mpv_wakeup` + `eventLoopExitSemaphore`.
    private var eventLoopRunning = false

    /// Whether a coalesced track-list refresh is pending (mpvQueue-guarded).
    /// Load and sub-add fire bursts of track-list/aid/sid notifications;
    /// they collapse into a single fetch.
    private var trackListRefreshScheduled = false

    /// Semaphore signaled when event loop exits
    private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)

    /// Ring buffer of recent mpv log messages (formatted "[prefix/level] text").
    /// Guarded by `logBufferLock`. Used to enrich load-error reports surfaced to the UI.
    private var recentLogBuffer: [MPVLogLine] = []
    private let logBufferLock = NSLock()
    private let recentLogBufferCapacity = 32

    /// Callback for render updates (called when mpv wants to redraw)
    var onRenderUpdate: (() -> Void)?

    /// Callback for when a new video frame is available (not just any redraw)
    var onVideoFrameReady: (() -> Void)?

    // MARK: - Initialization

    init() {}

    deinit {
        destroy()
    }

    // MARK: - Device Detection

    #if os(tvOS)
    /// A8-class (PowerVR Series6XT) and older GPUs: their GL driver deadlocks on
    /// the float texture uploads mpv's renderer defaults to (issue #956).
    /// Keyed on the Metal GPU family rather than the device model so every device
    /// with this GPU generation is covered, e.g. Apple TV HD (AppleTV5,3, A8).
    /// If an A10-class device ever shows the same hang, bump the check to .apple4.
    static let hasFragileGLDriver: Bool = {
        guard let device = MTLCreateSystemDefaultDevice() else { return true }
        return !device.supportsFamily(.apple3) // .apple2 = A8/A8X, .apple1 = A7
    }()
    #endif

    // MARK: - Logging Helpers

    /// Log to LoggingService from any thread (async dispatch to MainActor).
    private func log(_ message: String, details: String? = nil) {
        Task { @MainActor in
            LoggingService.shared.logMPV(message, details: details)
        }
    }

    /// Log debug message to LoggingService from any thread.
    private func logDebug(_ message: String, details: String? = nil) {
        Task { @MainActor in
            LoggingService.shared.logMPVDebug(message, details: details)
        }
    }

    /// Log warning to LoggingService from any thread.
    private func logWarning(_ message: String, details: String? = nil) {
        Task { @MainActor in
            LoggingService.shared.logMPVWarning(message, details: details)
        }
    }

    /// Log error to LoggingService from any thread.
    private func logError(_ message: String, details: String? = nil) {
        Task { @MainActor in
            LoggingService.shared.logMPVError(message)
        }
    }

    /// Handle MPV_EVENT_LOG_MESSAGE: trim, store in ring buffer, and forward to LoggingService.
    private func handleLogMessage(_ msg: mpv_event_log_message) {
        let prefix = String(cString: msg.prefix)
        let level = String(cString: msg.level)
        var text = String(cString: msg.text)
        // mpv text usually ends with a newline; trim for tidy logs.
        while text.hasSuffix("\n") || text.hasSuffix("\r") {
            text.removeLast()
        }
        guard !text.isEmpty else { return }

        let line = MPVLogLine(prefix: prefix, level: level, text: text)

        logBufferLock.lock()
        recentLogBuffer.append(line)
        if recentLogBuffer.count > recentLogBufferCapacity {
            recentLogBuffer.removeFirst(recentLogBuffer.count - recentLogBufferCapacity)
        }
        logBufferLock.unlock()

        let formatted = line.formatted
        switch level {
        case "fatal", "error":
            Task { @MainActor in LoggingService.shared.logMPVError(formatted) }
        case "warn":
            Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) }
        default:
            // Surface info always; verbose only for renderer/decoder subsystems
            // so the persisted log stays readable when verbose capture is on.
            let p = prefix.lowercased()
            let rendererRelevant = p.hasPrefix("vo") || p.contains("gpu")
                || p.contains("placebo") || p.hasPrefix("vd") || p.hasPrefix("ffmpeg")
            if level == "info" || rendererRelevant {
                Task { @MainActor in LoggingService.shared.logMPV(formatted) }
            }
        }
    }

    /// Snapshot the recent log buffer (most-recent-last). Thread-safe.
    func recentLogLines(minimumSeverity: Int = 0) -> [MPVLogLine] {
        logBufferLock.lock()
        defer { logBufferLock.unlock() }
        if minimumSeverity <= 0 { return recentLogBuffer }
        return recentLogBuffer.filter { $0.severity >= minimumSeverity }
    }

    /// Clear the recent log buffer. Call before starting a fresh load attempt.
    func clearRecentLogLines() {
        logBufferLock.lock()
        recentLogBuffer.removeAll(keepingCapacity: true)
        logBufferLock.unlock()
    }

    // MARK: - Lifecycle

    /// Initialize the MPV instance with default options.
    func initialize() throws {
        var initError: String?

        try mpvQueue.sync {
            guard mpv == nil else {
                logDebug("Already initialized")
                return
            }

            log("Creating handle...")

            // Create MPV handle
            mpv = mpv_create()
            guard mpv != nil else {
                initError = "Failed to create MPV handle"
                throw MPVError.initializationFailed
            }

            log("Handle created, configuring options...")

            // Configure default options
            configureDefaultOptions()

            log("Options configured, initializing...")

            // Initialize MPV
            let result = mpv_initialize(mpv)
            guard result >= 0 else {
                let errorString = String(cString: mpv_error_string(result))
                initError = "MPV initialization failed: \(errorString) (\(result))"
                mpv_destroy(mpv)
                mpv = nil
                throw MPVError.initializationFailed
            }

            log("Initialized, setting up property observers...")

            // Capture HTTP/demuxer/decoder errors to surface on load failure.
            // Verbose logging (when enabled) also captures renderer init details.
            let mpvLogLevel = MPVLogging.verboseEnabled ? "v" : "warn"
            let logLevelResult = mpv_request_log_messages(mpv, mpvLogLevel)
            if logLevelResult < 0 {
                logWarning("Failed to subscribe to mpv log messages: \(String(cString: mpv_error_string(logLevelResult)))")
            }

            // Log hwdec diagnostics
            #if os(tvOS)
            if let hwdec = mpv_get_property_string(mpv, "hwdec") {
                logDebug("hwdec: \(String(cString: hwdec))")
                mpv_free(hwdec)
            }
            if let hwdecCodecs = mpv_get_property_string(mpv, "hwdec-codecs") {
                logDebug("hwdec-codecs: \(String(cString: hwdecCodecs))")
                mpv_free(hwdecCodecs)
            }
            if let hwdecInterop = mpv_get_property_string(mpv, "gpu-hwdec-interop") {
                logDebug("gpu-hwdec-interop: \(String(cString: hwdecInterop))")
                mpv_free(hwdecInterop)
            }
            #endif

            // Set up property observers
            setupPropertyObservers()

            log("Starting event loop...")

            // Start event loop
            startEventLoop()

            log("Fully initialized")

            // Log version info
            logVersionInfo()
        }

        // Log errors on main thread after sync block
        if let error = initError {
            Task { @MainActor in
                LoggingService.shared.logMPVError(error)
            }
        }
    }

    /// Log MPV version and build information.
    private func logVersionInfo() {
        guard let mpv, !isDestroyed else { return }

        // Get mpv-version property
        var mpvVersion = "unknown"
        if let cString = mpv_get_property_string(mpv, "mpv-version") {
            mpvVersion = String(cString: cString)
            mpv_free(cString)
        }

        // Get ffmpeg-version property
        var ffmpegVersion = "unknown"
        if let cString = mpv_get_property_string(mpv, "ffmpeg-version") {
            ffmpegVersion = String(cString: cString)
            mpv_free(cString)
        }

        // Get libmpv API version
        let apiVersion = mpv_client_api_version()
        let apiMajor = (apiVersion >> 16) & 0xFFFF
        let apiMinor = apiVersion & 0xFFFF

        log("MPV version info",
            details: "MPV: \(mpvVersion)\nFFmpeg: \(ffmpegVersion)\nAPI: \(apiMajor).\(apiMinor)")
    }

    /// Destroy the MPV instance and release resources.
    func destroy() {
        // First, destroy render context (must happen before mpv is destroyed)
        destroyRenderContext()

        // Mark as destroyed and wake up event loop
        let hasEventLoop: Bool = mpvQueue.sync {
            guard !isDestroyed, mpv != nil else { return false }
            isDestroyed = true

            // Clear any pending operations
            pendingAudioURL = nil

            // Wake up the event loop so it can see isDestroyed and exit
            if let mpv {
                mpv_wakeup(mpv)
            }

            let hasTask = eventLoopRunning
            eventLoopRunning = false
            return hasTask
        }

        // Wait for event loop to actually exit before destroying mpv
        if hasEventLoop {
            // Wait up to 500ms for event loop to exit gracefully
            _ = eventLoopExitSemaphore.wait(timeout: .now() + 0.5)
        }

        // Now safe to destroy mpv - event loop has exited
        mpvQueue.sync {
            guard let mpvHandle = mpv else { return }
            mpv_terminate_destroy(mpvHandle)
            mpv = nil
        }
    }

    // MARK: - Configuration

    private func configureDefaultOptions() {
        guard mpv != nil else { return }

        // Video output - use libmpv for render API
        setOptionSync("vo", "libmpv")

        #if targetEnvironment(simulator)
        // Simulator-specific configuration for software rendering
        setOptionSync("hwdec", "no")  // Force software decode (no VideoToolbox in simulator)
        setOptionSync("sw-fast", "yes")  // Enable fast software rendering mode
        // Limit resolution to 720p for better simulator performance
        setOptionSync("vf", "scale=w=min(iw\\,1280):h=min(ih\\,720)")
        #else
        // Hardware decoding - use videotoolbox-copy for correct colors
        // Zero-copy (videotoolbox) causes color space issues with OpenGL ES rendering
        setOptionSync("hwdec", "videotoolbox-copy")

        // Explicitly enable all VideoToolbox-supported codecs for hardware decode
        // MPV default may not include VP9 - we need to explicitly list it
        setOptionSync("hwdec-codecs", "h264,hevc,mpeg1video,mpeg2video,mpeg4,vp9,av1,prores")
        #endif

        // Keep player open after playback ends
        setOptionSync("keep-open", "yes")

        // Start paused - wait for explicit play() call
        // This prevents audio from playing before the UI is ready
        setOptionSync("pause", "yes")

        // Color management
        setOptionSync("target-prim", "bt.709")
        setOptionSync("target-trc", "srgb")

        #if os(tvOS)
        // Avoid the float texture uploads that hang the A8 GL driver (issue #956):
        // dithering and LUT scalers (mpv 0.37+ defaults) and CPU frame uploads all
        // deadlock. Zero-copy VideoToolbox uploads via IOSurface instead.
        if Self.hasFragileGLDriver {
            setOptionSync("dither-depth", "no")
            setOptionSync("hwdec", "videotoolbox")
            setOptionSync("scale", "bilinear")
            setOptionSync("dscale", "bilinear")
            setOptionSync("cscale", "bilinear")
        }
        #endif

        // Use display-vdrop: drops/repeats frames to match display timing
        // This is lighter weight than display-resample (no interpolation overhead)
        // and handles both hardware and software decode gracefully.
        // On tvOS, the user may override this via the debug Video Sync Mode setting.
        #if os(tvOS)
        setOptionSync("video-sync", SettingsManager.tvVideoSyncModeSync().rawValue)
        #else
        setOptionSync("video-sync", "display-vdrop")
        #endif

        // Allow frame dropping when decoder can't keep up (essential for software decode)
        // decoder+vo: drop at decoder level first, then at video output if still behind
        setOptionSync("framedrop", "decoder+vo")

        // Audio
        setOptionSync("audio-client-name", "Yattee")
        #if os(tvOS)
        // Prefer avfoundation (AVSampleBufferAudioRenderer): audiounit can't open
        // 32-channel HDMI routes (Atmos "Continuous Audio Output") and stays silent.
        // mpv falls back to audiounit if avfoundation fails to initialize.
        setOptionSync("ao", "avfoundation,audiounit")
        #elseif os(iOS)
        setOptionSync("ao", "audiounit")
        #elseif os(macOS)
        // coreaudio is the native macOS AO, but on macOS 27 beta it fails to
        // initialize ("unable to set the input channel layout on the audio
        // unit … -50"), leaving playback silent. Worse, mpv's coreaudio AO
        // registers a HAL hotplug listener before the step that fails and its
        // init-failure path never unregisters it, so every failed init leaves
        // a dangling listener that crashes (use-after-free) on the next audio
        // device change. On macOS 27+ put avfoundation first so the failing
        // coreaudio init never runs; older macOS keeps native coreaudio with
        // avfoundation as fallback.
        if ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 27 {
            setOptionSync("ao", "avfoundation,coreaudio")
        } else {
            setOptionSync("ao", "coreaudio,avfoundation")
        }
        #endif

        // Cache settings for network streams
        setOptionSync("cache", "yes")
        #if os(tvOS)
        // Apple TV can decode 1080p60 easily, but aggressively filling a large
        // demuxer cache during startup competes with the first seconds of
        // rendering. Keep enough readahead for smooth playback without racing
        // hundreds of seconds ahead immediately after load/seek.
        setOptionSync("cache-secs", "30")
        setOptionSync("demuxer-readahead-secs", "20")
        setOptionSync("demuxer-max-bytes", "24MiB")
        setOptionSync("demuxer-max-back-bytes", "8MiB")
        #else
        setOptionSync("demuxer-max-bytes", "50MiB")
        setOptionSync("demuxer-max-back-bytes", "25MiB")
        #endif

        // Logging - minimal logging for release builds
        setOptionSync("terminal", "no")
        setOptionSync("msg-level", "all=v")

        // User agent for HTTP requests - use the user's configured setting
        setOptionSync("user-agent", SettingsManager.currentUserAgent())

        // Apply subtitle appearance settings
        applySubtitleSettings()

        // Apply user-configured fixed audio delay on tvOS (in seconds; setting is ms)
        #if os(tvOS)
        let delaySeconds = SettingsManager.tvAudioDelayMsSync() / 1000.0
        setOptionSync("audio-delay", String(delaySeconds))
        #endif

        // Apply user's custom MPV options (these can override defaults)
        applyCustomOptions()
    }

    /// Update the `audio-delay` property on a running MPV instance. Safe to call
    /// while playback is active. Value is in milliseconds; converted to seconds
    /// before being sent to MPV.
    func updateAudioDelay(milliseconds: Double) {
        mpvQueue.async { [weak self] in
            guard let self, let mpv = self.mpv, !self.isDestroyed else { return }
            let seconds = milliseconds / 1000.0
            let str = String(seconds)
            str.withCString { ptr in
                _ = mpv_set_property_string(mpv, "audio-delay", ptr)
            }
        }
    }

    /// Apply subtitle appearance settings from user preferences.
    private func applySubtitleSettings() {
        let subtitleSettings = SettingsManager.subtitleSettingsSync()
        let options = subtitleSettings.mpvOptions()
        for (name, value) in options {
            log("Applying subtitle option", details: "\(name)=\(value)")
            setOptionSync(name, value)
        }
    }

    /// Update subtitle settings on a running MPV instance.
    /// Uses mpv_set_property_string which works during playback (unlike mpv_set_option_string).
    func updateSubtitleSettings() {
        mpvQueue.async { [weak self] in
            guard let self, let mpv = self.mpv, !self.isDestroyed else { return }

            let subtitleSettings = SettingsManager.subtitleSettingsSync()
            let options = subtitleSettings.mpvOptions()

            for (name, value) in options {
                self.log("Updating subtitle property", details: "\(name)=\(value)")
                mpv_set_property_string(mpv, name, value)
            }
        }
    }

    /// Apply user-defined custom MPV options from settings.
    /// These are applied after default options and can override them.
    private func applyCustomOptions() {
        let customOptions = SettingsManager.customMPVOptionsSync()
        for (name, value) in customOptions {
            log("Applying custom option", details: "\(name)=\(value)")
            setOptionSync(name, value)
        }
    }

    /// Set option synchronously (for use during initialization on mpvQueue).
    private func setOptionSync(_ name: String, _ value: String) {
        guard let mpv else { return }
        let result = mpv_set_option_string(mpv, name, value)
        if result < 0 {
            logWarning("Failed to set option \(name)=\(value)", details: String(cString: mpv_error_string(result)))
        }
    }

    private func setupPropertyObservers() {
        guard mpv != nil else { return }

        // Observe key properties
        observeProperty("time-pos", format: MPV_FORMAT_DOUBLE)
        observeProperty("duration", format: MPV_FORMAT_DOUBLE)
        observeProperty("pause", format: MPV_FORMAT_FLAG)
        observeProperty("eof-reached", format: MPV_FORMAT_FLAG)
        observeProperty("demuxer-cache-time", format: MPV_FORMAT_DOUBLE)
        observeProperty("demuxer-cache-state", format: MPV_FORMAT_NODE)
        observeProperty("speed", format: MPV_FORMAT_DOUBLE)
        observeProperty("volume", format: MPV_FORMAT_DOUBLE)
        observeProperty("mute", format: MPV_FORMAT_FLAG)
        observeProperty("core-idle", format: MPV_FORMAT_FLAG)
        observeProperty("seeking", format: MPV_FORMAT_FLAG)
        // Cache-related properties for debugging playback start issues
        observeProperty("paused-for-cache", format: MPV_FORMAT_FLAG)
        observeProperty("cache-buffering-state", format: MPV_FORMAT_INT64)
        // Video dimensions for aspect ratio detection
        observeProperty("width", format: MPV_FORMAT_INT64)
        observeProperty("height", format: MPV_FORMAT_INT64)
        // Video FPS for display link frame rate matching (avoid sync fetch on main thread)
        observeProperty("container-fps", format: MPV_FORMAT_DOUBLE)
        // Video codec info for hwdec diagnostics (avoid sync fetch on main thread)
        observeProperty("video-codec", format: MPV_FORMAT_STRING)
        observeProperty("hwdec-current", format: MPV_FORMAT_STRING)
        observeProperty("hwdec-interop", format: MPV_FORMAT_STRING)
        // Color transfer for tvOS dynamic-range matching (SDR / HDR10 / HLG)
        observeProperty("video-params/gamma", format: MPV_FORMAT_STRING)
        // Track roster and selection: change signals only — the values are
        // ignored and a coalesced track-list fetch runs instead.
        observeProperty("track-list", format: MPV_FORMAT_NONE)
        observeProperty("aid", format: MPV_FORMAT_STRING)
        observeProperty("sid", format: MPV_FORMAT_STRING)
    }

    // MARK: - Options

    /// Set an MPV option (must be called before initialize or after load).
    func setOption(_ name: String, _ value: String) {
        mpvQueue.async { [weak self] in
            guard let mpv = self?.mpv else { return }
            mpv_set_option_string(mpv, name, value)
        }
    }

    // MARK: - Playback Control

    /// Load a file/URL for playback.
    /// - Parameters:
    ///   - url: The video URL to play
    ///   - audioURL: Optional separate audio track URL (for video-only streams)
    ///   - httpHeaders: Optional HTTP headers for streaming (cookies, referer, etc.)
    ///   - useEDL: If true and audioURL is provided, combine streams using EDL for unified caching
    ///   - disableVideoTrack: If true, load with the video track disabled (audio mode for muxed files)
    ///   - options: Additional MPV options
    func loadFile(_ url: URL, audioURL: URL? = nil, httpHeaders: [String: String]? = nil, useEDL: Bool = true, disableVideoTrack: Bool = false, options: [String] = []) throws {
        try mpvQueue.sync {
            guard mpv != nil, !isDestroyed else {
                throw MPVError.commandFailed("loadfile")
            }

            // Video track selection for the upcoming file. Set as a property
            // before the loadfile command (per-file loadfile options moved to
            // the 4th argument in mpv 0.38+, so they can't be relied on here).
            // Only ever changed between loads, never live - live `vid` toggling
            // causes A/V desync (see handlePlayerSheetVisibility).
            setPropertyUnsafe("vid", disableVideoTrack ? "no" : "auto")
            if disableVideoTrack {
                logDebug("Video track disabled for this load (audio mode)")
            }

            // Set HTTP headers as a property before loading (if provided)
            // Use MPV_FORMAT_NODE_ARRAY for proper header handling
            if let httpHeaders, !httpHeaders.isEmpty {
                let headerArray = httpHeaders.map { "\($0.key): \($0.value)" }
                logDebug("Setting \(headerArray.count) HTTP headers", details: headerArray.map { String($0.prefix(50)) }.joined(separator: ", "))
                setStringArrayPropertyUnsafe("http-header-fields", headerArray)
            } else {
                // Clear any previously set headers
                setStringArrayPropertyUnsafe("http-header-fields", [])
            }

            // Determine what to load
            // NOTE: EDL mode doesn't work with HTTP headers - the http-header-fields property
            // only applies to the EDL URL itself, not to the embedded stream URLs.
            // When streams require headers (cookies, user-agent, etc.), we must use traditional
            // loadfile + audio-add approach instead of EDL.
            let hasHeaders = httpHeaders != nil && !httpHeaders!.isEmpty
            let shouldUseEDL = useEDL && !hasHeaders
            
            let loadString: String
            if let audioURL, shouldUseEDL {
                // Use EDL to combine video and audio into single virtual file
                // This provides unified caching and better A/V sync
                let edlString = EDLBuilder.combinedString(video: url, audio: audioURL)
                log("loadFile using EDL", details: edlString)
                loadString = edlString
                pendingAudioURL = nil  // No need to add audio separately
            } else {
                // Fall back to loading video first, then adding audio via audio-add
                // This is required when HTTP headers are needed (EDL doesn't support headers on embedded URLs)
                if hasHeaders {
                    log("loadFile (non-EDL due to HTTP headers)", details: url.absoluteString)
                } else {
                    log("loadFile", details: url.absoluteString)
                }
                if let audioURL {
                    logDebug("will add separate audio after load", details: audioURL.absoluteString)
                }
                loadString = url.absoluteString
                pendingAudioURL = audioURL
            }

            var args = ["loadfile", loadString]
            if !options.isEmpty {
                args.append("replace")
                args.append(options.joined(separator: ","))
            }

            log("Command", details: args.joined(separator: " "))
            try commandThrowingUnsafe(args)
        }
    }

    /// Pending audio URL to be added after file loads (accessed only on mpvQueue).
    private var pendingAudioURL: URL?

    /// Add an external audio track. Call this after the file is loaded.
    private func addExternalAudioUnsafe(_ url: URL) {
        // Must be called on mpvQueue
        guard mpv != nil, !isDestroyed else { return }

        logDebug("Adding external audio", details: url.absoluteString)

        // audio-add <url> [<flags> [<title> [<lang>]]]
        // flags: "select" to auto-select, "auto" for default behavior, "cached" for cached
        let args = ["audio-add", url.absoluteString, "select"]
        _ = commandUnsafe(args)
    }

    /// Adds pending audio track if any. Must be called on mpvQueue.
    private func addPendingAudioIfNeeded() {
        guard let audioURL = pendingAudioURL else { return }
        pendingAudioURL = nil
        addExternalAudioUnsafe(audioURL)
    }

    /// Start or resume playback.
    func play() {
        setProperty("pause", false)
    }

    /// Pause playback.
    func pause() {
        setProperty("pause", true)
    }

    /// Stop playback and clear the playlist.
    func stop() {
        command(["stop"])
    }

    /// Seek to a specific time in seconds.
    func seek(to time: Double, mode: String = "absolute") {
        command(["seek", String(time), mode])
    }

    /// Seek to a specific time in seconds asynchronously (non-blocking).
    /// Use this to avoid blocking the calling thread during seeks.
    func seekAsync(to time: Double, mode: String = "absolute") {
        commandAsync(["seek", String(time), mode])
    }

    /// Seek by a relative offset in seconds.
    func seekRelative(_ offset: Double) {
        command(["seek", String(offset), "relative"])
    }

    /// Seek by a relative offset in seconds asynchronously (non-blocking).
    func seekRelativeAsync(_ offset: Double) {
        commandAsync(["seek", String(offset), "relative"])
    }

    // MARK: - Subtitles

    /// Add an external subtitle file.
    /// - Parameters:
    ///   - url: The subtitle URL (VTT, SRT, etc.)
    ///   - select: Whether to automatically select this subtitle
    func addSubtitle(_ url: URL, select: Bool = true) {
        // sub-add <url> [<flags> [<title> [<lang>]]]
        let flags = select ? "select" : "auto"
        command(["sub-add", url.absoluteString, flags])
    }

    /// Add an external subtitle file asynchronously (does not block caller).
    /// Use this for remote URLs to avoid blocking the UI during download.
    /// - Parameters:
    ///   - url: The subtitle URL (VTT, SRT, etc.)
    ///   - select: Whether to automatically select this subtitle
    func addSubtitleAsync(_ url: URL, select: Bool = true) {
        let flags = select ? "select" : "auto"
        commandAsync(["sub-add", url.absoluteString, flags])
    }

    /// Disable subtitles (set sid to "no").
    func disableSubtitles() {
        setProperty("sid", "no")
    }

    /// Enable subtitles by selecting the first track (if any).
    func enableSubtitles() {
        setProperty("sid", "auto")
    }

    /// Remove all external subtitle tracks.
    func removeAllSubtitles() {
        command(["sub-remove"])
    }

    /// Remove all external subtitle tracks asynchronously.
    func removeAllSubtitlesAsync() {
        commandAsync(["sub-remove"])
    }

    // MARK: - Embedded Tracks

    /// Select an audio track by mpv track id (live, no reload). nil disables audio.
    func setAudioTrack(_ trackID: Int?) {
        setProperty("aid", trackID.map(String.init) ?? "no")
    }

    /// Select a subtitle track by mpv track id (embedded or external). nil disables subtitles.
    func setSubtitleTrack(_ trackID: Int?) {
        setProperty("sid", trackID.map(String.init) ?? "no")
    }

    /// Schedule a coalesced fetch of `track-list`, delivered via
    /// `mpvClient(_:didUpdateTrackList:)`. Safe to call from any thread.
    private func scheduleTrackListRefresh() {
        mpvQueue.async { [weak self] in
            guard let self, !self.trackListRefreshScheduled, !self.isDestroyed else { return }
            self.trackListRefreshScheduled = true
            self.mpvQueue.asyncAfter(deadline: .now() + 0.08) { [weak self] in
                guard let self else { return }
                self.trackListRefreshScheduled = false
                self.refreshTrackListUnsafe()
            }
        }
    }

    /// Read and deliver the current track list (must be called on mpvQueue).
    private func refreshTrackListUnsafe() {
        guard let mpv, !isDestroyed else { return }
        guard let cString = mpv_get_property_string(mpv, "track-list") else { return }
        let json = String(cString: cString)
        mpv_free(cString)

        let tracks = Self.parseTrackList(json: json)
        if tracks.isEmpty, json.count > 2 {
            logWarning("Failed to parse track-list", details: String(json.prefix(500)))
        }
        Task { @MainActor [weak self] in
            guard let self else { return }
            self.delegate?.mpvClient(self, didUpdateTrackList: tracks)
        }
    }

    /// Decode mpv's JSON representation of `track-list`. Lenient: a single
    /// undecodable entry is dropped instead of blanking the whole list.
    static func parseTrackList(json: String) -> [MPVTrack] {
        struct FailableTrack: Decodable {
            let track: MPVTrack?
            init(from decoder: Decoder) {
                track = try? MPVTrack(from: decoder)
            }
        }

        guard let data = json.data(using: .utf8),
              let entries = try? JSONDecoder().decode([FailableTrack].self, from: data) else {
            return []
        }
        return entries.compactMap(\.track)
    }

    // MARK: - Properties

    /// ⚠️ **THREADING WARNING FOR SYNC GETTERS**: The synchronous property getters below
    /// (getFlag, getDouble, getString, getInt, getCacheState, getVersionInfo, getDebugProperties)
    /// use `mpvQueue.sync` which **blocks the calling thread** until the property is fetched.
    ///
    /// **NEVER call these from @MainActor code** - they cause UI hangs (300ms+) that trigger
    /// iOS watchdog warnings. Hang logs show this pattern:
    /// ```
    /// Main Thread → getFlag() → mpvQueue.sync → mpv_get_property() → mp_dispatch_lock() → blocked
    /// ```
    ///
    /// **Safe usage:**
    /// - ✅ From background threads/queues where blocking is acceptable
    /// - ✅ During initialization (before UI is shown)
    /// - ✅ In non-critical paths where 100-500ms delay is acceptable
    ///
    /// **Use async variants instead:**
    /// - From @MainActor code: Use `getFlagAsync()`, `getDoubleAsync()`, etc.
    /// - For frequently accessed properties: Use property observation (delegate callbacks)
    /// - For diagnostics/logging: Use `Task.detached` with async getters
    ///
    /// **Why blocking is necessary:**
    /// libmpv is not thread-safe - all property access must be serialized through `mpvQueue`.
    /// The `sync` dispatch ensures the caller gets the current value, but requires blocking.

    /// Set a property value.
    func setProperty(_ name: String, _ value: Any) {
        mpvQueue.async { [weak self] in
            self?.setPropertyUnsafe(name, value)
        }
    }

    /// Set a property value synchronously. Use when timing is critical.
    func setPropertySync(_ name: String, _ value: Any) {
        mpvQueue.sync { [weak self] in
            self?.setPropertyUnsafe(name, value)
        }
    }

    /// Internal property setter (must be called on mpvQueue).
    private func setPropertyUnsafe(_ name: String, _ value: Any) {
        guard let mpv, !isDestroyed else { return }

        switch value {
        case let boolValue as Bool:
            var flag: Int32 = boolValue ? 1 : 0
            mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &flag)

        case let intValue as Int:
            var int64 = Int64(intValue)
            mpv_set_property(mpv, name, MPV_FORMAT_INT64, &int64)

        case let doubleValue as Double:
            var double = doubleValue
            mpv_set_property(mpv, name, MPV_FORMAT_DOUBLE, &double)

        case let stringValue as String:
            stringValue.withCString { cString in
                var ptr: UnsafePointer<CChar>? = cString
                mpv_set_property(mpv, name, MPV_FORMAT_STRING, &ptr)
            }

        case let stringArray as [String]:
            // For properties like http-header-fields that expect an array of strings
            setStringArrayPropertyUnsafe(name, stringArray)

        default:
            break
        }
    }

    /// Set a string array property using MPV_FORMAT_NODE_ARRAY.
    private func setStringArrayPropertyUnsafe(_ name: String, _ values: [String]) {
        guard let mpv, !isDestroyed else { return }

        // Create array of mpv_node for each string
        var nodeList = values.map { str -> mpv_node in
            var node = mpv_node()
            node.format = MPV_FORMAT_STRING
            // We need to keep the C string alive, so we'll use strdup
            node.u.string = strdup(str)
            return node
        }

        // Create the node array
        var list = mpv_node_list()
        list.num = Int32(nodeList.count)

        nodeList.withUnsafeMutableBufferPointer { buffer in
            list.values = buffer.baseAddress

            var node = mpv_node()
            node.format = MPV_FORMAT_NODE_ARRAY
            withUnsafeMutablePointer(to: &list) { listPtr in
                node.u.list = listPtr
                mpv_set_property(mpv, name, MPV_FORMAT_NODE, &node)
            }
        }

        // Free the strdup'd strings
        for node in nodeList {
            free(node.u.string)
        }
    }

    /// Get a double property value.
    /// **⚠️ BLOCKS CALLING THREAD** - Use getDoubleAsync() from @MainActor code.
    func getDouble(_ name: String) -> Double? {
        mpvQueue.sync {
            guard let mpv, !isDestroyed else { return nil }

            var value: Double = 0
            let result = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
            return result >= 0 ? value : nil
        }
    }

    /// Get a boolean property value.
    /// **⚠️ BLOCKS CALLING THREAD** - Use getFlagAsync() from @MainActor code.
    func getFlag(_ name: String) -> Bool? {
        mpvQueue.sync {
            guard let mpv, !isDestroyed else { return nil }

            var value: Int32 = 0
            let result = mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &value)
            return result >= 0 ? (value != 0) : nil
        }
    }

    /// Get a string property value.
    /// **⚠️ BLOCKS CALLING THREAD** - Use getStringAsync() from @MainActor code.
    func getString(_ name: String) -> String? {
        mpvQueue.sync {
            guard let mpv, !isDestroyed else { return nil }

            guard let cString = mpv_get_property_string(mpv, name) else {
                return nil
            }
            let string = String(cString: cString)
            mpv_free(cString)
            return string
        }
    }

    /// Get an integer property value.
    /// **⚠️ BLOCKS CALLING THREAD** - Use getIntAsync() from @MainActor code.
    func getInt(_ name: String) -> Int? {
        mpvQueue.sync {
            guard let mpv, !isDestroyed else { return nil }

            var value: Int64 = 0
            let result = mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value)
            return result >= 0 ? Int(value) : nil
        }
    }

    /// Get the demuxer cache state.
    /// **⚠️ BLOCKS CALLING THREAD** - Use getCacheStateAsync() from @MainActor code.
    func getCacheState() -> MPVCacheState? {
        mpvQueue.sync {
            guard let mpv, !isDestroyed else { return nil }

            var node = mpv_node()
            let result = mpv_get_property(mpv, "demuxer-cache-state", MPV_FORMAT_NODE, &node)
            guard result >= 0 else {
                return nil
            }

            defer {
                mpv_free_node_contents(&node)
            }

            return parseCacheStateSync(node)
        }
    }

    /// Get MPV version and build information.
    /// **⚠️ BLOCKS CALLING THREAD** - Use getVersionInfoAsync() from @MainActor code.
    func getVersionInfo() -> MPVVersionInfo? {
        mpvQueue.sync {
            guard let mpv, !isDestroyed else { return nil }

            // Get mpv-version property
            var mpvVersion: String?
            if let cString = mpv_get_property_string(mpv, "mpv-version") {
                mpvVersion = String(cString: cString)
                mpv_free(cString)
            }

            // Get ffmpeg-version property
            var ffmpegVersion: String?
            if let cString = mpv_get_property_string(mpv, "ffmpeg-version") {
                ffmpegVersion = String(cString: cString)
                mpv_free(cString)
            }

            // Get mpv-configuration property (compilation flags)
            var configuration: String?
            if let cString = mpv_get_property_string(mpv, "mpv-configuration") {
                configuration = String(cString: cString)
                mpv_free(cString)
            }

            // Get libmpv API version
            let apiVersion = mpv_client_api_version()

            return MPVVersionInfo(
                mpvVersion: mpvVersion,
                ffmpegVersion: ffmpegVersion,
                configuration: configuration,
                apiVersion: UInt(apiVersion)
            )
        }
    }

    /// Raw debug properties fetched in a single sync block to minimize lock contention.
    struct DebugProperties {
        var videoCodec: String?
        var hwdecCurrent: String?
        var width: Int?
        var height: Int?
        var containerFps: Double?
        var estimatedVfFps: Double?
        var audioCodecName: String?
        var audioSampleRate: Int?
        var audioChannels: Int?
        var frameDropCount: Int?
        var mistimedFrameCount: Int?
        var voDelayedFrameCount: Int?
        var avsync: Double?
        var estimatedFrameNumber: Int?
        var demuxerCacheDuration: Double?
        var fileFormat: String?
        var cacheState: MPVCacheState?
        // tvOS-specific
        var videoSync: String?
        var displayFps: Double?
        var vsyncJitter: Double?
        var videoSpeedCorrection: Double?
        var audioSpeedCorrection: Double?
        var framedrop: String?
        var audioDelay: Double?
        var currentVo: String?
        var currentAo: String?
        var audioDevice: String?
        var displayWidth: Int?
        var displayHeight: Int?
        var displayNames: String?
        var decoderFrameDropCount: Int?
    }

    /// Fetch all debug properties in a single sync block to avoid multiple lock acquisitions.
    /// **⚠️ BLOCKS CALLING THREAD** - Use getDebugPropertiesAsync() from @MainActor code.
    func getDebugProperties() -> DebugProperties {
        mpvQueue.sync {
            guard let mpv, !isDestroyed else { return DebugProperties() }

            var props = DebugProperties()

            // Helper functions for property fetching
            func getString(_ name: String) -> String? {
                guard let cString = mpv_get_property_string(mpv, name) else { return nil }
                let string = String(cString: cString)
                mpv_free(cString)
                return string
            }

            func getDouble(_ name: String) -> Double? {
                var value: Double = 0
                let result = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
                return result >= 0 ? value : nil
            }

            func getInt(_ name: String) -> Int? {
                var value: Int64 = 0
                let result = mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value)
                return result >= 0 ? Int(value) : nil
            }

            // Video info
            props.videoCodec = getString("video-codec")
            props.hwdecCurrent = getString("hwdec-current")
            props.width = getInt("width")
            props.height = getInt("height")
            props.containerFps = getDouble("container-fps")
            props.estimatedVfFps = getDouble("estimated-vf-fps")

            // Audio info
            props.audioCodecName = getString("audio-codec-name")
            props.audioSampleRate = getInt("audio-params/samplerate")
            props.audioChannels = getInt("audio-params/channel-count")

            // Playback stats
            props.frameDropCount = getInt("frame-drop-count")
            props.mistimedFrameCount = getInt("mistimed-frame-count")
            props.voDelayedFrameCount = getInt("vo-delayed-frame-count")
            props.avsync = getDouble("avsync")
            props.estimatedFrameNumber = getInt("estimated-frame-number")

            // Cache/Network
            props.demuxerCacheDuration = getDouble("demuxer-cache-duration")

            // Container
            props.fileFormat = getString("file-format")

            // Cache state (complex property)
            var node = mpv_node()
            if mpv_get_property(mpv, "demuxer-cache-state", MPV_FORMAT_NODE, &node) >= 0 {
                props.cacheState = parseCacheStateSync(node)
                mpv_free_node_contents(&node)
            }

            // Video Sync stats (tvOS)
            #if os(tvOS)
            props.videoSync = getString("video-sync")
            props.displayFps = getDouble("display-fps")
            props.vsyncJitter = getDouble("vsync-jitter")
            props.videoSpeedCorrection = getDouble("video-speed-correction")
            props.audioSpeedCorrection = getDouble("audio-speed-correction")
            props.framedrop = getString("framedrop")
            props.audioDelay = getDouble("audio-delay")
            props.currentVo = getString("current-vo")
            props.currentAo = getString("current-ao")
            props.audioDevice = getString("audio-device")
            props.displayWidth = getInt("display-width")
            props.displayHeight = getInt("display-height")
            props.displayNames = getString("display-names")
            props.decoderFrameDropCount = getInt("decoder-frame-drop-count")
            #endif

            return props
        }
    }

    // MARK: - Async Property Getters (Non-Blocking)

    /// Get a boolean property value asynchronously (non-blocking).
    /// Use this from MainActor code instead of getFlag() to avoid blocking the UI.
    /// - Parameter name: The property name (e.g., "pause", "idle-active")
    /// - Returns: The boolean value, or nil if the property doesn't exist or MPV is destroyed
    func getFlagAsync(_ name: String) async -> Bool? {
        await withCheckedContinuation { continuation in
            mpvQueue.async { [weak self] in
                guard let self, let mpv = self.mpv, !self.isDestroyed else {
                    continuation.resume(returning: nil)
                    return
                }
                var value: Int32 = 0
                let result = mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &value)
                continuation.resume(returning: result >= 0 ? (value != 0) : nil)
            }
        }
    }

    /// Get a double property value asynchronously (non-blocking).
    /// Use this from MainActor code instead of getDouble() to avoid blocking the UI.
    /// - Parameter name: The property name (e.g., "time-pos", "duration")
    /// - Returns: The double value, or nil if the property doesn't exist or MPV is destroyed
    func getDoubleAsync(_ name: String) async -> Double? {
        await withCheckedContinuation { continuation in
            mpvQueue.async { [weak self] in
                guard let self, let mpv = self.mpv, !self.isDestroyed else {
                    continuation.resume(returning: nil)
                    return
                }
                var value: Double = 0
                let result = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
                continuation.resume(returning: result >= 0 ? value : nil)
            }
        }
    }

    /// Get a string property value asynchronously (non-blocking).
    /// Use this from MainActor code instead of getString() to avoid blocking the UI.
    /// - Parameter name: The property name (e.g., "video-codec", "hwdec-current")
    /// - Returns: The string value, or nil if the property doesn't exist or MPV is destroyed
    func getStringAsync(_ name: String) async -> String? {
        await withCheckedContinuation { continuation in
            mpvQueue.async { [weak self] in
                guard let self, let mpv = self.mpv, !self.isDestroyed else {
                    continuation.resume(returning: nil)
                    return
                }
                guard let cString = mpv_get_property_string(mpv, name) else {
                    continuation.resume(returning: nil)
                    return
                }
                let string = String(cString: cString)
                mpv_free(cString)
                continuation.resume(returning: string)
            }
        }
    }

    /// Get an integer property value asynchronously (non-blocking).
    /// Use this from MainActor code instead of getInt() to avoid blocking the UI.
    /// - Parameter name: The property name (e.g., "width", "height")
    /// - Returns: The integer value, or nil if the property doesn't exist or MPV is destroyed
    func getIntAsync(_ name: String) async -> Int? {
        await withCheckedContinuation { continuation in
            mpvQueue.async { [weak self] in
                guard let self, let mpv = self.mpv, !self.isDestroyed else {
                    continuation.resume(returning: nil)
                    return
                }
                var value: Int64 = 0
                let result = mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value)
                continuation.resume(returning: result >= 0 ? Int(value) : nil)
            }
        }
    }

    /// Get the demuxer cache state asynchronously (non-blocking).
    /// Use this from MainActor code instead of getCacheState() to avoid blocking the UI.
    /// - Returns: The cache state information, or nil if unavailable or MPV is destroyed
    func getCacheStateAsync() async -> MPVCacheState? {
        await withCheckedContinuation { continuation in
            mpvQueue.async { [weak self] in
                guard let self, let mpv = self.mpv, !self.isDestroyed else {
                    continuation.resume(returning: nil)
                    return
                }
                
                var node = mpv_node()
                let result = mpv_get_property(mpv, "demuxer-cache-state", MPV_FORMAT_NODE, &node)
                guard result >= 0 else {
                    continuation.resume(returning: nil)
                    return
                }
                
                let cacheState = self.parseCacheStateSync(node)
                mpv_free_node_contents(&node)
                continuation.resume(returning: cacheState)
            }
        }
    }

    /// Get MPV version and build information asynchronously (non-blocking).
    /// Use this from MainActor code instead of getVersionInfo() to avoid blocking the UI.
    /// - Returns: The version information, or nil if unavailable or MPV is destroyed
    func getVersionInfoAsync() async -> MPVVersionInfo? {
        await withCheckedContinuation { continuation in
            mpvQueue.async { [weak self] in
                guard let self, let mpv = self.mpv, !self.isDestroyed else {
                    continuation.resume(returning: nil)
                    return
                }
                
                // Get mpv-version property
                var mpvVersion: String?
                if let cString = mpv_get_property_string(mpv, "mpv-version") {
                    mpvVersion = String(cString: cString)
                    mpv_free(cString)
                }
                
                // Get ffmpeg-version property
                var ffmpegVersion: String?
                if let cString = mpv_get_property_string(mpv, "ffmpeg-version") {
                    ffmpegVersion = String(cString: cString)
                    mpv_free(cString)
                }
                
                // Get mpv-configuration property
                var configuration: String?
                if let cString = mpv_get_property_string(mpv, "mpv-configuration") {
                    configuration = String(cString: cString)
                    mpv_free(cString)
                }
                
                let apiVersion = mpv_client_api_version()
                
                let info = MPVVersionInfo(
                    mpvVersion: mpvVersion,
                    ffmpegVersion: ffmpegVersion,
                    configuration: configuration,
                    apiVersion: UInt(apiVersion)
                )
                continuation.resume(returning: info)
            }
        }
    }

    /// Fetch all debug properties asynchronously in a single operation (non-blocking).
    /// Use this from MainActor code instead of getDebugProperties() to avoid blocking the UI.
    /// This method batches all property fetches into one async operation for efficiency.
    /// - Returns: A DebugProperties struct with all fetched values
    func getDebugPropertiesAsync() async -> DebugProperties {
        await withCheckedContinuation { continuation in
            mpvQueue.async { [weak self] in
                guard let self, let mpv = self.mpv, !self.isDestroyed else {
                    continuation.resume(returning: DebugProperties())
                    return
                }
                
                var props = DebugProperties()
                
                // Helper functions for property fetching
                func getString(_ name: String) -> String? {
                    guard let cString = mpv_get_property_string(mpv, name) else { return nil }
                    let string = String(cString: cString)
                    mpv_free(cString)
                    return string
                }
                
                func getDouble(_ name: String) -> Double? {
                    var value: Double = 0
                    let result = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
                    return result >= 0 ? value : nil
                }
                
                func getInt(_ name: String) -> Int? {
                    var value: Int64 = 0
                    let result = mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value)
                    return result >= 0 ? Int(value) : nil
                }
                
                // Video info
                props.videoCodec = getString("video-codec")
                props.hwdecCurrent = getString("hwdec-current")
                props.width = getInt("width")
                props.height = getInt("height")
                props.containerFps = getDouble("container-fps")
                props.estimatedVfFps = getDouble("estimated-vf-fps")
                
                // Audio info
                props.audioCodecName = getString("audio-codec-name")
                props.audioSampleRate = getInt("audio-params/samplerate")
                props.audioChannels = getInt("audio-params/channel-count")
                
                // Playback stats
                props.frameDropCount = getInt("frame-drop-count")
                props.mistimedFrameCount = getInt("mistimed-frame-count")
                props.voDelayedFrameCount = getInt("vo-delayed-frame-count")
                props.avsync = getDouble("avsync")
                props.estimatedFrameNumber = getInt("estimated-frame-number")
                
                // Cache/Network
                props.demuxerCacheDuration = getDouble("demuxer-cache-duration")
                
                // Container
                props.fileFormat = getString("file-format")
                
                // Cache state (complex property)
                var node = mpv_node()
                if mpv_get_property(mpv, "demuxer-cache-state", MPV_FORMAT_NODE, &node) >= 0 {
                    props.cacheState = self.parseCacheStateSync(node)
                    mpv_free_node_contents(&node)
                }
                
                // Video Sync stats (tvOS)
                #if os(tvOS)
                props.videoSync = getString("video-sync")
                props.displayFps = getDouble("display-fps")
                props.vsyncJitter = getDouble("vsync-jitter")
                props.videoSpeedCorrection = getDouble("video-speed-correction")
                props.audioSpeedCorrection = getDouble("audio-speed-correction")
                props.framedrop = getString("framedrop")
                props.audioDelay = getDouble("audio-delay")
                props.currentVo = getString("current-vo")
                props.currentAo = getString("current-ao")
                props.audioDevice = getString("audio-device")
                props.displayWidth = getInt("display-width")
                props.displayHeight = getInt("display-height")
                props.displayNames = getString("display-names")
                props.decoderFrameDropCount = getInt("decoder-frame-drop-count")
                #endif

                continuation.resume(returning: props)
            }
        }
    }

    /// Parse mpv_node map into MPVCacheState (sync version for use on mpvQueue).
    private func parseCacheStateSync(_ node: mpv_node) -> MPVCacheState? {
        guard node.format == MPV_FORMAT_NODE_MAP,
              let list = node.u.list else { return nil }

        var forwardBytes: Int64 = 0
        var totalBytes: Int64 = 0
        var inputRate: Int64 = 0
        var eofCached = false
        var cacheDuration: Double?

        let count = Int(list.pointee.num)
        for i in 0..<count {
            guard let keyPtr = list.pointee.keys?[i] else { continue }
            let key = String(cString: keyPtr)
            let valueNode = list.pointee.values[i]

            switch key {
            case "fw-bytes":
                if valueNode.format == MPV_FORMAT_INT64 {
                    forwardBytes = valueNode.u.int64
                }
            case "total-bytes":
                if valueNode.format == MPV_FORMAT_INT64 {
                    totalBytes = valueNode.u.int64
                }
            case "raw-input-rate":
                if valueNode.format == MPV_FORMAT_INT64 {
                    inputRate = valueNode.u.int64
                }
            case "eof-cached":
                if valueNode.format == MPV_FORMAT_FLAG {
                    eofCached = valueNode.u.flag != 0
                }
            case "cache-duration":
                if valueNode.format == MPV_FORMAT_DOUBLE {
                    cacheDuration = valueNode.u.double_
                }
            default:
                break
            }
        }

        return MPVCacheState(
            forwardBytes: forwardBytes,
            totalBytes: totalBytes,
            inputRate: inputRate,
            eofCached: eofCached,
            cacheDuration: cacheDuration
        )
    }

    // MARK: - Property Observation

    private func observeProperty(_ name: String, format: mpv_format) {
        guard let mpv else { return }
        mpv_observe_property(mpv, 0, name, format)
    }

    // MARK: - Commands

    /// Send a command to MPV (public, acquires lock).
    @discardableResult
    func command(_ args: [String]) -> Bool {
        mpvQueue.sync {
            commandUnsafe(args)
        }
    }

    /// Send a command to MPV asynchronously using mpv_command_async.
    /// This returns immediately and doesn't block the mpvQueue during execution.
    /// Use this for commands that may take time (e.g., loading remote subtitles).
    func commandAsync(_ args: [String]) {
        mpvQueue.async { [weak self] in
            guard let self, let mpv = self.mpv, !self.isDestroyed else { return }

            self.logDebug("Async command", details: args.joined(separator: " "))

            // Convert strings to C strings
            var cStrings = args.map { strdup($0) }
            cStrings.append(nil)

            // Use mpv_command_async which returns immediately
            cStrings.withUnsafeMutableBufferPointer { buffer in
                var constPtrs = buffer.map { UnsafePointer($0) }
                // Use @discardableResult pattern - result type is Void so we can just call it
                constPtrs.withUnsafeMutableBufferPointer { constBuffer in
                    // reply_userdata 0 means we don't care about the result
                    _ = mpv_command_async(mpv, 0, constBuffer.baseAddress)
                }
            }

            // Free the strings
            for ptr in cStrings where ptr != nil {
                free(ptr)
            }
        }
    }

    /// Send a command to MPV without locking (must be called on mpvQueue).
    private func commandUnsafe(_ args: [String]) -> Bool {
        guard let mpv, !isDestroyed else {
            logWarning("Command failed: not initialized or destroyed")
            return false
        }

        logDebug("Command", details: args.joined(separator: " "))

        // Convert strings to C strings
        var cStrings = args.map { strdup($0) }
        cStrings.append(nil)

        // Create array of const pointers for mpv_command
        let result = cStrings.withUnsafeMutableBufferPointer { buffer -> Int32 in
            // Cast mutable pointers to const pointers
            var constPtrs = buffer.map { UnsafePointer($0) }
            return constPtrs.withUnsafeMutableBufferPointer { constBuffer in
                mpv_command(mpv, constBuffer.baseAddress)
            }
        }

        // Free the strings
        for ptr in cStrings where ptr != nil {
            free(ptr)
        }

        if result < 0 {
            let errorString = String(cString: mpv_error_string(result))
            logWarning("Command '\(args.first ?? "")' failed", details: "\(errorString) (\(result))")
        }

        return result >= 0
    }

    /// Send a command to MPV, throwing on failure (must be called on mpvQueue).
    private func commandThrowingUnsafe(_ args: [String]) throws {
        let success = commandUnsafe(args)
        if !success {
            throw MPVError.commandFailed(args.joined(separator: " "))
        }
    }

    // MARK: - Event Loop

    private func startEventLoop() {
        // Dedicated OS thread, not the Swift cooperative pool: runEventLoop blocks
        // in mpv_wait_event for the client's whole lifetime, and as a Task it would
        // permanently occupy a pool thread. On a 2-core device the two live clients
        // (active + pre-warmed) would hold both pool threads and starve every await
        // in the app (issue #956).
        eventLoopRunning = true
        let thread = Thread { [weak self] in
            self?.runEventLoop()
        }
        thread.name = "stream.yattee.mpv.events"
        thread.qualityOfService = .userInitiated
        thread.start()
    }

    private func runEventLoop() {
        defer {
            // Signal that event loop has exited
            eventLoopExitSemaphore.signal()
        }

        while !isDestroyed {
            guard let mpv else { break }

            // Wait for events with a short timeout
            let event = mpv_wait_event(mpv, 0.1)
            guard let event else { continue }

            let eventId = event.pointee.event_id

            // Exit on shutdown
            if eventId == MPV_EVENT_SHUTDOWN {
                break
            }

            // Skip none events
            if eventId == MPV_EVENT_NONE {
                continue
            }

            // Process event
            processEvent(event.pointee)
        }
    }

    private func processEvent(_ event: mpv_event) {
        let eventId = event.event_id

        switch eventId {
        case MPV_EVENT_PROPERTY_CHANGE:
            if let data = event.data {
                let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee
                handlePropertyChange(property)
            }

         case MPV_EVENT_END_FILE:
            if let data = event.data {
                let endFile = data.assumingMemoryBound(to: mpv_event_end_file.self).pointee
                let reason = mapEndFileReason(endFile.reason)

                let isErrorReason = endFile.reason == MPV_END_FILE_REASON_ERROR
                let errorCode: Int32 = isErrorReason ? endFile.error : 0
                let errorString: String? = isErrorReason ? String(cString: mpv_error_string(errorCode)) : nil

                if isErrorReason, let mappedString = errorString {
                    logError("End file with error", details: "code=\(errorCode), message=\(mappedString)")
                    Task { @MainActor in
                        LoggingService.shared.logMPVError("MPV end-file error: \(mappedString) (code: \(errorCode))")
                    }
                }

                Task { @MainActor [weak self] in
                    guard let self else { return }
                    self.delegate?.mpvClientDidEndFile(self, reason: reason, errorCode: errorCode, errorString: errorString)
                }
            }

        case MPV_EVENT_LOG_MESSAGE:
            if let data = event.data {
                let msg = data.assumingMemoryBound(to: mpv_event_log_message.self).pointee
                handleLogMessage(msg)
            }

        case MPV_EVENT_FILE_LOADED:
            // Add pending external audio track after file is loaded (dispatch to mpvQueue for thread safety)
            mpvQueue.async { [weak self] in
                self?.addPendingAudioIfNeeded()
            }
            Task { @MainActor [weak self] in
                guard let self else { return }
                self.delegate?.mpvClient(self, didReceiveEvent: eventId)
            }

        case MPV_EVENT_PLAYBACK_RESTART, MPV_EVENT_SEEK:
            Task { @MainActor [weak self] in
                guard let self else { return }
                self.delegate?.mpvClient(self, didReceiveEvent: eventId)
            }

        default:
            break
        }
    }

    private func handlePropertyChange(_ property: mpv_event_property) {
        let name = String(cString: property.name)

        // Any change to the track roster or selection funnels into one
        // coalesced track-list refresh; the raw property values are unused.
        if name == "track-list" || name == "aid" || name == "sid" {
            scheduleTrackListRefresh()
            return
        }

        var value: Any?

        switch property.format {
        case MPV_FORMAT_DOUBLE:
            if let ptr = property.data {
                value = ptr.assumingMemoryBound(to: Double.self).pointee
            }

        case MPV_FORMAT_FLAG:
            if let ptr = property.data {
                let flag = ptr.assumingMemoryBound(to: Int32.self).pointee
                value = flag != 0
            }

        case MPV_FORMAT_INT64:
            if let ptr = property.data {
                value = ptr.assumingMemoryBound(to: Int64.self).pointee
            }

        case MPV_FORMAT_STRING:
            if let ptr = property.data {
                let cString = ptr.assumingMemoryBound(to: UnsafePointer<CChar>.self).pointee
                value = String(cString: cString)
            }

        case MPV_FORMAT_NODE:
            // Handle demuxer-cache-state specially
            if name == "demuxer-cache-state", let ptr = property.data {
                let node = ptr.assumingMemoryBound(to: mpv_node.self).pointee
                if let cacheState = parseCacheState(node) {
                    Task { @MainActor [weak self] in
                        guard let self else { return }
                        self.delegate?.mpvClient(self, didUpdateCacheState: cacheState)
                    }
                }
                return
            }

        case MPV_FORMAT_NONE:
            // Property unavailable
            break

        default:
            break
        }

        let capturedValue = value
        Task { @MainActor [weak self] in
            guard let self else { return }
            self.delegate?.mpvClient(self, didUpdateProperty: name, value: capturedValue)
        }
    }

    /// Parse mpv_node map into MPVCacheState.
    private func parseCacheState(_ node: mpv_node) -> MPVCacheState? {
        guard node.format == MPV_FORMAT_NODE_MAP,
              let list = node.u.list else { return nil }

        var forwardBytes: Int64 = 0
        var totalBytes: Int64 = 0
        var inputRate: Int64 = 0
        var eofCached = false
        var cacheDuration: Double?

        let count = Int(list.pointee.num)
        for i in 0..<count {
            guard let keyPtr = list.pointee.keys?[i] else { continue }
            let key = String(cString: keyPtr)
            let valueNode = list.pointee.values[i]

            switch key {
            case "fw-bytes":
                if valueNode.format == MPV_FORMAT_INT64 {
                    forwardBytes = valueNode.u.int64
                }
            case "total-bytes":
                if valueNode.format == MPV_FORMAT_INT64 {
                    totalBytes = valueNode.u.int64
                }
            case "raw-input-rate":
                if valueNode.format == MPV_FORMAT_INT64 {
                    inputRate = valueNode.u.int64
                }
            case "eof-cached":
                if valueNode.format == MPV_FORMAT_FLAG {
                    eofCached = valueNode.u.flag != 0
                }
            case "cache-duration":
                if valueNode.format == MPV_FORMAT_DOUBLE {
                    cacheDuration = valueNode.u.double_
                }
            default:
                break
            }
        }

        return MPVCacheState(
            forwardBytes: forwardBytes,
            totalBytes: totalBytes,
            inputRate: inputRate,
            eofCached: eofCached,
            cacheDuration: cacheDuration
        )
    }

    private func mapEndFileReason(_ reason: mpv_end_file_reason) -> MPVEndFileReason {
        switch reason {
        case MPV_END_FILE_REASON_EOF:
            return .eof
        case MPV_END_FILE_REASON_STOP:
            return .stop
        case MPV_END_FILE_REASON_QUIT:
            return .quit
        case MPV_END_FILE_REASON_ERROR:
            return .error
        case MPV_END_FILE_REASON_REDIRECT:
            return .redirect
        default:
            return .unknown
        }
    }

    // MARK: - Render Context

    /// Get the MPV handle for render context creation.
    /// Must be called on mpvQueue.
    var mpvHandle: OpaquePointer? {
        mpvQueue.sync { mpv }
    }

    /// Create a render context for OpenGL rendering.
    /// - Parameter getProcAddress: Function to get OpenGL proc addresses
    /// - Returns: Whether creation succeeded
    func createRenderContext(getProcAddress: @escaping @convention(c) (UnsafeMutableRawPointer?, UnsafePointer<CChar>?) -> UnsafeMutableRawPointer?) -> Bool {
        mpvQueue.sync {
            guard let mpv, renderContext == nil else {
                logWarning("Cannot create render context", details: "mpv=\(self.mpv != nil), renderContext=\(self.renderContext != nil)")
                MPVLogging.warn("createRenderContext: precondition failed",
                    details: "mpv:\(self.mpv != nil) renderContext:\(self.renderContext != nil)")
                return false
            }

            log("Creating OpenGL render context...")
            MPVLogging.log("createRenderContext: starting")

            // Set up OpenGL parameters
            var apiType = MPV_RENDER_API_TYPE_OPENGL
            var glInitParams = mpv_opengl_init_params(
                get_proc_address: getProcAddress,
                get_proc_address_ctx: nil
            )

            var ctx: OpaquePointer?
            let result = withUnsafeMutablePointer(to: &apiType) { apiTypePtr in
                withUnsafeMutablePointer(to: &glInitParams) { glInitParamsPtr in
                    var params: [mpv_render_param] = [
                        mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: apiTypePtr),
                        mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: glInitParamsPtr),
                        mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
                    ]
                    return params.withUnsafeMutableBufferPointer { paramsPtr in
                        mpv_render_context_create(&ctx, mpv, paramsPtr.baseAddress)
                    }
                }
            }

            if result < 0 {
                let errorString = String(cString: mpv_error_string(result))
                logError("Failed to create render context", details: "\(errorString) (\(result))")
                MPVLogging.warn("createRenderContext: FAILED", details: "\(errorString) (\(result))")
                return false
            }

            renderContextLock.lock()
            renderContext = ctx
            renderContextLock.unlock()
            log("Render context created successfully")
            MPVLogging.log("createRenderContext: success")

            // Set up render update callback
            if let ctx = renderContext {
                let clientPtr = Unmanaged.passUnretained(self).toOpaque()
                mpv_render_context_set_update_callback(ctx, { clientPtr in
                    guard let clientPtr else { return }
                    let client = Unmanaged<MPVClient>.fromOpaque(clientPtr).takeUnretainedValue()

                    #if os(macOS)
                    // On macOS with CAOpenGLLayer, don't call mpv_render_context_update() here!
                    // That function clears the frame-ready flag after returning it.
                    // If we call it here, then canDraw() will call it again and get 0.
                    // Instead, just notify the layer to display - canDraw() will check the flag.
                    client.onRenderUpdate?()
                    #else
                    // On iOS/tvOS, check if this update includes an actual video frame.
                    // This is needed for PiP frame capture which relies on onVideoFrameReady.
                    // Dispatched on renderEventQueue (NOT mpvQueue) so frame notifications
                    // are never delayed behind slow property/command work.
                    client.renderEventQueue.async {
                        client.renderContextLock.lock()
                        var hasVideoFrame = false
                        if let renderCtx = client.renderContext, !client.isDestroyed {
                            let flags = mpv_render_context_update(renderCtx)
                            hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
                        }
                        client.renderContextLock.unlock()

                        // Always notify for general redraw
                        client.onRenderUpdate?()

                        // Notify specifically when there's a video frame
                        if hasVideoFrame {
                            client.onVideoFrameReady?()
                        }
                    }
                    #endif
                }, clientPtr)
            }

            return true
        }
    }

    /// Render a frame to the current OpenGL framebuffer.
    /// - Parameters:
    ///   - fbo: Framebuffer object ID (0 for default)
    ///   - width: Render width in pixels
    ///   - height: Render height in pixels
    /// - Note: This may trigger a priority inversion warning because MPV's internal
    ///   threads run at default QoS. This is unavoidable when using mpv_render_context_render.
    func render(fbo: Int32, width: Int32, height: Int32) {
        renderContextLock.lock()
        defer { renderContextLock.unlock() }

        guard let renderContext, !isDestroyed else {
            // Log when render is skipped to help diagnose black screen issues
            MPVLogging.warn("render: skipped",
                details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
            return
        }

        // GL_RGBA8 = 0x8058
        var fboData = mpv_opengl_fbo(
            fbo: fbo,
            w: width,
            h: height,
            internal_format: 0x8058
        )

        var flipY: Int32 = 1

        withUnsafeMutablePointer(to: &fboData) { fboPtr in
            withUnsafeMutablePointer(to: &flipY) { flipPtr in
                var params: [mpv_render_param] = [
                    mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: fboPtr),
                    mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flipPtr),
                    mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
                ]
                _ = params.withUnsafeMutableBufferPointer { paramsPtr in
                    mpv_render_context_render(renderContext, paramsPtr.baseAddress)
                }
            }
        }
    }

    /// Create a render context for software rendering (CPU-based, for simulator).
    /// - Returns: Whether creation succeeded
    func createSoftwareRenderContext() -> Bool {
        mpvQueue.sync {
            guard let mpv, renderContext == nil else {
                logWarning("Cannot create software render context", details: "mpv=\(self.mpv != nil), renderContext=\(self.renderContext != nil)")
                MPVLogging.warn("createSoftwareRenderContext: precondition failed",
                    details: "mpv:\(self.mpv != nil) renderContext:\(self.renderContext != nil)")
                return false
            }

            log("Creating software render context...")
            MPVLogging.log("createSoftwareRenderContext: starting")

            // Set up software rendering API type
            var apiType = MPV_RENDER_API_TYPE_SW
            
            var ctx: OpaquePointer?
            let result = withUnsafeMutablePointer(to: &apiType) { apiTypePtr in
                var params: [mpv_render_param] = [
                    mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: apiTypePtr),
                    mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
                ]
                return params.withUnsafeMutableBufferPointer { paramsPtr in
                    mpv_render_context_create(&ctx, mpv, paramsPtr.baseAddress)
                }
            }

            if result < 0 {
                let errorString = String(cString: mpv_error_string(result))
                logError("Failed to create software render context", details: "\(errorString) (\(result))")
                MPVLogging.warn("createSoftwareRenderContext: FAILED", details: "\(errorString) (\(result))")
                return false
            }

            renderContextLock.lock()
            renderContext = ctx
            renderContextLock.unlock()
            log("Software render context created successfully")
            MPVLogging.log("createSoftwareRenderContext: success")

            // Set up render update callback
            // Software rendering is only used on iOS/tvOS simulator, so use the full callback
            if let ctx = renderContext {
                let clientPtr = Unmanaged.passUnretained(self).toOpaque()
                mpv_render_context_set_update_callback(ctx, { clientPtr in
                    guard let clientPtr else { return }
                    let client = Unmanaged<MPVClient>.fromOpaque(clientPtr).takeUnretainedValue()

                    // Check if this update includes an actual video frame
                    client.renderEventQueue.async {
                        client.renderContextLock.lock()
                        var hasVideoFrame = false
                        if let renderCtx = client.renderContext, !client.isDestroyed {
                            let flags = mpv_render_context_update(renderCtx)
                            hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
                        }
                        client.renderContextLock.unlock()

                        // Always notify for general redraw
                        client.onRenderUpdate?()

                        // Notify specifically when there's a video frame
                        if hasVideoFrame {
                            client.onVideoFrameReady?()
                        }
                    }
                }, clientPtr)
            }

            return true
        }
    }

    /// Render a frame to a software buffer (CPU-based rendering).
    /// - Parameters:
    ///   - buffer: Pointer to the pixel buffer (RGBA format)
    ///   - width: Render width in pixels
    ///   - height: Render height in pixels
    ///   - stride: Bytes per line (row stride)
    /// - Returns: true if a frame was rendered, false otherwise
    @discardableResult
    func renderSoftware(buffer: UnsafeMutableRawPointer, width: Int32, height: Int32, stride: Int) -> Bool {
        renderContextLock.lock()
        defer { renderContextLock.unlock() }
        return {
            guard let renderContext, !isDestroyed else {
                return false
            }
            
            // Check if there's a frame ready to render
            let updateFlags = mpv_render_context_update(renderContext)
            let hasFrame = (updateFlags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue)) != 0
            
            if !hasFrame {
                // No frame ready, skip rendering
                return false
            }
            
            // MPV software rendering requires:
            // - MPV_RENDER_PARAM_SW_SIZE: int[2] array with [width, height]
            // - MPV_RENDER_PARAM_SW_FORMAT: C string with format name
            // - MPV_RENDER_PARAM_SW_STRIDE: size_t* pointing to stride value
            // - MPV_RENDER_PARAM_SW_POINTER: void* to pixel buffer
            
            // Stride as size_t (UInt on 64-bit)
            var strideValue: size_t = size_t(stride)
            
            // Format string - "rgb0" means RGB with padding byte (RGBX), which matches our RGBA buffer
            let format = "rgb0"
            
            // Create a contiguous buffer for the size array
            let sizeBuffer = UnsafeMutableBufferPointer<Int32>.allocate(capacity: 2)
            defer { sizeBuffer.deallocate() }
            sizeBuffer[0] = width
            sizeBuffer[1] = height
            
            let result: Int32 = format.withCString { formatPtr in
                withUnsafeMutablePointer(to: &strideValue) { stridePtr in
                    var params: [mpv_render_param] = [
                        mpv_render_param(type: MPV_RENDER_PARAM_SW_SIZE, data: sizeBuffer.baseAddress),
                        mpv_render_param(type: MPV_RENDER_PARAM_SW_FORMAT, data: UnsafeMutableRawPointer(mutating: formatPtr)),
                        mpv_render_param(type: MPV_RENDER_PARAM_SW_STRIDE, data: stridePtr),
                        mpv_render_param(type: MPV_RENDER_PARAM_SW_POINTER, data: buffer),
                        mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
                    ]
                    
                    return mpv_render_context_render(renderContext, &params)
                }
            }
            
            if result < 0 {
                let errorStr = String(cString: mpv_error_string(result))
                MPVLogging.log("renderSoftware: FAILED - error=\(result) (\(errorStr))")
                return false
            }

            return true
        }()
    }

    /// Report that the next frame should be rendered.
    func reportRenderUpdate() {
        renderEventQueue.async { [weak self] in
            guard let self else { return }
            self.renderContextLock.lock()
            var hasFrame = false
            if let renderContext = self.renderContext, !self.isDestroyed {
                let flags = mpv_render_context_update(renderContext)
                hasFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
            }
            self.renderContextLock.unlock()
            if hasFrame {
                self.onRenderUpdate?()
            }
        }
    }

    /// Destroy the render context.
    func destroyRenderContext() {
        // mpvQueue serializes against create; renderContextLock excludes
        // in-flight render/update calls while the context is freed.
        mpvQueue.sync {
            renderContextLock.lock()
            defer { renderContextLock.unlock() }
            guard let ctx = renderContext else {
                MPVLogging.log("destroyRenderContext: no context to destroy")
                return
            }
            MPVLogging.log("destroyRenderContext: destroying")
            mpv_render_context_free(ctx)
            renderContext = nil
            MPVLogging.log("destroyRenderContext: complete")
        }
    }

    /// Whether the render context is initialized.
    var hasRenderContext: Bool {
        renderContextLock.lock()
        defer { renderContextLock.unlock() }
        return renderContext != nil
    }

    // MARK: - macOS OpenGL Context Management

    #if os(macOS)
    /// Store the CGL context for locking during render operations.
    func setOpenGLContext(_ ctx: CGLContextObj) {
        mpvQueue.sync {
            openGLContext = ctx
        }
    }

    /// Lock the OpenGL context and make it current (call before GL operations from other threads).
    func lockAndSetOpenGLContext() {
        guard let ctx = openGLContext else { return }
        CGLLockContext(ctx)
        CGLSetCurrentContext(ctx)
    }

    /// Unlock the OpenGL context.
    func unlockOpenGLContext() {
        guard let ctx = openGLContext else { return }
        CGLUnlockContext(ctx)
    }
    #endif

    // MARK: - Frame Timing

    /// Report that a frame was swapped/presented (for vsync timing).
    /// Called synchronously from the render thread right after present - direct
    /// call (no queue hop) keeps the swap timestamp accurate for mpv's vsync
    /// estimation and avoids any queue-contention delay.
    func reportSwap() {
        renderContextLock.lock()
        defer { renderContextLock.unlock() }
        guard let ctx = renderContext, !isDestroyed else { return }
        mpv_render_context_report_swap(ctx)
    }

    /// Check if MPV has a frame ready to render (non-blocking).
    /// This is safe to call from any thread - mpv's render context API is thread-safe.
    func shouldRenderUpdateFrame() -> Bool {
        renderContextLock.lock()
        defer { renderContextLock.unlock() }
        guard let ctx = renderContext, !isDestroyed else { return false }
        let flags = mpv_render_context_update(ctx)
        return flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
    }

    /// Get the render context directly (for thread-safe mpv render operations).
    var mpvRenderContext: OpaquePointer? {
        renderContextLock.lock()
        defer { renderContextLock.unlock() }
        return renderContext
    }

    // MARK: - Rendering with Depth

    /// Render a frame to the current OpenGL framebuffer with specified color depth.
    /// - Parameters:
    ///   - fbo: Framebuffer object ID
    ///   - width: Render width in pixels
    ///   - height: Render height in pixels
    ///   - depth: Color depth (8 or 16 for 10-bit)
    func renderWithDepth(fbo: Int32, width: Int32, height: Int32, depth: Int32) {
        renderContextLock.lock()
        defer { renderContextLock.unlock() }

        guard let renderContext, !isDestroyed else {
            MPVLogging.warn("renderWithDepth: skipped",
                details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
            return
        }

        // GL_RGBA8 = 0x8058
        var fboData = mpv_opengl_fbo(
            fbo: fbo,
            w: width,
            h: height,
            internal_format: 0x8058
        )

        var flipY: Int32 = 1
        var bufferDepth = depth

        withUnsafeMutablePointer(to: &fboData) { fboPtr in
            withUnsafeMutablePointer(to: &flipY) { flipPtr in
                withUnsafeMutablePointer(to: &bufferDepth) { depthPtr in
                    var params: [mpv_render_param] = [
                        mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: fboPtr),
                        mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flipPtr),
                        mpv_render_param(type: MPV_RENDER_PARAM_DEPTH, data: depthPtr),
                        mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
                    ]
                    _ = params.withUnsafeMutableBufferPointer { paramsPtr in
                        mpv_render_context_render(renderContext, paramsPtr.baseAddress)
                    }
                }
            }
        }
    }
}
