import AVFoundation
import Combine
import Foundation
import NitroModules

// MARK: - DecoderState (mirrors C++ aviation::DecoderState)

/// Domain-agnostic decoder state reported back to the C++ coordinator.
/// Maps 1:1 to the C++ DecoderState enum in PlaybackState.hpp.
enum DecoderState: Int {
    case idle = 0
    case loading = 1
    case ready = 2
    case buffering = 3
    case ended = 4
    case error = 5
    /// Transport observed paused/playing at the platform level. Covers
    /// external actors (PiP controls, fullscreen transport, notifications,
    /// interruptions); command-driven changes also report these — the
    /// state machine transitions are idempotent.
    case paused = 6
    case playing = 7
}

// MARK: - DecoderEventCallback

/// The adapter reports platform events through this callback.
/// The HybridPlaybackEngine bridge translates these into C++ DecoderEvents
/// and feeds them to the PlaybackCoordinator.
///
/// This keeps the adapter a pure Swift class with no C++ dependencies.
struct DecoderEventCallbacks {
    /// Called when the decoder state changes (item ready, buffering, ended, error).
    var onStateChanged: ((_ state: DecoderState) -> Void)?

    /// Called on periodic time updates (~250ms during playback).
    var onTimeUpdate: ((_ currentMs: Double, _ durationMs: Double,
                        _ seekableStartMs: Double, _ seekableEndMs: Double,
                        _ isLive: Bool) -> Void)?

    /// Called when the decoder encounters an error.
    var onError: ((_ message: String, _ code: String) -> Void)?

    /// Called when the observed bitrate changes (from AVPlayerItem access log).
    var onBitrateChange: ((_ bitrateBps: Int64) -> Void)?

    /// Called when subtitle/audio tracks are discovered from the media.
    /// Typically fired once per item after readyToPlay when AVMediaSelectionGroups are available.
    var onTracksDiscovered: ((_ subtitleTracks: [SubtitleTrack], _ audioTracks: [AudioTrack]) -> Void)?
}

// MARK: - AVPlayerDecoderAdapter

/**
 * AVPlayerDecoderAdapter — Wraps AVPlayer as a hexagonal decoder port.
 *
 * This adapter is the ONLY class that touches AVFoundation for playback.
 * It has zero knowledge of the domain state machine, queue, or Nitro bridge.
 *
 * Architecture:
 *   C++ PlaybackCoordinator emits CommandEvents
 *     -> HybridPlaybackEngine bridge receives them on main thread
 *     -> Calls methods on this adapter (handleLoad, handlePlay, etc.)
 *   This adapter observes AVPlayer via KVO
 *     -> Translates platform callbacks into DecoderEvents
 *     -> Reports back via DecoderEventCallbacks
 *     -> Bridge forwards to C++ coordinator.onDecoderEvent()
 *
 * Threading:
 * @MainActor guarantees all mutations and reads happen on the main thread.
 * The compiler enforces this statically — no runtime dispatch needed.
 * KVO callbacks deliver on arbitrary threads and dispatch to main before
 * calling back into the adapter.
 */
/// All methods must be called on the main thread.
/// This is enforced by the caller (HybridPlaybackEngine.handleCommandEvent)
/// via dispatchPrecondition. We can't use @MainActor because the engine
/// itself is non-isolated (Nitro calls it from the JS thread).
class AVPlayerDecoderAdapter: NSObject, DecoderPort {
    // MARK: - Callbacks

    /// Set these before issuing any commands. The bridge sets them at init.
    var callbacks = DecoderEventCallbacks()

    // MARK: - AVPlayer

    /// Created eagerly so VideoView can hold a non-nil AVPlayer reference
    /// from engine creation.
    /// Items are loaded later via replaceCurrentItem(with:).
    private(set) var avPlayer: AVPlayer? = AVPlayer()
    private var avPlayerItem: AVPlayerItem?

    // MARK: - KVO observation tokens

    private var timeControlStatusObservation: NSKeyValueObservation?
    private var itemStatusObservation: NSKeyValueObservation?
    private var bufferEmptyObservation: NSKeyValueObservation?
    private var bufferKeepUpObservation: NSKeyValueObservation?
    private var bufferFullObservation: NSKeyValueObservation?

    // MARK: - Time observer (legacy)

    private var timeObserver: Any?

    // MARK: - Async position streaming (replaces timer)

    private var positionStreamTask: Task<Void, Never>?
    private var positionPublisher: AnyPublisher<Double, Never>?
    private var lastReportedPosition: Double = -1
    private var lastPositionUpdateTime: TimeInterval = 0
    private var isScrubbing = false

    // MARK: - Notification observers

    private var notificationObservers: [NSObjectProtocol] = []

    // MARK: - Internal state

    /// Whether we've received readyToPlay for the current item.
    /// Used to avoid sending duplicate READY states.
    private var itemIsReady = false

    /// Stored rate for when play is issued.
    private var currentRate: Double = 1.0

    /// Whether loop is enabled (adapter handles loop replay).
    private var loopEnabled = false

    /// Last reported bitrate to avoid duplicate events.
    private var lastReportedBitrate: Int64 = 0

    /// Maximum forward buffer duration in seconds. 0 = system default.
    /// Set by bridge via setBufferConfig(). Applied to each new AVPlayerItem in handleLoad().
    var maxBufferDuration: TimeInterval = 0

    // MARK: - Initialization

    override init() {}

    // MARK: - Command handlers

    //
    // Called by HybridPlaybackEngine on the main thread when it receives
    // CommandEvents from the C++ coordinator. @MainActor guarantees this
    // at compile time — no runtime dispatch wrappers needed.

    /// DECODER_LOAD — Load a pre-built AVURLAsset.
    ///
    /// Receives an AVURLAsset (prepared by the pipeline terminal closure),
    /// sets up KVO observations, and assigns to AVPlayer. Does NOT start
    /// playback — that happens when handlePlay() is called (after auto-play
    /// triggers DECODER_PLAY).
    ///
    /// The decoder has zero knowledge of caching or DRM — it simply plays
    /// whatever asset it receives (PURE-01).
    func handleLoad(_ request: DecoderLoadRequest) {
        // A prepared asset (cache interceptor / warm pool) supersedes
        // building one from the URI.
        let asset: AVURLAsset
        if let prepared = request.preparedAsset {
            asset = prepared
        } else {
            // Plain asset: non-cached content (DRM, live, or cache disabled).
            guard let url = URL(string: request.uri) else { return }
            var opts: [String: Any]? = nil
            if let headers = request.headers, !headers.isEmpty {
                opts = ["AVURLAssetHTTPHeaderFieldsKey": headers]
            }
            asset = opts != nil ? AVURLAsset(url: url, options: opts!) : AVURLAsset(url: url)
        }

        // Pause current playback immediately
        avPlayer?.pause()

        // Reset ready flag
        itemIsReady = false

        // Tear down previous observations
        teardownItemObservations()
        removeTimeObserver()

        // Fully release the previous item before loading the new one.
        // This ensures any pending AVAssetResourceLoaderDelegate callbacks
        // from a cache-aware asset are cancelled cleanly, preventing crashes
        // when switching between different media types (e.g., audio ↔ video).
        if avPlayerItem != nil {
            avPlayer?.replaceCurrentItem(with: nil)
            avPlayerItem = nil
        }

        // Attach DRM session if provided by pipeline interceptor
        // Must happen BEFORE AVPlayerItem creation for FairPlay
        if let keySession = request.contentKeySession {
            keySession.addContentKeyRecipient(asset)
        }

        // Build player item
        let playerItem = AVPlayerItem(asset: asset)

        // Apply buffer config
        if maxBufferDuration > 0 {
            playerItem.preferredForwardBufferDuration = maxBufferDuration
        }

        // Apply clipping
        if let startMs = request.startPositionMs {
            let startTime = CMTime(seconds: startMs / 1000.0, preferredTimescale: 600)
            playerItem.seek(to: startTime, completionHandler: nil)
        }
        if let endMs = request.endPositionMs {
            let endTime = CMTime(seconds: endMs / 1000.0, preferredTimescale: 600)
            playerItem.forwardPlaybackEndTime = endTime
        }

        // Store before giving to player (status can transition synchronously)
        avPlayerItem = playerItem
        observeItemStatus(playerItem)
        observeItemBufferState(playerItem)
        setupNotificationObservers(playerItem)

        avPlayer?.replaceCurrentItem(with: playerItem)

        // A file already on disk has nothing to stall on. Leaving stall
        // minimisation on makes AVPlayer withhold playback while it decides
        // it has buffered enough, which measures as ~1s before the first
        // frame of a local recording. Set per load — it has to follow the
        // item, not the player's lifetime.
        avPlayer?.automaticallyWaitsToMinimizeStalling = !asset.url.isFileURL

        // Set up time-control-status KVO on first load
        if timeControlStatusObservation == nil {
            observePlayerTimeControlStatus()
        }

        setupTimeObserver()
    }

    /// DECODER_PLAY — Start or resume playback.
    func handlePlay() {
        guard let player = avPlayer else { return }
        player.rate = Float(currentRate)
    }

    /// DECODER_PAUSE — Pause playback.
    func handlePause() {
        avPlayer?.pause()
    }

    /// DECODER_SEEK — Seek to a position.
    func handleSeek(positionMs: Double) {
        guard let player = avPlayer else { return }
        let time = CMTime(seconds: positionMs / 1000.0, preferredTimescale: 600)
        player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
            guard let self else { return }
            // Report the landed position once — no polling needed
            let landed = CMTimeGetSeconds(player.currentTime()) * 1000.0
            guard landed.isFinite else { return }
            callbacks.onTimeUpdate?(landed, durationMs,
                                    seekableStartMs, seekableEndMs,
                                    isLiveStream)
        }
    }

    /// DECODER_STOP — Stop playback and release the current item.
    func handleStop() {
        avPlayer?.pause()
        avPlayer?.replaceCurrentItem(with: nil)
        teardownItemObservations()
        removeTimeObserver()
        avPlayerItem = nil
        itemIsReady = false
        lastReportedBitrate = 0
    }

    /// DECODER_DESTROY — Tear down all resources.
    func handleDestroy() {
        avPlayer?.pause()
        teardownAllObservations()
        removeTimeObserver()
        avPlayer?.replaceCurrentItem(with: nil)
        avPlayer = nil
        avPlayerItem = nil
        itemIsReady = false
        lastReportedBitrate = 0
    }

    /// DECODER_SET_RATE — Update the playback rate.
    func handleSetRate(_ rate: Double) {
        currentRate = rate
        guard let player = avPlayer else { return }
        if player.timeControlStatus == .playing {
            player.rate = Float(rate)
        }
    }

    /// DECODER_SET_VOLUME — Update volume.
    func handleSetVolume(_ volume: Double) {
        avPlayer?.volume = Float(volume)
    }

    /// DECODER_SET_MUTED — Update muted state.
    func handleSetMuted(_ muted: Bool) {
        avPlayer?.isMuted = muted
    }

    /// Set loop mode (not a CommandEvent — set directly by the bridge).
    func setLoop(_ enabled: Bool) {
        loopEnabled = enabled
    }

    // MARK: - Sync property reads (for bridge getters)

    /// Current playback position in milliseconds.
    var currentTimeMs: Double {
        guard let player = avPlayer else { return 0 }
        let seconds = CMTimeGetSeconds(player.currentTime())
        return seconds.isFinite ? seconds * 1000.0 : 0
    }

    /// Duration of current item in milliseconds. Infinity for live.
    var durationMs: Double {
        guard let item = avPlayerItem else { return 0 }
        let seconds = CMTimeGetSeconds(item.duration)
        if !seconds.isFinite { return Double.infinity }
        return seconds * 1000.0
    }

    /// Seekable range start in milliseconds.
    var seekableStartMs: Double {
        guard let item = avPlayerItem,
              let range = item.seekableTimeRanges.first?.timeRangeValue else { return 0 }
        let seconds = CMTimeGetSeconds(range.start)
        return seconds.isFinite ? seconds * 1000.0 : 0
    }

    /// Seekable range end in milliseconds.
    var seekableEndMs: Double {
        guard let item = avPlayerItem,
              let range = item.seekableTimeRanges.last?.timeRangeValue else { return durationMs }
        let seconds = CMTimeGetSeconds(range.start) + CMTimeGetSeconds(range.duration)
        return seconds.isFinite ? seconds * 1000.0 : durationMs
    }

    /// Whether the current item is a live stream.
    /// Only returns true after the item reports readyToPlay with an indefinite
    /// duration. Before that, duration is unknown (also not finite) but doesn't
    /// mean the stream is live.
    var isLiveStream: Bool {
        guard let item = avPlayerItem, itemIsReady else { return false }
        let dur = CMTimeGetSeconds(item.duration)
        return !dur.isFinite
    }

    /// How far ahead the buffer extends, in milliseconds.
    var bufferedPositionMs: Double {
        guard let item = avPlayerItem else { return 0 }
        let buffered = item.loadedTimeRanges
            .compactMap(\.timeRangeValue)
            .map { CMTimeGetSeconds($0.start) + CMTimeGetSeconds($0.duration) }
            .max() ?? 0
        return buffered.isFinite ? buffered * 1000.0 : 0
    }

    // MARK: - Track discovery

    /// Discover subtitle and audio tracks from AVMediaSelectionGroups.
    /// Called once per item after readyToPlay status is observed.
    ///
    /// iOS track model:
    /// - AVAsset has AVMediaSelectionGroup per media characteristic
    /// - .legible group contains subtitle/caption tracks (VTT, TTML, CEA-608/708)
    /// - .audible group contains audio tracks (language variants, commentary)
    /// - Each group has AVMediaSelectionOption entries with locale, display name, characteristics
    ///
    /// CEA-608/708 detection:
    /// Options with AVMediaCharacteristic.transcribesSpokenDialogForAccessibility
    /// are closed captions (CC). This covers both CEA-608 and CEA-708 tracks.
    ///
    /// Forced subtitles:
    /// Options with AVMediaCharacteristic.containsOnlyForcedSubtitles are forced tracks.
    private func discoverTracks() {
        guard let asset = avPlayerItem?.asset else { return }

        let legibleGroup = asset.mediaSelectionGroup(forMediaCharacteristic: .legible)
        let audibleGroup = asset.mediaSelectionGroup(forMediaCharacteristic: .audible)

        var subtitleTracks: [SubtitleTrack] = []
        var audioTracks: [AudioTrack] = []

        // Parse subtitle/caption tracks from legible group
        if let group = legibleGroup {
            for (idx, option) in group.options.enumerated() {
                let language = option.locale?.identifier
                    .replacingOccurrences(of: "_", with: "-") ?? ""
                let label = option.displayName

                // CEA-608/708 closed captions have the accessibility transcription characteristic
                let isCC = option.hasMediaCharacteristic(.transcribesSpokenDialogForAccessibility)

                // Forced subtitles (foreign-language-only segments)
                let isForced = option.hasMediaCharacteristic(.containsOnlyForcedSubtitles)

                subtitleTracks.append(
                    SubtitleTrack(
                        index: Double(idx),
                        language: language,
                        label: label,
                        isForced: isForced,
                        isClosedCaption: isCC
                    )
                )
            }
        }

        // Parse audio tracks from audible group
        if let group = audibleGroup {
            for (idx, option) in group.options.enumerated() {
                let language = option.locale?.identifier
                    .replacingOccurrences(of: "_", with: "-") ?? ""
                let label = option.displayName

                // Get channel count from the associated AVAssetTrack's format descriptions
                var channelCount = 0
                if let asset = avPlayerItem?.asset {
                    let audioAssetTracks = asset.tracks(withMediaType: .audio)
                    if idx < audioAssetTracks.count {
                        for desc in audioAssetTracks[idx].formatDescriptions {
                            let fmtDesc = desc as! CMFormatDescription
                            if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(fmtDesc) {
                                channelCount = Int(asbd.pointee.mChannelsPerFrame)
                                break
                            }
                        }
                    }
                }

                audioTracks.append(
                    AudioTrack(
                        index: Double(idx),
                        language: language,
                        label: label,
                        channelCount: Double(channelCount)
                    )
                )
            }
        }

        // Fire callback (even if both arrays are empty -- consumer decides what to show)
        callbacks.onTracksDiscovered?(subtitleTracks, audioTracks)
    }

    // MARK: - KVO observations

    //
    // KVO callbacks fire on arbitrary threads. Each one captures the value
    // and dispatches to main, where it's safe to call @MainActor methods.

    private func observePlayerTimeControlStatus() {
        guard let player = avPlayer else { return }
        timeControlStatusObservation = player.observe(
            \.timeControlStatus,
            options: [.new, .old]
        ) { [weak self] player, _ in
            let newStatus = player.timeControlStatus
            DispatchQueue.main.async { [weak self] in
                self?.handleTimeControlStatusChange(newStatus)
            }
        }
    }

    /// Translate AVPlayer.timeControlStatus into DecoderState events.
    ///
    /// timeControlStatus is the ground truth for the transport regardless of
    /// who changed it — the coordinator's own commands, PiP controls, the
    /// AVPlayerViewController fullscreen transport, Now Playing remote
    /// commands, or an audio interruption. Both directions are reported and
    /// the C state machine treats already-known transitions as no-ops, so
    /// command-driven changes don't loop.
    private func handleTimeControlStatusChange(_ newStatus: AVPlayer.TimeControlStatus) {
        switch newStatus {
        case .playing:
            callbacks.onStateChanged?(.playing)

        case .paused:
            // Suppress the initial paused status before any item is loaded.
            guard let player = avPlayer, let item = player.currentItem else { break }
            // Suppress the transient paused blip at natural end-of-item.
            // timeControlStatus flips to .paused just before
            // .AVPlayerItemDidPlayToEndTime delivers .ended; forwarding it
            // would fire "on paused" consumers at 100%. When the item has
            // effectively reached its end, drop the paused and let the
            // end-of-item notification report .ended.
            if isItemAtEnd(item) { break }
            callbacks.onStateChanged?(.paused)

        case .waitingToPlayAtSpecifiedRate:
            // The decoder is buffering — report BUFFERING.
            callbacks.onStateChanged?(.buffering)

        @unknown default:
            break
        }
    }

    /// True when the item has effectively played to its end — used to
    /// distinguish the natural end-of-item pause from a user/transport pause.
    /// Live/indefinite-duration items never report at-end here.
    private func isItemAtEnd(_ item: AVPlayerItem) -> Bool {
        let duration = item.duration
        guard duration.isValid, !duration.isIndefinite, duration.seconds.isFinite,
              duration.seconds > 0 else { return false }
        let current = item.currentTime()
        guard current.isValid else { return false }
        // AVPlayer settles a hair short of exact duration at natural end.
        let epsilon = 0.25
        return current.seconds >= duration.seconds - epsilon
    }

    private func observeItemStatus(_ item: AVPlayerItem) {
        itemStatusObservation?.invalidate()
        itemStatusObservation = item.observe(
            \.status,
            options: [.new, .initial]
        ) { [weak self] observedItem, _ in
            let status = observedItem.status
            let itemError = observedItem.error
            DispatchQueue.main.async { [weak self] in
                guard let self else { return }
                // Guard against stale callbacks: if the observed item is no longer
                // the current item, this is a leftover dispatch from a previous item's
                // KVO firing during teardown. Ignore it.
                guard observedItem === avPlayerItem else { return }
                handleItemStatusChange(status, error: itemError)
            }
        }
    }

    /// Translate AVPlayerItem.status into DecoderState events.
    private func handleItemStatusChange(_ status: AVPlayerItem.Status, error: Error?) {
        switch status {
        case .readyToPlay:
            guard !itemIsReady else { return } // Deduplicate
            itemIsReady = true
            callbacks.onStateChanged?(.ready)
            discoverTracks()

        case .failed:
            let errorMsg = error?.localizedDescription ?? "AVPlayerItem failed"
            NSLog("[Aviation][ERR] item failed: %@ | %@" , errorMsg, String(describing: error))
            callbacks.onError?(errorMsg, "ITEM_FAILED")
            callbacks.onStateChanged?(.error)

        case .unknown:
            break

        @unknown default:
            break
        }
    }

    private func observeItemBufferState(_ item: AVPlayerItem) {
        bufferEmptyObservation?.invalidate()
        bufferKeepUpObservation?.invalidate()
        bufferFullObservation?.invalidate()

        bufferEmptyObservation = item.observe(
            \.isPlaybackBufferEmpty, options: [.new]
        ) { _, _ in
            // Buffer empty — timeControlStatus handles the BUFFERING state.
        }

        bufferKeepUpObservation = item.observe(
            \.isPlaybackLikelyToKeepUp, options: [.new]
        ) { _, _ in
            // Buffer sufficient — timeControlStatus handles the transition back.
        }

        bufferFullObservation = item.observe(
            \.isPlaybackBufferFull, options: [.new]
        ) { _, _ in
            // Buffer full — no action needed.
        }
    }

    // MARK: - Time observer

    private func setupTimeObserver() {
        guard let player = avPlayer else { return }
        removeTimeObserver()

        // Create async stream for position updates
        positionStreamTask = Task { [weak self] in
            guard let self else { return }

            // Create position stream using AsyncStream
            let positionStream = AsyncStream<Double> { continuation in
                // Use Combine publishers for reactive updates
                let ratePublisher: AnyPublisher<Double, Never> = player.publisher(for: \.rate)
                    .map { _ in self.getCurrentPosition() }
                    .eraseToAnyPublisher()

                let statusPublisher: AnyPublisher<Double, Never> = player.publisher(for: \.status)
                    .map { _ in self.getCurrentPosition() }
                    .eraseToAnyPublisher()

                let timeControlPublisher: AnyPublisher<Double, Never> = player.publisher(for: \.timeControlStatus)
                    .map { _ in self.getCurrentPosition() }
                    .eraseToAnyPublisher()

                // Combine all publishers
                let combinedPublisher = Publishers.Merge3(
                    ratePublisher,
                    statusPublisher,
                    timeControlPublisher
                )

                // For smooth playback updates, add a timer publisher that only emits when playing
                let playbackTimer = Timer.publish(every: 0.1, on: .main, in: .common)
                    .autoconnect()
                    .compactMap { _ -> Double? in
                        guard player.rate > 0 else { return nil }
                        return self.getCurrentPosition()
                    }

                // Merge all sources
                let allPublishers = Publishers.Merge(combinedPublisher, playbackTimer)
                    .removeDuplicates { (old: Double, new: Double) in
                        // Skip if position hasn't changed significantly
                        Swift.abs(old - new) < 50 // 50ms threshold
                    }

                // Subscribe and yield to stream
                let cancellable = allPublishers.sink { position in
                    continuation.yield(position)
                }

                // Store cancellable for cleanup
                continuation.onTermination = { _ in
                    cancellable.cancel()
                }
            }

            // Consume the stream with smart throttling
            for await position in positionStream {
                await handlePositionUpdate(position)
            }
        }
    }

    private func getCurrentPosition() -> Double {
        guard let player = avPlayer else { return 0 }
        let time = player.currentTime()
        guard time.isValid, time.isNumeric else { return 0 }
        return CMTimeGetSeconds(time) * 1000.0
    }

    @MainActor
    private func handlePositionUpdate(_ posMs: Double) async {
        guard posMs.isFinite else { return }

        // Smart throttling based on activity
        let now = CACurrentMediaTime()
        let player = avPlayer
        let rate = player?.rate ?? 0

        // Determine if we're scrubbing
        isScrubbing = Swift.abs(rate) > 1.1 || player?.timeControlStatus == .waitingToPlayAtSpecifiedRate

        // Adaptive throttling
        let minInterval = isScrubbing ? 0.05 : 0.5 // 50ms scrubbing, 500ms normal
        let positionDelta = Swift.abs(posMs - lastReportedPosition)
        let timeSinceLastUpdate = now - lastPositionUpdateTime

        // Skip if too soon and position hasn't jumped
        if timeSinceLastUpdate < minInterval, positionDelta < 100 {
            return
        }

        lastReportedPosition = posMs
        lastPositionUpdateTime = now

        // Gather metadata
        let durMs = durationMs
        let seekStart = seekableStartMs
        let seekEnd = seekableEndMs
        let isLive = isLiveStream

        // Push update
        callbacks.onTimeUpdate?(posMs, durMs, seekStart, seekEnd, isLive)

        // Check bitrate changes
        if let accessLog = avPlayerItem?.accessLog(),
           let lastEvent = accessLog.events.last
        {
            let bitrate = Int64(lastEvent.observedBitrate)
            if bitrate > 0, bitrate != lastReportedBitrate {
                lastReportedBitrate = bitrate
                callbacks.onBitrateChange?(bitrate)
            }
        }
    }

    private func removeTimeObserver() {
        // Cancel async stream task
        positionStreamTask?.cancel()
        positionStreamTask = nil

        // Reset state
        lastReportedPosition = -1
        lastPositionUpdateTime = 0
        isScrubbing = false

        // Remove old timer observer if it exists (for cleanup of old code)
        if let observer = timeObserver {
            avPlayer?.removeTimeObserver(observer)
            timeObserver = nil
        }
    }

    // MARK: - NotificationCenter observers

    private func setupNotificationObservers(_ item: AVPlayerItem) {
        removeNotificationObservers()

        notificationObservers.append(
            NotificationCenter.default.addObserver(
                forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
            ) { [weak self] _ in
                guard let self, item === avPlayerItem else { return }
                if loopEnabled {
                    // Loop: seek back to start and keep playing
                    avPlayer?.seek(to: .zero)
                    avPlayer?.rate = Float(currentRate)
                } else {
                    callbacks.onStateChanged?(.ended)
                }
            }
        )

        notificationObservers.append(
            NotificationCenter.default.addObserver(
                forName: .AVPlayerItemFailedToPlayToEndTime, object: item, queue: .main
            ) { [weak self] notification in
                guard let self, item === avPlayerItem else { return }
                let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error

                // Category/route switches (recorder start/stop, config
                // changes) abort the current item spuriously. Within a short
                // window after such a mutation this is noise: recover by
                // resuming instead of failing the app's error UI. Only while
                // the item itself is healthy — play() cannot recover a
                // failed item, and swallowing its error would leave the
                // consumer buffering forever. Genuinely failed items are
                // still reported through the item-status KVO path, so the
                // wall-clock window can no longer mask them.
                if avPlayerItem?.status != .failed,
                   Date().timeIntervalSince(HybridAudioSession.lastCategoryChangeAt) < 1.5
                {
                    #if DEBUG
                        NSLog("[Aviation] suppressing transient playback failure; recovering")
                    #endif
                    avPlayer?.play()
                    return
                }

                callbacks.onError?(
                    error?.localizedDescription ?? "Playback failed",
                    "PLAYBACK_FAILED"
                )
                callbacks.onStateChanged?(.error)
            }
        )
    }

    private func removeNotificationObservers() {
        for observer in notificationObservers {
            NotificationCenter.default.removeObserver(observer)
        }
        notificationObservers.removeAll()
    }

    // MARK: - Observation teardown

    private func teardownItemObservations() {
        itemStatusObservation?.invalidate()
        itemStatusObservation = nil
        bufferEmptyObservation?.invalidate()
        bufferEmptyObservation = nil
        bufferKeepUpObservation?.invalidate()
        bufferKeepUpObservation = nil
        bufferFullObservation?.invalidate()
        bufferFullObservation = nil
        removeNotificationObservers()
    }

    private func teardownAllObservations() {
        timeControlStatusObservation?.invalidate()
        timeControlStatusObservation = nil
        teardownItemObservations()
    }

    // MARK: - Lifecycle

    deinit {
        // KVO observations are invalidated automatically when the
        // NSKeyValueObservation tokens are deallocated. We only need
        // to clean up the time observer and notification observers
        // which require explicit removal.
        let player = avPlayer
        let timeObs = timeObserver
        let notifObservers = notificationObservers

        DispatchQueue.main.async {
            if let obs = timeObs {
                player?.removeTimeObserver(obs)
            }
            for observer in notifObservers {
                NotificationCenter.default.removeObserver(observer)
            }
            player?.replaceCurrentItem(with: nil)
        }
    }
}
