//
//  MPVBackend.swift
//  Yattee
//
//  MPV-based player backend supporting all video formats.
//

import Foundation
import AVFoundation
import SwiftUI
import Combine
import Libmpv
import CoreMedia
import CoreVideo

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

/// MPV-based player backend implementation.
/// Supports all video formats including VP9, AV1, and DASH.
@MainActor
final class MPVBackend: PlayerBackend {
    // MARK: - PlayerBackend Properties

    let backendType: PlayerBackendType = .mpv

    weak var delegate: PlayerBackendDelegate?

    private(set) var currentTime: TimeInterval = 0
    private(set) var duration: TimeInterval = 0
    private(set) var bufferedTime: TimeInterval = 0
    private(set) var isReady: Bool = false
    private(set) var isPlaying: Bool = false
    private(set) var hasReachedEOF: Bool = false

    var rate: Float {
        get { _rate }
        set {
            _rate = newValue
            mpvClient?.setProperty("speed", Double(newValue))
        }
    }

    var volume: Float {
        get { _volume }
        set {
            _volume = newValue
            mpvClient?.setProperty("volume", Double(newValue * 100))
        }
    }

    var isMuted: Bool {
        get { _isMuted }
        set {
            _isMuted = newValue
            mpvClient?.setProperty("mute", newValue)
        }
    }

    /// Panscan value (0.0 = aspect fit with black bars, 1.0 = aspect fill/crop)
    var panscan: Double {
        get { _panscan }
        set {
            _panscan = max(0, min(1, newValue))
            mpvClient?.setProperty("panscan", _panscan)
        }
    }

    var supportedFormats: Set<StreamFormat> {
        Set(StreamFormat.allCases) // MPV supports all formats
    }

    // MARK: - Public Properties

    /// MPV version and build information (available after initialization).
    private(set) var versionInfo: MPVVersionInfo?

    // MARK: - Private Properties

    private var mpvClient: MPVClient?
    #if os(macOS)
    private var renderView: MPVOGLView?
    #elseif targetEnvironment(simulator) && (os(iOS) || os(tvOS))
    private var renderView: MPVSoftwareRenderView?
    #else
    private var renderView: MPVRenderView?
    #endif

    private var _rate: Float = 1.0
    private var _volume: Float = 1.0
    private var _isMuted: Bool = false
    private var _panscan: Double = 0.0

    private var currentStream: Stream?
    private var currentAudioStream: Stream?
    private var currentCaption: Caption?
    private var pendingAutoplay: Bool = false

    /// Tracks reported by mpv for the loaded file (embedded and external).
    private(set) var currentTracks: [MPVTrack] = []

    // Retry mechanism: 4 attempts with increasing timeouts and delays
    // Attempt 1: 3s timeout, 1s delay | Attempt 2: 3s timeout, 3s delay
    // Attempt 3: 10s timeout, 5s delay | Attempt 4: 10s timeout, fail
    private var retryCount = 0
    private let retryDelays: [TimeInterval] = [1, 3, 5]  // Delays between attempts (3 delays = 4 attempts)
    private let loadTimeouts: [TimeInterval] = [3, 3, 10, 10]  // Timeout per attempt
    private var isInitialLoading = false  // Prevents event handler from interfering during load
    private var currentLoadingID: UUID?   // Tracks current load operation for cancellation
    private var isWaitingForExternalAudio = false  // True when waiting for external audio track to load

    // Captured detail from the most recent failed load attempt (mpv error string + recent log lines).
    // Surfaced through BackendError.loadFailed so users see the underlying cause.
    private var lastLoadErrorDetail: String?
    // Set when MPV reports an END_FILE error during initial load so waitForReady can fail fast.
    private var loadFailedDuringWait = false

    // Buffer stall detection - triggers stream refresh when buffer stuck at 0% for too long
    private var bufferStallStartTime: Date?
    private let bufferStallTimeout: TimeInterval = 30  // Trigger refresh after 30 seconds of stall
    private var bufferStallCheckTask: Task<Void, Never>?

    // Periodic playback stats logging so exported user logs can diagnose
    // stuttering / cache starvation remotely (GitHub #947/#949)
    private var playbackStatsTask: Task<Void, Never>?

    // Video dimensions for aspect ratio detection
    private var videoWidth: Int = 0
    private var videoHeight: Int = 0
    // Coalesces separate width/height property events into one PiP size update
    private var pipVideoSizeUpdateTask: Task<Void, Never>?
    // Cached video FPS to avoid sync fetch on main thread
    private var containerFps: Double = 0
    // Cached cache state to avoid sync fetch on main thread
    private var demuxerCacheTime: Double = 0
    private var cacheBufferingState: Int = 0
    private var pausedForCache: Bool = false
    // Cached codec info for hwdec diagnostics
    private var videoCodec: String = ""
    private var hwdecCurrent: String = ""
    private var hwdecInterop: String = ""
    #if os(tvOS)
    private var videoGamma: String = ""
    #endif
    
    // Async initialization tracking
    private var setupTask: Task<Void, Error>?
    private(set) var isSetupComplete = false
    private let setupStartTime = Date()

    #if os(iOS)
    #if targetEnvironment(simulator)
    private var _playerView: MPVSoftwareRenderView?
    #else
    private var _playerView: MPVRenderView?
    #endif
    var playerView: UIView? { _playerView }

    // PiP support using AVSampleBufferDisplayLayer bridge
    private var pipBridge: MPVPiPBridge?

    /// Whether PiP is currently active.
    var isPiPActive: Bool { pipBridge?.isPiPActive ?? false }

    /// Whether PiP is possible.
    var isPiPPossible: Bool { pipBridge?.isPiPPossible ?? false }

    /// Callback for when user wants to restore from PiP to main app.
    /// Set by PlayerService to expand the player sheet.
    var onRestoreFromPiP: (() async -> Void)?

    /// Callback for when PiP starts.
    /// Set by PlayerService to collapse the player sheet.
    var onPiPDidStart: (() -> Void)?

    /// Pause video rendering (for smooth panscan animation)
    func pauseRendering() {
        MPVLogging.log("MPVBackend.pauseRendering called")
        _playerView?.pauseRendering()
    }

    /// Resume video rendering
    func resumeRendering() {
        MPVLogging.log("MPVBackend.resumeRendering called")
        _playerView?.resumeRendering()
    }
    #elseif os(tvOS)
    #if targetEnvironment(simulator)
    private var _playerView: MPVSoftwareRenderView?
    #else
    private var _playerView: MPVRenderView?
    #endif
    var playerView: UIView? { _playerView }
    #elseif os(macOS)
    private var _playerView: MPVOGLView?
    var playerView: NSView? { _playerView }

    // PiP support using AVSampleBufferDisplayLayer bridge
    private var pipBridge: MPVPiPBridge?

    /// Whether PiP is currently active.
    var isPiPActive: Bool { pipBridge?.isPiPActive ?? false }

    /// Whether PiP is possible.
    var isPiPPossible: Bool { pipBridge?.isPiPPossible ?? false }

    /// Callback for when user wants to restore from PiP to main app.
    var onRestoreFromPiP: (() async -> Void)?

    /// Callback for when PiP starts.
    var onPiPDidStart: (() -> Void)?

    /// Callback for when PiP stops without restore (user clicked close button in PiP).
    var onPiPDidStopWithoutRestore: (() -> Void)?

    /// Whether PiP setup has been completed
    private var isPiPSetUp = false

    /// Reference to player state for updating PiP availability
    private weak var pipPlayerState: PlayerState?

    /// Reference to container view for updating layer frame
    private weak var pipContainerView: NSView?

    /// Pause video rendering
    func pauseRendering() {
        MPVLogging.log("MPVBackend.pauseRendering called")
        _playerView?.pauseRendering()
    }

    /// Resume video rendering
    func resumeRendering() {
        MPVLogging.log("MPVBackend.resumeRendering called")
        _playerView?.resumeRendering()
    }
    #endif

    // MARK: - Initialization

    init() {
        // Don't call setupMPV() here - it will be called via beginSetup()
        // This makes init() fast and non-blocking
    }

    deinit {
        MainActor.assumeIsolated {
            cleanup()
        }
    }
    
    /// Begin async initialization (non-blocking).
    /// Call this immediately after creating the backend to start setup in background.
    func beginSetup() {
        guard setupTask == nil else {
            MPVLogging.log("MPVBackend.beginSetup: already started")
            return
        }
        
        MPVLogging.log("MPVBackend.beginSetup: starting async setup")
        
        setupTask = Task { @MainActor in
            await setupMPVAsync()
        }
    }
    
    /// Wait for setup to complete.
    /// Call this before loading streams to ensure backend is ready.
    func waitForSetup() async throws {
        guard let task = setupTask else {
            // Not started yet - begin now
            MPVLogging.log("MPVBackend.waitForSetup: setup not started, beginning now")
            beginSetup()
            guard let task = setupTask else {
                throw MPVRenderError.openGLSetupFailed
            }
            try await task.value
            return
        }
        try await task.value
    }

    // MARK: - Setup

    /// Async setup - moves heavy OpenGL initialization off main thread.
    private func setupMPVAsync() async {
        let startTime = Date()
        MPVLogging.log("setupMPVAsync: starting")

        // Create MPV client (fast, no blocking)
        let client = MPVClient()
        client.delegate = self
        mpvClient = client

        // Create render view (fast on main thread)
        #if os(macOS)
        let view = MPVOGLView()
        renderView = view
        _playerView = view
        MPVLogging.log("setupMPVAsync: macOS render view created")
        #elseif os(iOS)
        #if targetEnvironment(simulator)
        // Use software rendering in simulator (OpenGL ES not available)
        let view = MPVSoftwareRenderView()
        renderView = view
        _playerView = view
        MPVLogging.log("setupMPVAsync: software render view created (iOS simulator)")
        #else
        let view = MPVRenderView()
        renderView = view
        _playerView = view
        MPVLogging.log("setupMPVAsync: OpenGL render view created (iOS device)")
        #endif
        #elseif os(tvOS)
        #if targetEnvironment(simulator)
        // Use software rendering in simulator (OpenGL ES not available)
        let view = MPVSoftwareRenderView()
        renderView = view
        _playerView = view
        MPVLogging.log("setupMPVAsync: software render view created (tvOS simulator)")
        #else
        let view = MPVRenderView()
        renderView = view
        _playerView = view
        MPVLogging.log("setupMPVAsync: OpenGL render view created (tvOS device)")
        #endif
        #endif

        // Set up first-frame callback for accurate ready detection
        view.onFirstFrameRendered = { [weak self] in
            self?.handleFirstFrameRendered()
        }

        #if os(iOS)
        // Set up window callback for PiP setup (view needs to be in window for PiP)
        view.onDidMoveToWindow = { [weak self] containerView in
            guard let self, !self.isPiPSetUp, self.pipPlayerState != nil else { return }
            // Store container reference for updating layer frame after layout
            self.pipContainerView = containerView
            // Complete PiP setup now that view is in a window
            self.setupPiP(in: containerView)
            self.isPiPSetUp = true
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
                // Update sampleBufferLayer frame now that container has been laid out
                if let containerView = self?.pipContainerView, containerView.bounds.size != .zero {
                    self?.pipBridge?.updateLayerFrame(containerView.bounds)
                }
                self?.pipPlayerState?.isPiPPossible = self?.isPiPPossible ?? false
            }
        }
        #endif

        // Initialize MPV and setup render view (HEAVY WORK - now async)
        do {
            MPVLogging.log("setupMPVAsync: initializing client")
            try client.initialize()
            
            MPVLogging.log("setupMPVAsync: setting up render view (OpenGL)")
            let glSetupStart = Date()
            try await view.setupAsync(with: client) // NEW: async version moves OpenGL off main thread
            let glSetupTime = Date().timeIntervalSince(glSetupStart)
            MPVLogging.log("setupMPVAsync: OpenGL setup complete", 
                          details: "time=\(String(format: "%.3f", glSetupTime))s")

            // Capture version info asynchronously
            Task { [weak self] in
                guard let self, let client = self.mpvClient else { return }
                self.versionInfo = await client.getVersionInfoAsync()
            }

            let totalTime = Date().timeIntervalSince(startTime)
            let timeSinceInit = Date().timeIntervalSince(setupStartTime)
            
            LoggingService.shared.logMPV("MPV backend initialized", 
                                         details: "setup=\(String(format: "%.3f", totalTime))s, sinceInit=\(String(format: "%.3f", timeSinceInit))s, gl=\(String(format: "%.3f", glSetupTime))s")
            MPVLogging.log("setupMPVAsync: complete")
            
            isSetupComplete = true
        } catch {
            LoggingService.shared.logMPVError("Failed to initialize MPV", error: error)
            MPVLogging.warn("setupMPVAsync: FAILED", details: "\(error)")
        }
    }

    private func cleanup() {
        // Stop buffer stall detection
        stopBufferStallDetection()
        stopPlaybackStatsLogging()

        #if os(macOS)
        // Clear MPV client callbacks before destroying - these reference the render view
        // which will be deallocated
        mpvClient?.onRenderUpdate = nil
        mpvClient?.onVideoFrameReady = nil
        #endif

        // Remove player view from its superview to prevent orphaned views
        // covering new player views in the view hierarchy
        _playerView?.removeFromSuperview()

        mpvClient?.destroy()
        mpvClient = nil
        renderView = nil
        _playerView = nil
    }

    // MARK: - Playback Control

    func load(stream: Stream, audioStream: Stream?, autoplay: Bool, useEDL: Bool) async throws {
        // Wait for setup to complete before loading
        MPVLogging.log("MPVBackend.load: waiting for setup")
        try await waitForSetup()
        MPVLogging.log("MPVBackend.load: setup complete, proceeding with load")
        
        // Disable EDL for live streams - MPV's EDL doesn't handle live streams properly
        // (live streams have no fixed duration and infinite length, which breaks EDL demuxer)
        let actualUseEDL = useEDL && !stream.isLive
        if stream.isLive && useEDL {
            LoggingService.shared.debug("MPV: Disabling EDL for live stream (live streams not compatible with EDL)", category: .mpv)
        }
        
        LoggingService.shared.logMPV("MPV loading stream", details: "\(stream.qualityLabel) - \(stream.format)\n\(stream.url.absoluteString)\nEDL: \(actualUseEDL) (requested: \(useEDL), isLive: \(stream.isLive))\nStream info: videoCodec=\(stream.videoCodec ?? "nil"), audioCodec=\(stream.audioCodec ?? "nil")")
        if let audioStream {
            LoggingService.shared.logMPV("MPV loading separate audio track", details: "\(audioStream.audioLanguage ?? "default") - \(audioStream.format)\n\(audioStream.url.absoluteString)\nAudio codec: \(audioStream.audioCodec ?? "nil")")
        }

        // Cancel any previous loading operation by changing the ID
        let loadingID = UUID()
        currentLoadingID = loadingID
        
        // IMMEDIATELY mark as loading to protect against .stop events from previous stream
        // This must happen before any async work to prevent race conditions where .stop
        // events from the previous stream see isInitialLoading=false and report idle state
        isInitialLoading = true

        // Store for potential retries
        currentStream = stream
        currentAudioStream = audioStream
        pendingAutoplay = autoplay
        pendingUseEDL = actualUseEDL
        retryCount = 0

        // Reset retry state in UI
        delegate?.backend(self, didUpdateRetryState: 0, maxRetries: maxRetries, isRetrying: false, exhausted: false)

        // Try loading with retries
        try await loadWithRetry(stream: stream, audioStream: audioStream, autoplay: autoplay, useEDL: actualUseEDL, loadingID: loadingID)
    }

    private var pendingUseEDL: Bool = true

    private func loadWithRetry(stream: Stream, audioStream: Stream?, autoplay: Bool, useEDL: Bool, loadingID: UUID) async throws {
        // Check if this load operation was cancelled (a new load started)
        guard currentLoadingID == loadingID else {
            LoggingService.shared.debug("MPV load operation cancelled - newer load in progress", category: .mpv)
            throw CancellationError()
        }

        // Reset state (but keep videoWidth/videoHeight for smooth aspect ratio transition)
        isReady = false
        isInitialLoading = true
        loadFailedDuringWait = false
        lastLoadErrorDetail = nil
        mpvClient?.clearRecentLogLines()
        isSeeking = false
        hasDisplayedVideo = false
        hasStartedPlayback = false
        hasReachedEOF = false
        pendingPlayAfterSeek = false
        // Note: pendingInitialSeek is NOT reset here - it's set by prepareForInitialSeek()
        // before load() is called, and cleared when seek() starts
        currentTime = 0
        duration = 0
        bufferedTime = 0
        containerFps = 0
        renderView?.videoFPS = 60
        // When loading with external audio (non-EDL mode), wait for PLAYBACK_RESTART after audio is added
        // With EDL, both streams load atomically so no waiting needed
        isWaitingForExternalAudio = audioStream != nil && !useEDL

        // Clear any subtitles from previous video
        currentCaption = nil
        mpvClient?.removeAllSubtitlesAsync()

        // Clear stale track info; the observer repopulates it once the new file loads
        currentTracks = []
        delegate?.backend(self, didUpdateTracks: [])

        // Reset first-frame tracking for new content
        renderView?.resetFirstFrameTracking()
        applyStreamFrameRateHint(stream)

        // Pause MPV before loading new content to prevent audio from playing
        // before the thumbnail hides. This is critical when reusing the backend
        // for a new video - without this, MPV may output audio during buffering.
        mpvClient?.pause()

        // Clear the render view to black to hide any old frame from previous video
        renderView?.clearToBlack()

        // Load the stream
        do {
            try mpvClient?.loadFile(
                stream.url,
                audioURL: audioStream?.url,
                httpHeaders: stream.httpHeaders,
                useEDL: useEDL,
                disableVideoTrack: stream.requiresVideoTrackDisabled
            )
            
            // Give MPV a moment to process the loadfile command
            try await Task.sleep(for: .milliseconds(100))
            
            // Log diagnostics asynchronously (background priority) - don't block video loading
            // These are non-critical diagnostic logs that shouldn't impact UI responsiveness
            Task.detached(priority: .background) { [weak self] in
                guard let mpvClient = await self?.mpvClient else { return }
                let idleActive = await mpvClient.getFlagAsync("idle-active")
                let coreIdle = await mpvClient.getFlagAsync("core-idle")
                let seeking = await mpvClient.getFlagAsync("seeking")
                LoggingService.shared.debug(
                    "MPV: After loadfile - idle-active=\(idleActive?.description ?? "nil"), core-idle=\(coreIdle?.description ?? "nil"), seeking=\(seeking?.description ?? "nil")",
                    category: .mpv
                )
            }

            // Wait for file to be loaded
            try await waitForReady(loadingID: loadingID)

            // Check again after waiting
            guard currentLoadingID == loadingID else {
                LoggingService.shared.debug("MPV load operation cancelled after ready - newer load in progress", category: .mpv)
                throw CancellationError()
            }

            // Reset retry state on success
            isInitialLoading = false
            resetRetryState()

            if autoplay {
                play()
            }

            LoggingService.shared.logMPV("MPV stream loaded successfully")
        } catch is CancellationError {
            // Re-throw cancellation errors without retry.
            // Only reset isInitialLoading if we're still the active load operation.
            LoggingService.shared.debug("MPV: loadWithRetry cancelled", category: .mpv)
            if currentLoadingID == loadingID {
                isInitialLoading = false
            }
            throw CancellationError()
        } catch {
            // Check if cancelled before retrying
            guard currentLoadingID == loadingID else {
                LoggingService.shared.debug("MPV load operation cancelled - newer load in progress", category: .mpv)
                // Don't reset isInitialLoading - a newer load owns it now
                throw CancellationError()
            }

            // Retry if we haven't exhausted attempts
            if retryCount < maxRetries {
                let delay = retryDelays[min(retryCount, retryDelays.count - 1)]
                retryCount += 1

                // Notify delegate that retry is starting
                delegate?.backend(self, didUpdateRetryState: retryCount, maxRetries: maxRetries, isRetrying: true, exhausted: false)

                let nextTimeout = loadTimeouts[min(retryCount, loadTimeouts.count - 1)]
                LoggingService.shared.warning("MPV stream load retry", category: .mpv, details: "Waiting \(Int(delay))s before attempt \(retryCount + 1)/\(maxRetries + 1) (timeout: \(Int(nextTimeout))s)")

                // Wait before retrying
                try await Task.sleep(for: .seconds(delay))

                // Check if cancelled after delay
                guard currentLoadingID == loadingID, !Task.isCancelled else {
                    LoggingService.shared.debug("MPV load operation cancelled during retry delay", category: .mpv)
                    // Don't reset isInitialLoading - a newer load owns it now
                    throw CancellationError()
                }
                try await loadWithRetry(stream: stream, audioStream: audioStream, autoplay: autoplay, useEDL: useEDL, loadingID: loadingID)
            } else {
                // All retries exhausted
                isInitialLoading = false
                LoggingService.shared.logMPVError("MPV stream load failed after \(retryDelays.count + 1) attempts")

                // Notify delegate that all retries exhausted
                delegate?.backend(self, didUpdateRetryState: maxRetries, maxRetries: maxRetries, isRetrying: false, exhausted: true)

                resetRetryState()
                throw error
            }
        }
    }

    /// Maximum number of retries
    private var maxRetries: Int {
        retryDelays.count
    }

    /// Timeout for current attempt
    private var currentLoadTimeout: TimeInterval {
        loadTimeouts[min(retryCount, loadTimeouts.count - 1)]
    }

    private func resetRetryState() {
        retryCount = 0
        currentStream = nil
        currentAudioStream = nil
        pendingAutoplay = false
        delegate?.backend(self, didUpdateRetryState: 0, maxRetries: maxRetries, isRetrying: false, exhausted: false)
    }

    func play() {
        guard isReady else {
            // Not ready yet - set pending flag to auto-play when ready
            LoggingService.shared.debug("MPV: play() called while not ready, setting pendingPlayAfterSeek", category: .mpv)
            pendingPlayAfterSeek = true
            return
        }

        LoggingService.shared.debug("MPV: play() called, isReady=\(isReady), cacheTime=\(demuxerCacheTime)s", category: .mpv)
        mpvClient?.play()
        isPlaying = true
        delegate?.backend(self, didChangeState: .playing)
    }

    /// Wait for sufficient buffer before starting playback.
    /// This prevents the initial pause/stutter that occurs when MPV starts playing
    /// before enough content is buffered.
    /// - Parameters:
    ///   - minimumBuffer: Minimum buffer time required before playback starts (default 3.0 seconds)
    ///   - timeout: Maximum time to wait for buffer (default 5 seconds)
    /// - Returns: The buffer time when wait completed
    func waitForBuffer(minimumBuffer: Double = 3.0, timeout: TimeInterval = 5.0) async -> Double {
        let startTime = Date()
        var lastLogTime = startTime

        // First, wait for any pending seek to complete
        // After a seek, the buffer at the new position needs to refill
        while isSeeking {
            if Date().timeIntervalSince(startTime) >= timeout {
                LoggingService.shared.debug("MPV: Buffer wait timeout while waiting for seek", category: .mpv)
                return 0
            }
            try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
        }

        LoggingService.shared.debug("MPV: Seek complete, now waiting for buffer to fill", category: .mpv)

        // After seek completes, give the demuxer a moment to start filling the buffer
        // at the new position. Without this, we might read stale cache values.
        try? await Task.sleep(nanoseconds: 100_000_000) // 100ms

        // Fast-path: If cache is already satisfied, return immediately
        // This handles fully buffered content (short videos, previously buffered, etc.)
        let initialBufferingState = cacheBufferingState
        let initialPausedForCache = pausedForCache
        if initialBufferingState >= 100 && !initialPausedForCache {
            return demuxerCacheTime
        }

        // Capture the initial cache time as a baseline
        // demuxer-cache-time returns total cached content from file start, not from seek position
        // So when seeking to 100s, cache might already be 100s even though buffer at that position is empty
        let initialCacheTime = demuxerCacheTime
        let targetCacheTime = initialCacheTime + minimumBuffer

        // Now wait for sufficient buffer at the current position
        // We check multiple conditions:
        // 1. demuxer-cache-time: seconds of video buffered (relative to baseline)
        // 2. cache-buffering-state: 0-100% of how full the cache is until MPV will unpause
        // 3. paused-for-cache: whether MPV would pause due to insufficient cache
        var lastCacheTime: Double = initialCacheTime
        var noProgressCount = 0

        while true {
            // Use cached values (updated via property observation) to avoid sync fetch on main thread
            let cacheTime = demuxerCacheTime
            let bufferingState = cacheBufferingState
            let isPausedForCache = pausedForCache

            // Calculate progress as percentage towards our minimum buffer target
            // Use the delta from initial cache time to show meaningful progress after seeks
            let bufferedSinceStart = max(0, cacheTime - initialCacheTime)
            let bufferProgress = min(Int((bufferedSinceStart / minimumBuffer) * 100), 99)
            delegate?.backend(self, didUpdateBufferProgress: bufferProgress)

            // Log progress every 0.5 seconds
            if Date().timeIntervalSince(lastLogTime) >= 0.5 {
                LoggingService.shared.debug("MPV: Waiting for buffer... cacheTime=\(String(format: "%.2f", cacheTime))s (delta=\(String(format: "%.2f", bufferedSinceStart))s), bufferingState=\(bufferingState)%, pausedForCache=\(isPausedForCache), target=\(minimumBuffer)s, progress=\(bufferProgress)%", category: .mpv)
                lastLogTime = Date()
            }

            // Primary condition: We have enough cached time relative to where we started
            let hasEnoughTime = cacheTime >= targetCacheTime

            if hasEnoughTime {
                LoggingService.shared.debug("MPV: Buffer ready, cacheTime=\(String(format: "%.2f", cacheTime))s (delta=\(String(format: "%.2f", bufferedSinceStart))s), bufferingState=\(bufferingState)%, reason=enough time", category: .mpv)
                return cacheTime
            }

            // Track if buffer isn't growing (video fully downloaded or other issue)
            if cacheTime <= lastCacheTime {
                noProgressCount += 1
            } else {
                noProgressCount = 0
                lastCacheTime = cacheTime
            }

            // Fallback: If MPV's cache is satisfied AND buffer isn't growing for 0.5s (10 checks),
            // the video is likely short/fully buffered, so proceed
            let cacheIsSatisfied = bufferingState >= 100 && !isPausedForCache
            if cacheIsSatisfied && noProgressCount >= 10 {
                LoggingService.shared.debug("MPV: Buffer ready, cacheTime=\(String(format: "%.2f", cacheTime))s (delta=\(String(format: "%.2f", bufferedSinceStart))s), bufferingState=\(bufferingState)%, reason=cache satisfied (no progress)", category: .mpv)
                return cacheTime
            }

            // Check timeout
            if Date().timeIntervalSince(startTime) >= timeout {
                LoggingService.shared.debug("MPV: Buffer wait timeout, proceeding with cacheTime=\(String(format: "%.2f", cacheTime))s, bufferingState=\(bufferingState)%", category: .mpv)
                return cacheTime
            }

            // Wait a bit before checking again
            try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
        }
    }

    func pause() {
        mpvClient?.pause()
        isPlaying = false
        delegate?.backend(self, didChangeState: .paused)
    }

    func stop() {
        #if os(iOS) || os(macOS)
        // Stop PiP if active before stopping playback
        if pipBridge?.isPiPActive == true {
            stopPiP()
        }
        // Flush PiP buffer to clear stale frames when reusing backend
        pipBridge?.flushBuffer()
        // Immediately reset PiP-related state (don't wait for async callbacks)
        // This ensures the next video can start with clean state
        _playerView?.isPiPActive = false
        _playerView?.captureFramesForPiP = false
        pipPlayerState?.pipState = .inactive
        // Note: Don't clear onFrameReady/onFirstFrameRendered callbacks - they're
        // set once in setupMPV() and needed for subsequent video loads when reusing backend
        #endif

        #if os(macOS)
        // On macOS, also clean up PiP bridge callbacks to prevent crashes
        // during window close
        pipBridge?.onPiPStatusChanged = nil
        pipBridge?.onPiPWillStart = nil
        pipBridge?.onPiPWillStop = nil
        pipBridge?.onPiPRenderSizeChanged = nil

        // NOTE: Do NOT clear mpvClient?.onRenderUpdate here!
        // The onRenderUpdate callback is set once during setup and needs to persist
        // across video changes. Clearing it here would break rendering for subsequent
        // videos since setup() is only called once per backend lifetime.
        // The callback will be properly cleared in cleanup() when the backend is destroyed.
        #endif

        // Stop buffer stall detection
        stopBufferStallDetection()
        stopPlaybackStatsLogging()

        mpvClient?.stop()
        isPlaying = false
        isReady = false
        hasReachedEOF = false
        pendingInitialSeek = false
        currentTime = 0
        duration = 0
        bufferedTime = 0
        currentStream = nil
        #if os(tvOS)
        videoGamma = ""
        clearTVDisplayCriteria()
        #endif
        delegate?.backend(self, didChangeState: .idle)
    }

    func prepareForInitialSeek() {
        // Signal that an initial seek will be performed after load completes.
        // This defers backendDidBecomeReady until the seek completes.
        pendingInitialSeek = true
        LoggingService.shared.debug("MPV: Preparing for initial seek", category: .mpv)
    }

    func seek(to time: TimeInterval, showLoading: Bool = false) async {
        // Clear pending initial seek flag - we're now doing the seek
        pendingInitialSeek = false

        // Clear EOF state when seeking (e.g., restart from beginning)
        hasReachedEOF = false

        // For initial resume seeks (before playback has really started),
        // or when explicitly requested (e.g., SponsorBlock intro skip),
        // reset ready state so we keep showing loading until video at new position is visible
        let shouldShowLoading = !hasStartedPlayback || showLoading

        if shouldShowLoading {
            LoggingService.shared.debug("MPV: Seek with loading state - resetting for new position", category: .mpv)
            hasDisplayedVideo = false
            hasStartedPlayback = false
            renderView?.resetFirstFrameTracking()

            // Pause MPV during seek to prevent brief playback at wrong position
            if isPlaying {
                mpvClient?.pause()
                pendingPlayAfterSeek = true
            }

            if isReady {
                isReady = false
                // Tell UI to go back to loading state
                delegate?.backend(self, didChangeState: .loading)
            }
        }

        // Set seeking immediately - don't wait for MPV's property update
        isSeeking = true

        // Use async seek to avoid blocking the main thread
        // Seek completion is tracked via MPV's "seeking" property observation
        mpvClient?.seekAsync(to: time)
        currentTime = time
        delegate?.backend(self, didUpdateTime: time)
    }

    /// Tracks if we need to resume playback after initial seek completes
    private var pendingPlayAfterSeek = false

    /// Tracks if MPV is currently seeking
    private var isSeeking = false

    /// Tracks if we've actually displayed video (first frame rendered)
    private var hasDisplayedVideo = false

    /// Tracks if real playback has started (user has seen video playing at intended position)
    /// This distinguishes initial resume seeks from user-initiated seeks during playback
    private var hasStartedPlayback = false

    /// Tracks if an initial seek is pending after load completes
    /// When true, handleFirstFrameRendered won't call backendDidBecomeReady until seek completes
    private var pendingInitialSeek = false


    // MARK: - Backend Switching

    func captureState() -> BackendState {
        BackendState(
            currentTime: currentTime,
            duration: duration,
            rate: _rate,
            volume: _volume,
            isMuted: _isMuted,
            isPlaying: isPlaying
        )
    }

    func restore(state: BackendState) async {
        _rate = state.rate
        _volume = state.volume
        _isMuted = state.isMuted

        // Apply to MPV
        mpvClient?.setProperty("speed", Double(state.rate))
        mpvClient?.setProperty("volume", Double(state.volume * 100))
        mpvClient?.setProperty("mute", state.isMuted)

        if state.currentTime > 0 {
            await seek(to: state.currentTime)
        }

        if state.isPlaying {
            play()
        }
    }

    func prepareForHandoff() {
        mpvClient?.pause()
        isPlaying = false
    }

    // MARK: - Subtitles

    /// Load and display a caption/subtitle track.
    /// - Parameter caption: The caption to load, or nil to disable subtitles
    func loadCaption(_ caption: Caption?) {
        // Remove any existing subtitles first (async to not block UI)
        mpvClient?.removeAllSubtitlesAsync()

        guard let caption else {
            // Disable subtitles
            mpvClient?.disableSubtitles()
            currentCaption = nil
            LoggingService.shared.debug("MPV: Subtitles disabled", category: .mpv)
            return
        }

        // Load the new subtitle asynchronously to avoid blocking UI during download
        LoggingService.shared.debug("MPV: Loading subtitle: \(caption.displayName)", category: .mpv)
        mpvClient?.addSubtitleAsync(caption.url, select: true)
        currentCaption = caption
    }

    /// Get the currently loaded caption.
    func getCurrentCaption() -> Caption? {
        currentCaption
    }

    // MARK: - Embedded Tracks

    /// Select an embedded audio track by mpv track id (live, no reload).
    func selectEmbeddedAudioTrack(_ trackID: Int) {
        mpvClient?.setAudioTrack(trackID)
    }

    /// Select an embedded subtitle track by mpv track id (nil = off).
    /// Deliberately no `sub-remove`: external captions stay loaded so the user
    /// can switch back without a re-download. Selection is no longer
    /// represented by a Caption, so `currentCaption` is cleared.
    func selectEmbeddedSubtitleTrack(_ trackID: Int?) {
        currentCaption = nil
        mpvClient?.setSubtitleTrack(trackID)
    }

    /// Update subtitle appearance settings on the active MPV instance.
    /// Call this after changing subtitle settings to apply them immediately without restarting playback.
    func updateSubtitleSettings() {
        mpvClient?.updateSubtitleSettings()
    }

    /// Push the current tvOS audio-delay setting into the running MPV instance.
    /// Caller passes milliseconds; MPVClient converts to seconds.
    func updateAudioDelay(milliseconds: Double) {
        mpvClient?.updateAudioDelay(milliseconds: milliseconds)
    }

    /// Get the actual video track dimensions from MPV.
    /// Returns (width, height) or nil if not available.
    func getVideoSize() -> (width: Int, height: Int)? {
        // Use cached values (updated via property observation) to avoid sync fetch
        guard videoWidth > 0, videoHeight > 0 else {
            return nil
        }
        return (videoWidth, videoHeight)
    }

    /// Get debug statistics from MPV for the debug overlay.
    /// Uses batch fetch to minimize lock contention (single sync block instead of ~25 separate calls).
    func getDebugStats() -> MPVDebugStats {
        var stats = MPVDebugStats()

        // Fetch all properties in a single sync block
        guard let props = mpvClient?.getDebugProperties() else {
            return stats
        }

        // Video info
        stats.videoCodec = props.videoCodec
        stats.hwdecCurrent = props.hwdecCurrent
        stats.width = props.width
        stats.height = props.height
        stats.fps = props.containerFps
        stats.estimatedVfFps = props.estimatedVfFps

        // Audio info
        stats.audioCodec = props.audioCodecName
        stats.audioSampleRate = props.audioSampleRate
        stats.audioChannels = props.audioChannels

        // Playback stats
        stats.droppedFrameCount = props.frameDropCount
        stats.mistimedFrameCount = props.mistimedFrameCount
        stats.delayedFrameCount = props.voDelayedFrameCount
        stats.avSync = props.avsync
        stats.estimatedFrameNumber = props.estimatedFrameNumber

        // Cache/Network
        if let cacheState = props.cacheState {
            stats.cacheDuration = cacheState.cacheDuration
            stats.cacheBytes = cacheState.totalBytes
            stats.networkSpeed = cacheState.inputRate
        }
        stats.demuxerCacheDuration = props.demuxerCacheDuration

        // Container
        stats.fileFormat = props.fileFormat
        stats.containerFps = props.containerFps

        // Video Sync stats (for tvOS frame timing diagnostics)
        #if os(tvOS)
        stats.videoSync = props.videoSync
        stats.displayFps = props.displayFps
        stats.vsyncJitter = props.vsyncJitter
        stats.videoSpeedCorrection = props.videoSpeedCorrection
        stats.audioSpeedCorrection = props.audioSpeedCorrection
        stats.framedrop = props.framedrop
        stats.displayLinkFps = renderView?.displayLinkTargetFPS
        stats.audioDelay = props.audioDelay
        stats.currentVo = props.currentVo
        stats.currentAo = props.currentAo
        stats.audioDevice = props.audioDevice
        stats.displayWidth = props.displayWidth
        stats.displayHeight = props.displayHeight
        stats.displayNames = props.displayNames
        stats.decoderFrameDropCount = props.decoderFrameDropCount
        #endif

        return stats
    }

    // MARK: - Background Playback

    func handleScenePhase(_ phase: ScenePhase, isPiPActive: Bool) {
        #if os(iOS)
        let pipActive = self.isPiPActive || isPiPActive
        #else
        let pipActive = isPiPActive
        #endif

        MPVLogging.logAppLifecycle("handleScenePhase(\(phase))",
            isPiPActive: pipActive, isRendering: nil)

        guard !pipActive else {
            MPVLogging.log("handleScenePhase: skipping (pip:\(pipActive))")
            return
        }

        switch phase {
        case .background:
            // Just pause rendering - don't touch video track or output properties
            // This preserves the demuxer cache and avoids rebuffering on resume
            // Same approach as handlePlayerSheetVisibility
            LoggingService.shared.debug("MPV: Pausing rendering for background", category: .mpv)
            MPVLogging.log("handleScenePhase: pausing rendering for background")
            #if os(macOS)
            _playerView?.pauseRendering()
            #elseif targetEnvironment(simulator)
            (playerView as? MPVSoftwareRenderView)?.pauseRendering()
            #else
            (playerView as? MPVRenderView)?.pauseRendering()
            #endif

        case .active:
            // Resume rendering
            LoggingService.shared.debug("MPV: Resuming rendering for foreground", category: .mpv)
            MPVLogging.log("handleScenePhase: resuming rendering for foreground")
            #if os(macOS)
            _playerView?.resumeRendering()
            #elseif targetEnvironment(simulator)
            (playerView as? MPVSoftwareRenderView)?.resumeRendering()
            #else
            (playerView as? MPVRenderView)?.resumeRendering()
            #endif

        default:
            break
        }
    }

    /// Handles player sheet visibility changes for background audio playback.
    /// - Parameter isVisible: true when sheet appears, false when it disappears
    ///
    /// Note: We only pause/resume rendering, NOT disable the video track.
    /// Disabling the video track (`vid=no`) stops video decoding while audio continues,
    /// causing severe A/V desync when video is re-enabled. Instead, we keep decoding
    /// running but pause the display link to save GPU while maintaining sync.
    func handlePlayerSheetVisibility(isVisible: Bool) {
        #if os(iOS)
        let hasFirstFrame = hasDisplayedVideo
        let currentlyPlaying = isPlaying
        let ready = isReady
        LoggingService.shared.debug("MPV: handlePlayerSheetVisibility(isVisible=\(isVisible)) - hasDisplayedVideo=\(hasFirstFrame), isPlaying=\(currentlyPlaying), isReady=\(ready), _playerView=\(_playerView != nil)", category: .mpv)
        MPVLogging.log("handlePlayerSheetVisibility(isVisible:\(isVisible))",
            details: "hasFirstFrame:\(hasFirstFrame) playing:\(currentlyPlaying) ready:\(ready)")

        if isVisible {
            LoggingService.shared.debug("MPV: Resuming rendering for sheet display", category: .mpv)
            MPVLogging.log("handlePlayerSheetVisibility: resuming rendering")
            _playerView?.resumeRendering()
        } else {
            LoggingService.shared.debug("MPV: Pausing rendering - sheet dismissed", category: .mpv)
            MPVLogging.log("handlePlayerSheetVisibility: pausing rendering")
            _playerView?.pauseRendering()
        }
        #endif
    }

    // MARK: - Picture-in-Picture (iOS)

    #if os(iOS)
    /// Tracks whether PiP has been set up
    private var isPiPSetUp = false

    /// Reference to the player state for updating isPiPPossible
    private weak var pipPlayerState: PlayerState?

    /// Reference to the container view for updating PiP layer frame after layout
    private weak var pipContainerView: UIView?

    /// Set up PiP if not already set up.
    /// Called from the representable when the view is updated.
    func setupPiPIfNeeded(in containerView: UIView, playerState: PlayerState?) {
        // Always store playerState so we have it when onDidMoveToWindow fires
        pipPlayerState = playerState

        // Only proceed with actual setup if not already done and view is in window
        guard !isPiPSetUp, containerView.window != nil else { return }

        // Store container reference for updating layer frame after layout
        pipContainerView = containerView

        setupPiP(in: containerView)
        isPiPSetUp = true

        // Update player state after a short delay to let PiP controller initialize
        // and container to be laid out with proper bounds
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
            // Update sampleBufferLayer frame now that container has been laid out
            if let containerView = self?.pipContainerView, containerView.bounds.size != .zero {
                self?.pipBridge?.updateLayerFrame(containerView.bounds)
            }
            playerState?.isPiPPossible = self?.isPiPPossible ?? false
        }
    }

    /// Set up PiP with the given container view.
    /// - Parameters:
    ///   - containerView: The view to embed the PiP layer in
    func setupPiP(in containerView: UIView) {
        // Create PiP bridge if needed
        if pipBridge == nil {
            pipBridge = MPVPiPBridge()
        }

        guard let pipBridge else { return }

        // Set up the bridge with this backend and the container
        pipBridge.setup(backend: self, in: containerView)

        // Use the restore callback set by PlayerService
        pipBridge.onRestoreUserInterface = { [weak self] in
            await self?.onRestoreFromPiP?()
        }

        // Update render view's PiP status to control main view rendering
        pipBridge.onPiPStatusChanged = { [weak self] isActive in
            self?._playerView?.isPiPActive = isActive
            // Also update player state
            self?.pipPlayerState?.pipState = isActive ? .active : .inactive
            if isActive {
                // Notify that PiP started (to collapse player sheet)
                self?.onPiPDidStart?()
            } else {
                // Disable frame capture when PiP stops (after animation completes)
                self?._playerView?.captureFramesForPiP = false
            }
        }

        // Clear main view to black immediately when PiP starts animating
        pipBridge.onPiPWillStart = { [weak self] in
            // Enable frame capture - this handles system-triggered PiP (when user minimizes app)
            // where captureFramesForPiP wasn't set beforehand
            self?._playerView?.captureFramesForPiP = true
            // Stop presenting to main view immediately
            self?._playerView?.isPiPActive = true
            // Clear to black
            self?._playerView?.clearMainViewForPiP()
        }

        // Note: Main view rendering resumes in onPiPStatusChanged(false) after animation completes.
        // This allows the system "playing in PiP" placeholder to show during close animation.
        pipBridge.onPiPWillStop = { [weak self] in
            // Keep isPiPActive = true so main view shows placeholder during close animation
            _ = self  // Silence unused warning
        }

        // Connect frame capture from render view to PiP bridge
        _playerView?.onFrameReady = { [weak self] pixelBuffer, presentationTime in
            self?.enqueueFrameForPiP(pixelBuffer, presentationTime: presentationTime)
        }

        LoggingService.shared.debug("MPV: PiP setup complete", category: .mpv)
    }

    /// Start Picture-in-Picture.
    func startPiP() {
        guard pipBridge != nil else {
            LoggingService.shared.warning("MPV: Cannot start PiP - not set up", category: .mpv)
            return
        }

        startPiPInternal()
    }

    /// Internal method to actually start PiP after any fullscreen handling
    private func startPiPInternal() {
        // Enable frame capture for PiP - start capturing BEFORE requesting PiP
        // so the sample buffer layer has content
        _playerView?.captureFramesForPiP = true

        // Wait a moment for frames to be captured, then start PiP
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
            self?.pipBridge?.startPiP()
        }
    }

    /// Stop Picture-in-Picture.
    func stopPiP() {
        pipBridge?.stopPiP()
        // Frame capture is disabled in onPiPStatusChanged callback after animation completes
    }

    /// Toggle Picture-in-Picture.
    func togglePiP() {
        if isPiPActive {
            stopPiP()
        } else {
            startPiP()
        }
    }

    /// Called by render view when a frame is ready for PiP display.
    func enqueueFrameForPiP(_ pixelBuffer: CVPixelBuffer, presentationTime: CMTime) {
        guard let pipBridge else { return }

        // Update playback state for PiP controls
        pipBridge.updatePlaybackState(
            duration: duration,
            currentTime: currentTime,
            isPaused: !isPlaying
        )

        pipBridge.enqueueFrame(pixelBuffer, presentationTime: presentationTime)
    }

    /// Clean up PiP resources.
    func cleanupPiP() {
        _playerView?.captureFramesForPiP = false
        _playerView?.onFrameReady = nil
        pipBridge?.cleanup()
        pipBridge = nil
        isPiPSetUp = false
    }

    /// Move the PiP layer to a new container view.
    /// Call this before starting PiP from fullscreen to ensure the layer
    /// is in the main window's view hierarchy.
    func movePiPLayer(to containerView: UIView) {
        pipBridge?.moveLayer(to: containerView)
    }

    #elseif os(macOS)

    // MARK: - Picture-in-Picture (macOS)

    /// Set up PiP if not already set up.
    /// Called from the representable when the view is updated.
    func setupPiPIfNeeded(in containerView: NSView, playerState: PlayerState?) {
        // Always store playerState so we have it for later
        pipPlayerState = playerState

        // Only proceed with actual setup if not already done and view is in window
        guard !isPiPSetUp, containerView.window != nil else {
            if isPiPSetUp {
                // Re-wire bridge callbacks in case stop() cleared them since
                // setup (the backend is reused across videos)
                wirePiPBridgeCallbacks()
                // If playerState changed, update isPiPPossible
                if let playerState {
                    playerState.isPiPPossible = isPiPPossible
                }
            }
            return
        }

        // Store container reference for updating layer frame after layout
        pipContainerView = containerView

        setupPiP(in: containerView)
        isPiPSetUp = true

        // Update sampleBufferLayer frame after a short delay now that container has been laid out
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
            if let containerView = self?.pipContainerView, containerView.bounds.size != .zero {
                // On macOS, use the method that calculates frame relative to window's content view
                self?.pipBridge?.updateLayerFrame(for: containerView)
            }
        }
        // Note: playerState.isPiPPossible is updated via KVO observation in pipBridge.onPiPPossibleChanged
    }

    /// Set up PiP with the given container view.
    func setupPiP(in containerView: NSView) {
        // Create PiP bridge if needed
        if pipBridge == nil {
            pipBridge = MPVPiPBridge()
        }

        guard let pipBridge else { return }

        // Set up the bridge with this backend and the container
        pipBridge.setup(backend: self, in: containerView)

        wirePiPBridgeCallbacks()

        // Manually notify current state now that callbacks are set up
        pipBridge.notifyPiPPossibleState()
    }

    /// (Re)connect the PiP bridge callbacks. stop() clears them to prevent
    /// crashes during window close, but the backend is reused across videos,
    /// so they must be wired again before the next PiP session — otherwise
    /// PiP starts without notifying PlayerService and the player window
    /// stays visible alongside the PiP window.
    private func wirePiPBridgeCallbacks() {
        guard let pipBridge else { return }

        // Use the restore callback set by PlayerService
        pipBridge.onRestoreUserInterface = { [weak self] in
            await self?.onRestoreFromPiP?()
        }

        // Update render view's PiP status to control main view rendering
        pipBridge.onPiPStatusChanged = { [weak self] isActive in
            LoggingService.shared.debug("MPVBackend (macOS): onPiPStatusChanged isActive=\(isActive), onPiPDidStart=\(self?.onPiPDidStart != nil ? "set" : "nil")", category: .mpv)
            self?._playerView?.isPiPActive = isActive
            self?.pipPlayerState?.pipState = isActive ? .active : .inactive
            if isActive {
                self?.onPiPDidStart?()
            } else {
                self?._playerView?.captureFramesForPiP = false
                // The layer was cleared to black when PiP started and macOS
                // drawing is pull-based — force a repaint so the restored
                // window shows the current frame even while paused. If the
                // view isn't in a window yet, the forced draw stays armed and
                // the setFrameSize/viewDidMoveToWindow retries complete it.
                self?._playerView?.resumeRendering()
            }
        }

        // Clear main view to black immediately when PiP starts animating
        pipBridge.onPiPWillStart = { [weak self] in
            self?._playerView?.captureFramesForPiP = true
            self?._playerView?.isPiPActive = true
            self?._playerView?.clearMainViewForPiP()
        }

        pipBridge.onPiPWillStop = { [weak self] in
            _ = self
        }

        // Clean up hidden window when PiP is closed (not restored)
        pipBridge.onPiPDidStopWithoutRestore = { [weak self] in
            self?.onPiPDidStopWithoutRestore?()
        }

        // Connect frame capture from render view to PiP bridge
        _playerView?.onFrameReady = { [weak self] pixelBuffer, presentationTime in
            self?.enqueueFrameForPiP(pixelBuffer, presentationTime: presentationTime)
        }

        // Update playerState when isPiPPossible changes (via KVO)
        pipBridge.onPiPPossibleChanged = { [weak self] isPossible in
            self?.pipPlayerState?.isPiPPossible = isPossible
        }

        // Update render view's PiP capture size when PiP window size changes
        pipBridge.onPiPRenderSizeChanged = { [weak self] size in
            self?._playerView?.updatePiPTargetSize(size)
        }
    }

    /// Start Picture-in-Picture.
    func startPiP() {
        guard pipBridge != nil else {
            LoggingService.shared.warning("MPV (macOS): Cannot start PiP - not set up", category: .mpv)
            return
        }

        // Re-wire bridge callbacks in case stop() cleared them since setup
        // (the backend is reused across videos)
        wirePiPBridgeCallbacks()

        // Enable frame capture for PiP - start capturing BEFORE requesting PiP
        _playerView?.captureFramesForPiP = true

        // Wait a moment for frames to be captured, then start PiP
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
            self?.pipBridge?.startPiP()
        }
    }

    /// Stop Picture-in-Picture.
    func stopPiP() {
        pipBridge?.stopPiP()
    }

    /// Toggle Picture-in-Picture.
    func togglePiP() {
        if isPiPActive {
            stopPiP()
        } else {
            startPiP()
        }
    }

    /// Called by render view when a frame is ready for PiP display.
    func enqueueFrameForPiP(_ pixelBuffer: CVPixelBuffer, presentationTime: CMTime) {
        guard let pipBridge else { return }

        pipBridge.updatePlaybackState(
            duration: duration,
            currentTime: currentTime,
            isPaused: !isPlaying
        )

        pipBridge.enqueueFrame(pixelBuffer, presentationTime: presentationTime)
    }

    /// Clean up PiP resources.
    func cleanupPiP() {
        _playerView?.captureFramesForPiP = false
        _playerView?.onFrameReady = nil
        pipBridge?.cleanup()
        pipBridge = nil
        isPiPSetUp = false
    }

    /// Move the PiP layer to a new container view.
    func movePiPLayer(to containerView: NSView) {
        pipBridge?.moveLayer(to: containerView)
    }
    #endif

    // MARK: - Private Methods

    /// Called when the render view has rendered its first frame.
    /// This is used to track that we've actually displayed video content.
    private func handleFirstFrameRendered() {
        // Verify this callback belongs to a current load operation
        // (defends against stale callbacks from a previous video)
        guard currentLoadingID != nil else {
            LoggingService.shared.debug("MPV: Ignoring stale first frame callback (no active load)", category: .mpv)
            return
        }

        hasDisplayedVideo = true
        LoggingService.shared.debug("MPV: First frame rendered, hasDisplayedVideo = true, pendingInitialSeek = \(pendingInitialSeek), cacheTime = \(demuxerCacheTime)s", category: .mpv)

        // If we're waiting to mark ready after a resume seek, do it now
        // Don't mark ready if an initial seek is pending - wait until seek completes
        if !isReady && !isSeeking && !pendingInitialSeek {
            isReady = true
            hasStartedPlayback = true

            // Resume playback if we paused for initial seek
            if pendingPlayAfterSeek {
                pendingPlayAfterSeek = false
                mpvClient?.play()
                LoggingService.shared.debug("MPV: First frame rendered, resuming playback after initial seek", category: .mpv)
            }

            // Notify UI that we're ready to show video
            delegate?.backendDidBecomeReady(self)
            delegate?.backend(self, didChangeState: isPlaying ? .playing : .ready)
        }
    }

    /// Pick the most informative recent mpv log line and combine with the END_FILE error string.
    /// Used to populate `BackendError.loadFailed` so the user sees the underlying cause.
    private func composeLoadErrorDetail(errorCode: Int32, errorString: String?) -> String {
        let logLines = mpvClient?.recentLogLines() ?? []
        let preferred = pickMostInformativeLogLine(logLines)
        if let preferred {
            if let errorString, !errorString.isEmpty {
                return "\(errorString): \(preferred)"
            }
            return preferred
        }
        return errorString ?? "unknown error (code \(errorCode))"
    }

    /// Returns ` — <line>` for inclusion in a timeout message if a useful log line exists; otherwise empty.
    private func logBufferDetailIfAny() -> String {
        guard let mpvClient else { return "" }
        if let line = pickMostInformativeLogLine(mpvClient.recentLogLines()) {
            return " — \(line)"
        }
        return ""
    }

    /// Pick the most relevant line: prefer error-level entries containing keywords like
    /// "HTTP error", "Failed", "error", falling back to the most recent error/warn line.
    private func pickMostInformativeLogLine(_ lines: [MPVLogLine]) -> String? {
        guard !lines.isEmpty else { return nil }
        let keywords = ["HTTP error", "Failed", "Cannot open", "No such", "refused", "timed out", "resolve"]

        // Search most-recent-first.
        let reversed = Array(lines.reversed())

        if let keywordHit = reversed.first(where: { line in
            line.severity >= 3 && keywords.contains(where: { line.text.localizedCaseInsensitiveContains($0) })
        }) {
            return keywordHit.formatted
        }

        if let errorLine = reversed.first(where: { $0.severity >= 4 }) {
            return errorLine.formatted
        }

        return reversed.first(where: { $0.severity >= 3 })?.formatted
    }

    private func waitForReady(loadingID: UUID) async throws {
        let start = Date()
        let timeout = currentLoadTimeout

        LoggingService.shared.debug("MPV: Waiting for stream ready (timeout: \(Int(timeout))s, attempt \(retryCount + 1)/\(maxRetries + 1))", category: .mpv)

        // Wait for video to be ready
        while !isReady {
            // Check if this load was cancelled
            guard currentLoadingID == loadingID else {
                throw CancellationError()
            }

            // Fail fast: MPV already reported an END_FILE error for this load.
            if loadFailedDuringWait {
                let detail = lastLoadErrorDetail ?? "MPV reported load error"
                throw BackendError.loadFailed("Failed to load stream: \(detail)")
            }

            if Date().timeIntervalSince(start) > timeout {
                let detail = lastLoadErrorDetail.map { " — \($0)" } ?? logBufferDetailIfAny()
                throw BackendError.loadFailed("Timeout waiting for MPV to load stream (\(Int(timeout))s)\(detail)")
            }

            try await Task.sleep(for: .milliseconds(100))
        }

        // If we have an external audio track, wait for PLAYBACK_RESTART after audio is added
        if isWaitingForExternalAudio {
            LoggingService.shared.debug("MPV: Waiting for external audio track to load", category: .mpv)
            while isWaitingForExternalAudio {
                guard currentLoadingID == loadingID else {
                    throw CancellationError()
                }

                if loadFailedDuringWait {
                    let detail = lastLoadErrorDetail ?? "MPV reported load error"
                    throw BackendError.loadFailed("Failed to load audio track: \(detail)")
                }

                if Date().timeIntervalSince(start) > timeout {
                    let detail = lastLoadErrorDetail.map { " — \($0)" } ?? logBufferDetailIfAny()
                    throw BackendError.loadFailed("Timeout waiting for audio track (\(Int(timeout))s)\(detail)")
                }

                try await Task.sleep(for: .milliseconds(100))
            }
            LoggingService.shared.debug("MPV: External audio track loaded", category: .mpv)
        }

        delegate?.backendDidBecomeReady(self)
        delegate?.backend(self, didChangeState: .ready)
    }
}

// MARK: - MPVClientDelegate

extension MPVBackend: MPVClientDelegate {
    nonisolated func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?) {
        Task { @MainActor [weak self] in
            self?.handlePropertyChange(property: property, value: value)
        }
    }

    nonisolated func mpvClient(_ client: MPVClient, didReceiveEvent event: mpv_event_id) {
        Task { @MainActor [weak self] in
            self?.handleEvent(event)
        }
    }

    nonisolated func mpvClient(_ client: MPVClient, didUpdateCacheState cacheState: MPVCacheState) {
        // Cache state is used for buffer display on seek bar - no action needed here
    }

    nonisolated func mpvClient(_ client: MPVClient, didUpdateTrackList tracks: [MPVTrack]) {
        Task { @MainActor [weak self] in
            guard let self else { return }
            self.currentTracks = tracks
            self.delegate?.backend(self, didUpdateTracks: tracks)
        }
    }

    nonisolated func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason, errorCode: Int32, errorString: String?) {
        Task { @MainActor [weak self] in
            self?.handleEndFile(reason: reason, errorCode: errorCode, errorString: errorString)
        }
    }

    // MARK: - Event Handlers

    private func handlePropertyChange(property: String, value: Any?) {
        switch property {
        case "time-pos":
            if let time = value as? Double, time >= 0 {
                currentTime = time
                renderView?.updateTimePosition(time)
                delegate?.backend(self, didUpdateTime: time)
                // Ready state is now handled by handleFirstFrameRendered()
            }

        case "duration":
            if let dur = value as? Double, dur > 0 {
                duration = dur
                delegate?.backend(self, didUpdateDuration: dur)

                // Audio-only streams may become ready once duration is known
                checkAndMarkReadyIfAudioOnlyDetected()
            }

        case "pause":
            if let paused = value as? Bool {
                LoggingService.shared.debug("MPV: pause = \(paused), isReady = \(isReady), hasReachedEOF = \(hasReachedEOF)", category: .mpv)
                isPlaying = !paused
                // Only send state changes after we're ready (have actual video)
                // This prevents premature transition from loading to playing
                // Don't override ended state when MPV pauses at EOF (keep-open=yes)
                if isReady && !hasReachedEOF {
                    delegate?.backend(self, didChangeState: paused ? .paused : .playing)
                }
            }

        case "demuxer-cache-time":
            if let cached = value as? Double, cached >= 0 {
                demuxerCacheTime = cached
                bufferedTime = currentTime + cached
                delegate?.backend(self, didUpdateBufferedTime: bufferedTime)
            }

        case "speed":
            if let speed = value as? Double {
                _rate = Float(speed)
            }

        case "volume":
            if let vol = value as? Double {
                _volume = Float(vol / 100)
            }

        case "mute":
            if let muted = value as? Bool {
                _isMuted = muted
            }

        case "core-idle":
            // core-idle indicates MPV is processing, but doesn't mean frames are ready
            break

        case "eof-reached":
            // With keep-open=yes, MPV sends eof-reached=true instead of end-file event
            if let eofReached = value as? Bool {
                hasReachedEOF = eofReached
                if eofReached {
                    LoggingService.shared.debug("MPV: EOF reached", category: .mpv)
                    isPlaying = false
                    delegate?.backend(self, didChangeState: .ended)
                    delegate?.backendDidFinishPlaying(self)
                }
            }

        case "seeking":
            if let seeking = value as? Bool {
                isSeeking = seeking
                LoggingService.shared.debug("MPV: seeking = \(seeking), hasDisplayedVideo = \(hasDisplayedVideo), isReady = \(isReady)", category: .mpv)

                // When seeking completes, check if we can mark ready
                if !seeking {
                    // Try to mark ready based on video size (fallback for render callback)
                    checkAndMarkReadyIfVideoAvailable()

                    // If we already displayed a frame before seek started, we can mark ready now
                    // This handles the case where handleFirstFrameRendered was deferred due to pendingInitialSeek
                    if hasDisplayedVideo && !isReady {
                        LoggingService.shared.debug("MPV: Seek completed with displayed video, marking ready", category: .mpv)
                        isReady = true
                        hasStartedPlayback = true

                        // Resume playback if we paused for initial seek
                        if pendingPlayAfterSeek {
                            pendingPlayAfterSeek = false
                            mpvClient?.play()
                        }

                        delegate?.backendDidBecomeReady(self)
                        delegate?.backend(self, didChangeState: isPlaying ? .playing : .ready)
                    }
                }
            }

        case "width":
            if let width = value as? Int64, width > 0 {
                videoWidth = Int(width)
                notifyVideoSizeIfReady()
            }

        case "height":
            if let height = value as? Int64, height > 0 {
                videoHeight = Int(height)
                notifyVideoSizeIfReady()
            }

        case "container-fps":
            if let fps = value as? Double, fps > 0 {
                containerFps = fps
                updateRenderViewFPS()
                #if os(tvOS)
                applyTVDisplayCriteria()
                #endif
            }

        #if os(tvOS)
        case "video-params/gamma":
            if let gamma = value as? String {
                videoGamma = gamma
                applyTVDisplayCriteria()
            }
        #endif

        case "paused-for-cache":
            if let isPausedForCache = value as? Bool {
                pausedForCache = isPausedForCache
                // Use cached values to avoid sync fetch on main thread
                LoggingService.shared.debug("MPV: paused-for-cache = \(isPausedForCache), cache-time = \(demuxerCacheTime)s", category: .mpv)

                // Check for buffer stall condition
                if isPausedForCache && cacheBufferingState == 0 && !isInitialLoading {
                    startBufferStallDetection()
                } else {
                    stopBufferStallDetection()
                }
            }

        case "cache-buffering-state":
            if let state = value as? Int64 {
                cacheBufferingState = Int(state)
                LoggingService.shared.debug("MPV: cache-buffering-state = \(state)%", category: .mpv)
                delegate?.backend(self, didUpdateBufferProgress: Int(state))

                // If buffer is no longer at 0%, cancel stall detection
                if state > 0 {
                    stopBufferStallDetection()
                }
            }

        case "video-codec":
            if let codec = value as? String {
                videoCodec = codec
            }

        case "hwdec-current":
            if let hwdec = value as? String {
                hwdecCurrent = hwdec
            }

        case "hwdec-interop":
            if let interop = value as? String {
                hwdecInterop = interop
            }

        default:
            break
        }
    }

    /// Notify delegate of video size when both dimensions are available
    private func notifyVideoSizeIfReady() {
        guard videoWidth > 0, videoHeight > 0 else { return }
        LoggingService.shared.debug("MPV: Video size detected: \(videoWidth)x\(videoHeight)", category: .mpv)
        delegate?.backend(self, didUpdateVideoSize: videoWidth, height: videoHeight)

        // Update PiP capture dimensions and aspect ratio, coalesced.
        // Width and height arrive as separate MPV property events and the old
        // values are kept across video switches, so right after a switch one of
        // them can still be stale (e.g. new width paired with old height).
        // Debounce so capture buffers and the PiP window never see that
        // transient mixed size.
        #if os(iOS) || os(macOS)
        pipVideoSizeUpdateTask?.cancel()
        pipVideoSizeUpdateTask = Task { [weak self] in
            try? await Task.sleep(for: .milliseconds(100))
            guard let self, !Task.isCancelled else { return }
            let width = self.videoWidth
            let height = self.videoHeight
            guard width > 0, height > 0 else { return }
            // Content dimensions must update before the aspect ratio: on macOS
            // the aspect update flushes the PiP sample buffer during active PiP,
            // and a stale-sized frame captured afterwards would make AVKit
            // re-read the old dimensions.
            self.renderView?.videoContentWidth = width
            self.renderView?.videoContentHeight = height
            self.pipBridge?.updateVideoAspectRatio(CGFloat(width) / CGFloat(height))
        }
        #endif

        // Update render view with video FPS for display link frame rate matching
        updateRenderViewFPS()

        // Use video size detection as a fallback signal that video is ready
        // This handles cases where the render view's first-frame callback doesn't fire
        checkAndMarkReadyIfVideoAvailable()
    }

    #if os(tvOS)
    /// Apply tvOS display-mode criteria (frame rate / dynamic range) based on the
    /// currently-cached MPV video parameters. Safe to call repeatedly as more info
    /// arrives (fps may land before gamma or vice versa).
    private func applyTVDisplayCriteria() {
        let fps = containerFps > 0 ? containerFps : nil
        let gamma = videoGamma.isEmpty ? nil : videoGamma
        Task { @MainActor in
            TVDisplayModeManager.shared.apply(fps: fps, gamma: gamma)
        }
    }

    private func clearTVDisplayCriteria() {
        Task { @MainActor in
            TVDisplayModeManager.shared.clear()
        }
    }
    #endif

    /// Seed render/display cadence from API metadata before MPV finishes probing.
    /// MPV will overwrite this later with the exact container FPS when available.
    private func applyStreamFrameRateHint(_ stream: Stream) {
        guard let fps = stream.fps, fps > 0 else { return }
        containerFps = Double(fps)
        updateRenderViewFPS()
        #if os(tvOS)
        applyTVDisplayCriteria()
        #endif
    }

    /// Update render view's video FPS for display link frame rate matching
    private func updateRenderViewFPS() {
        // Use cached container-fps (set via property observation to avoid sync fetch on main thread)
        guard containerFps > 0 else { return }
        renderView?.videoFPS = containerFps
        LoggingService.shared.debug("MPV: Video FPS detected: \(containerFps)", category: .mpv)
    }

    /// Mark as ready if we have video dimensions and aren't seeking
    /// This is a fallback for when the render view's first-frame callback doesn't fire
    private func checkAndMarkReadyIfVideoAvailable() {
        guard !isReady, !isSeeking, videoWidth > 0, videoHeight > 0 else { return }

        LoggingService.shared.debug("MPV: Marking ready based on video size detection", category: .mpv)
        isReady = true
        hasDisplayedVideo = true
        hasStartedPlayback = true

        // Resume playback if we paused for initial seek
        if pendingPlayAfterSeek {
            pendingPlayAfterSeek = false
            mpvClient?.play()
            LoggingService.shared.debug("MPV: Resuming playback after video size detected", category: .mpv)
        }

        // Notify UI that we're ready to show video
        delegate?.backendDidBecomeReady(self)
        delegate?.backend(self, didChangeState: isPlaying ? .playing : .ready)
    }

    /// Mark as ready if this is an audio-only stream detected by MPV
    /// Audio-only streams (like SoundCloud) have no video codec and zero dimensions
    /// This is a fallback when no video frames or dimensions are available
    private func checkAndMarkReadyIfAudioOnlyDetected() {
        guard !isReady, !isSeeking else { return }
        // Only apply this path for streams the user actually selected as audio-only.
        // For video streams with a separate audio track (DASH), PLAYBACK_RESTART can
        // fire before mpv has demuxed the video — videoWidth/codec are still 0 — and
        // we'd incorrectly mark ready as audio-only and start playback before video
        // decoding catches up, producing an audible startup A/V desync.
        guard currentStream?.isAudioOnly == true else { return }
        // Detect audio-only: no video codec and no video dimensions from MPV
        guard videoCodec.isEmpty, videoWidth == 0, videoHeight == 0 else { return }
        // Ensure stream metadata is loaded
        guard duration > 0 else { return }

        LoggingService.shared.debug("MPV: Marking ready for audio-only stream (no video track detected)", category: .mpv)
        isReady = true
        hasStartedPlayback = true

        // Resume playback if we paused for initial seek
        if pendingPlayAfterSeek {
            pendingPlayAfterSeek = false
            mpvClient?.play()
            LoggingService.shared.debug("MPV: Resuming playback after audio-only stream ready", category: .mpv)
        }

        delegate?.backendDidBecomeReady(self)
        delegate?.backend(self, didChangeState: isPlaying ? .playing : .ready)
    }

    private func handleEvent(_ event: mpv_event_id) {
        switch event {
        case MPV_EVENT_FILE_LOADED:
            LoggingService.shared.debug("MPV: File loaded", category: .mpv)
            startPlaybackStatsLogging()
            // Log hwdec diagnostics on tvOS (use cached values to avoid sync fetch)
            #if os(tvOS)
            let codec = videoCodec.isEmpty ? "unknown" : videoCodec
            let hwdec = hwdecCurrent.isEmpty ? "none" : hwdecCurrent
            let interop = hwdecInterop.isEmpty ? "none" : hwdecInterop
            LoggingService.shared.debug("MPV: Video codec: \(codec), hwdec-current: \(hwdec), hwdec-interop: \(interop)", category: .mpv)
            applyTVDisplayCriteria()
            #endif
            #if os(iOS) || os(tvOS)
            reactivateAudioSession()
            #endif

        case MPV_EVENT_PLAYBACK_RESTART:
            // PLAYBACK_RESTART fires when seek/load completes and playback can begin
            // When loading with external audio, this signals the audio track is ready
            LoggingService.shared.debug("MPV: Playback restart event (waitingForAudio=\(isWaitingForExternalAudio))", category: .mpv)
            if isWaitingForExternalAudio {
                isWaitingForExternalAudio = false
            }

            // Audio-only streams become ready when playback can begin
            // (no video frames or dimensions to wait for)
            checkAndMarkReadyIfAudioOnlyDetected()

            #if os(iOS) || os(tvOS)
            reactivateAudioSession()
            #endif

        case MPV_EVENT_SEEK:
            LoggingService.shared.debug("MPV: Seek completed", category: .mpv)

        case MPV_EVENT_AUDIO_RECONFIG:
            LoggingService.shared.debug("MPV: Audio reconfigured", category: .mpv)
            #if os(iOS) || os(tvOS)
            reactivateAudioSession()
            #endif

        default:
            break
        }
    }

    // MARK: - Audio Session

    #if os(iOS) || os(tvOS)
    /// Ensures audio session is active for Now Playing integration.
    /// Call this before setting up Now Playing info to ensure the system
    /// recognizes the app as an active media source.
    func ensureAudioSessionActive() {
        reactivateAudioSession()
    }

    /// Re-activates the audio session to ensure Now Playing integration works.
    /// MPV's audio handling can cause iOS to lose track of the active audio source,
    /// so we need to re-activate the session at key playback events.
    private func reactivateAudioSession() {
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playback, mode: .moviePlayback, options: [])
            try session.setActive(true, options: [])
            LoggingService.shared.debug("MPV: Audio session reactivated for Now Playing", category: .mpv)
        } catch {
            LoggingService.shared.error("MPV: Failed to reactivate audio session: \(error.localizedDescription)", category: .mpv)
        }
    }
    #endif

    private func handleEndFile(reason: MPVEndFileReason, errorCode: Int32 = 0, errorString: String? = nil) {
        switch reason {
        case .eof:
            LoggingService.shared.debug("MPV: End of file", category: .mpv)
            isPlaying = false
            delegate?.backend(self, didChangeState: .ended)
            delegate?.backendDidFinishPlaying(self)

        case .error:
            // During initial loading, error handling is done in waitForReady() / loadWithRetry()
            // Only report errors here if we're not in initial loading (e.g., mid-playback failure)
            if !isInitialLoading {
                LoggingService.shared.logMPVError("MPV: Playback error")

                // Mid-playback errors are likely due to expired stream URLs
                // Request stream refresh to attempt recovery
                LoggingService.shared.logMPV("MPV: Requesting stream refresh for mid-playback error")
                delegate?.backend(self, didRequestStreamRefresh: currentTime)
            } else {
                // Capture detailed cause for waitForReady to surface in BackendError.loadFailed.
                lastLoadErrorDetail = composeLoadErrorDetail(errorCode: errorCode, errorString: errorString)
                loadFailedDuringWait = true
                LoggingService.shared.debug("MPV: Load error (will retry) — \(lastLoadErrorDetail ?? errorString ?? "unknown")", category: .mpv)
            }

        case .stop:
            // During initial loading (e.g., stream switch), the previous file ends with .stop
            // Don't report idle state as we're loading the new stream
            if !isInitialLoading {
                LoggingService.shared.debug("MPV: Playback stopped", category: .mpv)
                isPlaying = false
                delegate?.backend(self, didChangeState: .idle)
            } else {
                LoggingService.shared.debug("MPV: Previous stream stopped (loading new stream)", category: .mpv)
            }

        default:
            break
        }
    }

    // MARK: - Buffer Stall Detection

    /// Start monitoring for buffer stall (buffer stuck at 0% for too long).
    /// Called when paused-for-cache becomes true with 0% buffer during playback.
    private func startBufferStallDetection() {
        // Don't restart if already tracking
        guard bufferStallStartTime == nil else { return }

        bufferStallStartTime = Date()
        LoggingService.shared.debug("MPV: Buffer stall detection started", category: .mpv)

        // Start a periodic check task
        bufferStallCheckTask = Task { @MainActor [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(5))  // Check every 5 seconds

                guard let self, !Task.isCancelled else { return }
                guard let stallStart = self.bufferStallStartTime else { return }

                let stallDuration = Date().timeIntervalSince(stallStart)
                // Use cached values (updated via property observation) to avoid sync fetch
                let bufferingState = self.cacheBufferingState
                let isPausedForCache = self.pausedForCache

                // Log stall progress
                LoggingService.shared.debug("MPV: Buffer stall check - duration=\(Int(stallDuration))s, buffering=\(bufferingState)%, pausedForCache=\(isPausedForCache)", category: .mpv)

                // If buffer is still at 0% and paused for cache after timeout, trigger refresh
                if stallDuration >= self.bufferStallTimeout && bufferingState == 0 && isPausedForCache {
                    LoggingService.shared.logMPV("MPV: Buffer stalled for \(Int(stallDuration))s, requesting stream refresh")
                    self.stopBufferStallDetection()
                    self.delegate?.backend(self, didRequestStreamRefresh: self.currentTime)
                    return
                }
            }
        }
    }

    /// Stop buffer stall monitoring.
    /// Called when buffer recovers, playback resumes, or refresh is triggered.
    private func stopBufferStallDetection() {
        guard bufferStallStartTime != nil else { return }

        bufferStallCheckTask?.cancel()
        bufferStallCheckTask = nil
        bufferStallStartTime = nil
        LoggingService.shared.debug("MPV: Buffer stall detection stopped", category: .mpv)
    }

    // MARK: - Playback Stats Logging

    /// Log cache/frame-drop/pacing stats every 10s while a file is loaded, at INFO level
    /// so they appear in user-exported logs (diagnostics for GitHub #947/#949).
    /// Only logs while verbose MPV logging is enabled in settings.
    private func startPlaybackStatsLogging() {
        guard playbackStatsTask == nil else { return }

        playbackStatsTask = Task { @MainActor [weak self] in
            #if os(macOS)
            var previousHealth: MPVRenderHealth?
            var previousVoDrops: Int?
            var watchdogFiredLastTick = false
            #endif
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(10))
                guard let self, !Task.isCancelled, let client = self.mpvClient else { return }

                #if !os(macOS)
                // Only macOS needs the property fetch every tick (render
                // watchdog below). Elsewhere the batched mpv_get_property
                // calls contend with the playback core and caused a visible
                // stutter every 10s on tvOS, so skip the fetch entirely
                // unless verbose logging wants the stats line.
                guard MPVLogging.verboseEnabled else { continue }
                #endif

                let props = await client.getDebugPropertiesAsync()

                #if os(macOS)
                // Render watchdog — runs every tick regardless of the verbose
                // gate. Detects a stalled video output: playback advancing and
                // frames being consumed (skip-render) or dropped by the VO,
                // but not a single successful draw since the last tick. That
                // is the "permanent black video" signature (shared render view
                // parented in a non-visible window); attempt recovery by
                // re-attaching the view to a visible container.
                let health = self._playerView?.renderHealthSnapshot()
                if let health {
                    let drawsDelta = health.draws &- (previousHealth?.draws ?? health.draws)
                    let skipsDelta = health.skips &- (previousHealth?.skips ?? health.skips)
                    let voDrops = props.frameDropCount
                    let voDropsDelta = (voDrops ?? 0) - (previousVoDrops ?? voDrops ?? 0)

                    if watchdogFiredLastTick {
                        let outcome = drawsDelta > 0 ? "succeeded" : "failed"
                        LoggingService.shared.warning(
                            "MPV render watchdog: recovery \(outcome) (draws=+\(drawsDelta), skips=+\(skipsDelta), attach: \(self._playerView?.attachmentDescription ?? "no view"))",
                            category: .mpv
                        )
                        watchdogFiredLastTick = false
                    }

                    if self.isPlaying, !self.isPiPActive, previousHealth != nil,
                       drawsDelta == 0, skipsDelta > 20 || voDropsDelta > 50 {
                        LoggingService.shared.warning(
                            "MPV render watchdog: video output stalled (draws=+0, skips=+\(skipsDelta), voDrops=+\(voDropsDelta), attach: \(self._playerView?.attachmentDescription ?? "no view")) - attempting recovery",
                            category: .mpv
                        )
                        MPVContainerNSView.recoverSharedPlayerViewIfNeeded()
                        self._playerView?.resumeRendering()
                        watchdogFiredLastTick = true
                    }

                    previousVoDrops = voDrops ?? previousVoDrops
                }

                // Misplaced-view check: the player window is on screen but the
                // shared render view lives in a different window (e.g. the mini
                // capsule preview grabbed it during an expand race). Draws look
                // healthy, yet the player window shows black.
                if !self.isPiPActive,
                   let playerWindow = ExpandedPlayerWindowManager.shared.currentPlayerWindow,
                   playerWindow.isVisible,
                   let view = self._playerView, view.window !== playerWindow {
                    LoggingService.shared.warning(
                        "MPV render watchdog: player view outside visible player window (attach: \(view.attachmentDescription)) - re-attaching",
                        category: .mpv
                    )
                    if MPVContainerNSView.recoverSharedPlayerViewIfNeeded() {
                        self.resumeRendering()
                    }
                }
                #endif

                // Gate on the verbose setting each tick (not at task start) so
                // toggling it mid-playback takes effect without a reload
                guard MPVLogging.verboseEnabled else {
                    #if os(macOS)
                    previousHealth = health
                    #endif
                    continue
                }

                func megabytes(_ bytes: Int64) -> String {
                    String(format: "%.1fMiB", Double(bytes) / 1_048_576)
                }

                var parts: [String] = ["paused=\(!self.isPlaying)"]
                if let hwdec = props.hwdecCurrent { parts.append("hwdec=\(hwdec)") }
                if let fps = props.estimatedVfFps { parts.append(String(format: "vf-fps=%.2f", fps)) }
                if let avsync = props.avsync { parts.append(String(format: "avsync=%.3f", avsync)) }
                var drops: [String] = []
                if let vo = props.frameDropCount { drops.append("vo=\(vo)") }
                if let dec = props.decoderFrameDropCount { drops.append("dec=\(dec)") }
                if let mistimed = props.mistimedFrameCount { drops.append("mistimed=\(mistimed)") }
                if let delayed = props.voDelayedFrameCount { drops.append("delayed=\(delayed)") }
                if !drops.isEmpty { parts.append("dropped(\(drops.joined(separator: " ")))") }
                var cache: [String] = []
                if let duration = props.demuxerCacheDuration { cache.append(String(format: "dur=%.1fs", duration)) }
                if let state = props.cacheState {
                    cache.append("fw=\(megabytes(state.forwardBytes))")
                    cache.append("rate=\(megabytes(state.inputRate))/s")
                    cache.append("eof=\(state.eofCached)")
                }
                if !cache.isEmpty { parts.append("cache(\(cache.joined(separator: " ")))") }

                #if os(macOS)
                if let health {
                    let drawsDelta = health.draws &- (previousHealth?.draws ?? health.draws)
                    let skipsDelta = health.skips &- (previousHealth?.skips ?? health.skips)
                    let droppedDelta = health.droppedDraws &- (previousHealth?.droppedDraws ?? health.droppedDraws)
                    parts.append("render(draws=+\(drawsDelta) skips=+\(skipsDelta) dropped=+\(droppedDelta))")
                }
                if let view = self._playerView {
                    parts.append("attach(\(view.attachmentDescription))")
                }
                previousHealth = health
                #endif

                LoggingService.shared.info("MPV playback stats: \(parts.joined(separator: " "))", category: .mpv)
            }
        }
    }

    /// Stop periodic playback stats logging.
    private func stopPlaybackStatsLogging() {
        playbackStatsTask?.cancel()
        playbackStatsTask = nil
    }
}
