import AVFoundation
import NitroModules

// Command and domain event handlers from the C coordinator EventBus.
// Split out of HybridPlaybackEngine.swift; shared state and helpers
// remain on the main type.
extension HybridPlaybackEngine {
    // MARK: - Command Event Handler (from C coordinator via EventBus)

    /// Handle command events from the C state machine.
    /// These tell the decoder adapter what platform operations to perform.
    func handleCommandEvent(_ event: AviationCommandEvent) {
        dispatchPrecondition(condition: .onQueue(.main))

        let cmdType = AviationCommandType(rawValue: UInt32(event.type))

        // When an output owns playback, every decoder command goes there
        // instead. The coordinator still emitted it and still transitioned, so
        // the state machine stays authoritative regardless of where audio
        // comes out.
        let output = playbackOutput

        switch cmdType {
        case AVIATION_CMD_DECODER_LOAD:
            if let output {
                // The load pipeline is local-playback machinery — cache, warm
                // pool, DRM key sessions. None of it applies to a remote output,
                // which takes the URI and metadata as-is.
                let item = unsafeBitCast(event.nativeHandle, to: AnyObject?.self) as? HybridMediaItem
                output.handleLoad(media: OutputMedia(
                    uri: String(cCharArray: event.uri),
                    mimeType: item?.mimeType,
                    title: item?.title,
                    artist: item?.artist,
                    album: item?.album,
                    artworkUri: item?.artworkUri,
                    startPositionMs: item?.startPositionMs
                ))
                break
            }
            guard let decoder else {
                NSLog("[Aviation] DECODER_LOAD — no decoder registered. Call activateDefaultDecoder() first.")
                break
            }
            // TTFF: capture total start time at load initiation
            ttffTotalStart = CFAbsoluteTimeGetCurrent()
            ttffPending = true

            let nativeItem = unsafeBitCast(event.nativeHandle, to: AnyObject?.self) as? HybridMediaItem
            let context = LoadContext(event: event, nativeItem: nativeItem)
            #if DEBUG
                NSLog("[Aviation] DECODER_LOAD uri=%@", context.uri)
            #endif

            // Reset track state from previous item before loading new media
            _availableSubtitleTracks = []
            _availableAudioTracks = []

            pipeline.dispatchLoad(event: event, context: context) { [weak self] ctx in
                guard let self, let decoder = self.decoder else { return }

                // TTFF: determine source and capture decoder start.
                // Warm pool is only valid if content is still cached (not cleared).
                let warmPoolValid = ctx.preloadedItem != nil && cacheAdapter.isCached(uri: ctx.uri)
                if warmPoolValid {
                    ttffSource = "warm_pool"
                } else if ctx.cacheHit {
                    ttffSource = "cache_hit"
                } else {
                    ttffSource = "cold"
                }
                ttffUri = ctx.uri
                ttffDecoderStart = CFAbsoluteTimeGetCurrent()

                // Check for warm pool item first (preloaded AVPlayerItem).
                // Only use it if the content is still cached on disk. After clearCache(),
                // in-flight preloads can re-populate the warm pool with items whose
                // cache-aware assets reference deleted files. These stale items cause
                // AVPlayer to fail with -12860 because the resource loader delegate
                // can't serve data from a stale asset.
                let request = DecoderLoadRequest(
                    uri: ctx.uri,
                    headers: ctx.nativeItem?.headers,
                    startPositionMs: ctx.nativeItem?.startPositionMs,
                    endPositionMs: ctx.nativeItem?.endPositionMs
                )
                if let warmItem = ctx.preloadedItem,
                   let warmAsset = warmItem.asset as? AVURLAsset,
                   cacheAdapter.isCached(uri: ctx.uri)
                {
                    request.preparedAsset = warmAsset
                }
                // Cache interceptor's prepared asset (resource-loader-backed)
                // applies when the warm pool didn't supply one.
                if request.preparedAsset == nil {
                    request.preparedAsset = ctx.preparedAsset
                }
                // DRM session applies on every path: FairPlay registration
                // must precede player-item creation.
                request.contentKeySession = ctx.contentKeySession

                decoder.handleLoad(request)

                nowPlayingAdapter.setAVPlayerForAdMode(avDecoder?.avPlayer)
            }

        case AVIATION_CMD_DECODER_PLAY:
            // IMA command guard: suppress play during ad breaks (defense-in-depth)
            if shouldSuppressCommand(UInt32(AVIATION_CMD_DECODER_PLAY.rawValue)) {
                #if DEBUG
                    NSLog("[Aviation] Play suppressed by command guard (ad break active)")
                #endif
                break
            }
            if let output {
                output.handlePlay()
                break
            }
            guard let decoder else { break }
            decoder.handlePlay()

        case AVIATION_CMD_DECODER_PAUSE:
            if let output { output.handlePause() } else { decoder?.handlePause() }

        case AVIATION_CMD_DECODER_SEEK:
            // IMA command guard: suppress seek during ad breaks (defense-in-depth)
            if shouldSuppressCommand(UInt32(AVIATION_CMD_DECODER_SEEK.rawValue)) {
                #if DEBUG
                    NSLog("[Aviation] Seek suppressed by command guard (ad break active)")
                #endif
                break
            }
            if let output {
                output.handleSeek(positionMs: Double(event.positionMs))
            } else {
                guard let decoder else { break }
                decoder.handleSeek(positionMs: Double(event.positionMs))
            }
            // Anchor Now Playing at the target position immediately so the
            // lock screen scrubber jumps to the right spot without flicker.
            let durMs = _mediaPosition.durationMs
            nowPlayingAdapter.updateTime(MPNowPlayingAdapter.TimeInfo(
                currentMs: Double(event.positionMs),
                durationMs: durMs,
                isLive: _isLive
            ))

        case AVIATION_CMD_DECODER_STOP:
            if let output { output.handleStop() } else { decoder?.handleStop() }

        // DESTROY is local teardown, never redirected: the output's lifetime
        // belongs to whoever installed it, not to the engine's decoder.
        case AVIATION_CMD_DECODER_DESTROY:
            decoder?.handleDestroy()

        case AVIATION_CMD_DECODER_SET_RATE:
            if let output { output.handleSetRate(event.rate) } else { decoder?.handleSetRate(event.rate) }

        case AVIATION_CMD_DECODER_SET_VOLUME:
            if let output { output.handleSetVolume(event.volume) } else { decoder?.handleSetVolume(event.volume) }

        case AVIATION_CMD_DECODER_SET_MUTED:
            if let output { output.handleSetMuted(event.muted) } else { decoder?.handleSetMuted(event.muted) }

        case AVIATION_CMD_AUDIO_SESSION_ACTIVATE:
            do {
                try AVAudioSession.sharedInstance().setActive(true, options: [])
            } catch {
                NSLog("[Aviation] Failed to activate audio session: %@", error.localizedDescription)
            }

        case AVIATION_CMD_AUDIO_SESSION_DEACTIVATE:
            do {
                try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
            } catch {
                NSLog("[Aviation] Failed to deactivate audio session: %@", error.localizedDescription)
            }

        case AVIATION_CMD_VIDEO_SURFACE_ATTACH:
            // Video surface attachment handled by HybridVideoView directly — no-op in engine
            break

        case AVIATION_CMD_VIDEO_SURFACE_DETACH:
            // Video surface detachment handled by HybridVideoView directly — no-op in engine
            break

        default:
            NSLog("[Aviation] Unhandled command type: %d", event.type)
        }
    }

    // MARK: - Domain Event Handler (from C coordinator via EventBus)

    /// Handle domain events from the C coordinator.
    /// These update cached state and fire JS callbacks.
    func handleDomainEvent(_ event: AviationDomainEvent) {
        dispatchPrecondition(condition: .onQueue(.main))

        let evtType = AviationDomainEventType(rawValue: UInt32(event.type))

        // Generic lossless forwarder — every event reaches JS subscribers,
        // including types the per-type switch below does not handle (ads).
        if !domainEventEntries.isEmpty, let jsEvent = buildJsDomainEvent(event) {
            for entry in snapshot(\.domainEventEntries) {
                entry.callback(jsEvent)
            }
        }

        switch evtType {
        case AVIATION_EVT_STATE_CHANGED:
            let newState = cStateToSwift(Int(event.newState))
            let oldState = cStateToSwift(Int(event.oldState))
            _state = newState
            #if DEBUG
                NSLog("[Aviation] Engine: %@ -> %@",
                      String(describing: oldState), String(describing: newState))
            #endif
            for entry in snapshot(\.stateChangeEntries) {
                entry.callback(newState)
            }

            // Push current position to Now Playing BEFORE state change so
            // applyTime() has a fresh anchor for the new playback rate.
            // iOS interpolates from this anchor — no periodic updates needed.
            nowPlayingAdapter.updateTime(MPNowPlayingAdapter.TimeInfo(
                currentMs: _mediaPosition.currentMs,
                durationMs: _mediaPosition.durationMs,
                isLive: _isLive
            ))
            nowPlayingAdapter.handleStateChange(newState)

            // TTFF measurement: emit on first playing state after a load
            if newState == .playing, ttffPending {
                let now = CFAbsoluteTimeGetCurrent()
                let ttffTotalMs = (now - ttffTotalStart) * 1000
                let ttffDecoderMs = (now - ttffDecoderStart) * 1000
                ttffPending = false

                // Emit via C event bus
                emitTtffEvent(totalMs: Int64(ttffTotalMs), decoderMs: Int64(ttffDecoderMs))

                // Emit via onPlaybackMetric callbacks
                let ttffSourceEnum = TtffSource(fromString: ttffSource == "warm_pool" ? "warmPool" : (ttffSource == "cache_hit" ? "cacheHit" : "cold"))
                let totalMetric = PlaybackMetricEvent(type: .ttfftotal, uri: ttffUri, bytes: nil, layer: nil, durationMs: ttffTotalMs, source: ttffSourceEnum, error: nil)
                let decoderMetric = PlaybackMetricEvent(type: .ttffdecoder, uri: ttffUri, bytes: nil, layer: nil, durationMs: ttffDecoderMs, source: ttffSourceEnum, error: nil)
                let metricEntries = snapshot(\.playbackMetricEntries)
                for entry in metricEntries {
                    entry.callback(totalMetric)
                }
                for entry in metricEntries {
                    entry.callback(decoderMetric)
                }
            }

            // Reset TTFF on error state to avoid stale timestamps
            if newState == .error {
                ttffPending = false
            }

            // Resolve/reject promises based on state transitions
            handlePromisesForStateChange(from: oldState, to: newState)

        case AVIATION_EVT_POSITION_UPDATED:
            let pos = event.position
            _isLive = pos.isLive

            let progress: Double = if pos.isLive || pos.durationMs <= 0 {
                0
            } else {
                min(max(Double(pos.currentMs) / Double(pos.durationMs), 0), 1)
            }

            let liveEdgeOffset: Double
            let atLiveEdge: Bool
            if pos.isLive {
                liveEdgeOffset = max(0, Double(pos.seekableEndMs - pos.currentMs))
                atLiveEdge = liveEdgeOffset < 15000
            } else {
                liveEdgeOffset = 0
                atLiveEdge = false
            }

            let jsDurationMs: Double = pos.isLive ? Double.infinity : Double(pos.durationMs)

            // Detect when duration first becomes known (0 → real value)
            // so we can push to Now Playing for the lock screen time bar.
            let durationJustResolved = jsDurationMs > 0 && jsDurationMs.isFinite
                && _prevDurationMs == 0

            _mediaPosition = MediaPosition(
                currentMs: Double(pos.currentMs),
                durationMs: jsDurationMs,
                seekableStartMs: Double(pos.seekableStartMs),
                seekableEndMs: Double(pos.seekableEndMs),
                isLive: pos.isLive,
                progress: progress,
                liveEdgeOffsetMs: liveEdgeOffset,
                isAtLiveEdge: atLiveEdge
            )
            _prevDurationMs = jsDurationMs

            for entry in snapshot(\.mediaPositionChangeEntries) {
                entry.callback(_mediaPosition)
            }

            // Push to Now Playing when duration first resolves (so the time
            // bar appears on initial play) or when a live stream is detected.
            // All other updates come from state change / seek anchors.
            if durationJustResolved || (pos.isLive && _prevDurationMs == 0) {
                nowPlayingAdapter.updateTime(MPNowPlayingAdapter.TimeInfo(
                    currentMs: Double(pos.currentMs),
                    durationMs: jsDurationMs,
                    isLive: pos.isLive
                ))
            }

        case AVIATION_EVT_CURRENT_ITEM_CHANGED:
            // Reset duration tracking so the next track triggers a fresh
            // Now Playing time push when its duration becomes known.
            _prevDurationMs = 0

            // Update Swift-side current item from the event's queue index.
            // Skip methods call the C coordinator on the JS thread, which
            // emits this event — the event carries the authoritative queue
            // index. We resolve the item here on the main thread to avoid
            // a cross-thread data race on _currentItem.
            let queueIdx = Int(event.queueIndex)
            if queueIdx >= 0, queueIdx < _queueItemRefs.count {
                _currentItem = _queueItemRefs[queueIdx]
                _currentItemSource = .queue
            }
            // If queueIndex is -1 (direct load), _currentItem was already set
            // on the main thread by loadAndPlay's DispatchQueue.main.async block.

            fireCurrentItemChangeCallbacks()

            // Push metadata to now playing adapter
            if let item = _currentItem {
                nowPlayingAdapter.updateMetadata(MPNowPlayingAdapter.TrackMetadata(
                    uri: item.uri,
                    title: item.title,
                    artist: item.artist,
                    album: item.album,
                    artworkUri: item.artworkUri
                ))
            }

        case AVIATION_EVT_PLAYBACK_ENDED:
            for entry in snapshot(\.playbackEndedEntries) {
                entry.callback()
            }
            // Auto-advance is now handled by the C coordinator internally
            // We just need to trigger it
            if let coord = coordinator {
                aviation_coordinator_queue_auto_advance(coord)
            }

        case AVIATION_EVT_ERROR_OCCURRED:
            let message = String(cCharArray: event.errorMessage)
            let code = String(cCharArray: event.errorCode)
            // Capture for promise rejections — the error event fires before
            // the state change to .error, so these are available when
            // handlePromisesForStateChange rejects pending promises.
            lastErrorMessage = message
            lastErrorCode = code
            for entry in snapshot(\.errorEntries) {
                entry.callback(message, code)
            }

        case AVIATION_EVT_RATE_CHANGED:
            _rate = event.rate
            nowPlayingAdapter.updateRate(event.rate)

        case AVIATION_EVT_VOLUME_CHANGED:
            _volume = event.volume

        case AVIATION_EVT_MUTED_CHANGED:
            _muted = event.muted

        case AVIATION_EVT_BITRATE_CHANGED:
            let videoBps = event.videoBitrateBps
            let audioBps = event.audioBitrateBps
            #if DEBUG
                NSLog("[Aviation] Bitrate changed: video=%lld audio=%lld", videoBps, audioBps)
            #endif
            // Phase 4 will wire this to JS onBitrateChange callbacks

        case AVIATION_EVT_QUEUE_END:
            for entry in snapshot(\.queueEndEntries) {
                entry.callback()
            }

        case AVIATION_EVT_QUEUE_ITEM_CHANGED:
            for entry in snapshot(\.queueChangeEntries) {
                entry.callback()
            }

        case AVIATION_EVT_EXTENSION:
            // Feature telemetry (cache/preload) also feeds the metric
            // channel; ads flow through the generic forwarder only.
            let ext = event.extension
            let uri = String(cCharArray: ext.text)
            switch ext.code {
            case AviationExtCode.cacheHit:
                let metric = PlaybackMetricEvent(type: .cachehit, uri: uri, bytes: Double(AviationExt.value(ext, 0)), layer: nil, durationMs: nil, source: nil, error: nil)
                for entry in snapshot(\.playbackMetricEntries) {
                    entry.callback(metric)
                }
            case AviationExtCode.cacheMiss:
                let metric = PlaybackMetricEvent(type: .cachemiss, uri: uri, bytes: nil, layer: nil, durationMs: nil, source: nil, error: nil)
                for entry in snapshot(\.playbackMetricEntries) {
                    entry.callback(metric)
                }
            case AviationExtCode.preloadStarted:
                let metric = PlaybackMetricEvent(type: .preloadstarted, uri: uri, bytes: nil, layer: Double(AviationExt.value(ext, 0)), durationMs: nil, source: nil, error: nil)
                for entry in snapshot(\.playbackMetricEntries) {
                    entry.callback(metric)
                }
            case AviationExtCode.preloadCompleted:
                let metric = PlaybackMetricEvent(type: .preloadcompleted, uri: uri, bytes: nil, layer: Double(AviationExt.value(ext, 0)), durationMs: Double(AviationExt.value(ext, 1)), source: nil, error: nil)
                for entry in snapshot(\.playbackMetricEntries) {
                    entry.callback(metric)
                }
            default:
                break
            }

        default:
            break
        }
    }

    // MARK: - Generic domain-event forwarder

    /// C ordinal -> JSI enum via the camelCase vocabulary shared with the
    /// generated Kotlin DomainEvents table. Extension telemetry has no
    /// direct JS name — it is decoded per code in buildJsExtensionEvent.
    private func jsDomainEventType(_ type: Int32) -> DomainEventType? {
        switch AviationDomainEventType(rawValue: UInt32(type)) {
        case AVIATION_EVT_STATE_CHANGED: DomainEventType(fromString: "stateChanged")
        case AVIATION_EVT_POSITION_UPDATED: DomainEventType(fromString: "positionUpdated")
        case AVIATION_EVT_BUFFERING_STARTED: DomainEventType(fromString: "bufferingStarted")
        case AVIATION_EVT_BUFFERING_ENDED: DomainEventType(fromString: "bufferingEnded")
        case AVIATION_EVT_CURRENT_ITEM_CHANGED: DomainEventType(fromString: "currentItemChanged")
        case AVIATION_EVT_PLAYBACK_ENDED: DomainEventType(fromString: "playbackEnded")
        case AVIATION_EVT_QUEUE_END: DomainEventType(fromString: "queueEnd")
        case AVIATION_EVT_QUEUE_ITEM_CHANGED: DomainEventType(fromString: "queueItemChanged")
        case AVIATION_EVT_ERROR_OCCURRED: DomainEventType(fromString: "errorOccurred")
        case AVIATION_EVT_RATE_CHANGED: DomainEventType(fromString: "rateChanged")
        case AVIATION_EVT_VOLUME_CHANGED: DomainEventType(fromString: "volumeChanged")
        case AVIATION_EVT_MUTED_CHANGED: DomainEventType(fromString: "mutedChanged")
        case AVIATION_EVT_BITRATE_CHANGED: DomainEventType(fromString: "bitrateChanged")
        case AVIATION_EVT_INTERRUPTION: DomainEventType(fromString: "interruption")
        default: nil
        }
    }

    /**
     * Defaults-carrying constructor so per-type builders only pass the fields
     * their event populates; the nitrogen init requires every argument.
     */
    // swiftlint:disable:next function_parameter_count
    private func jsDomainEvent(
        type: DomainEventType,
        queueIndex: Double? = nil,
        oldState: PlaybackState? = nil,
        newState: PlaybackState? = nil,
        position: MediaPosition? = nil,
        itemSource: ItemSource? = nil,
        errorMessage: String? = nil,
        errorCode: String? = nil,
        rate: Double? = nil,
        volume: Double? = nil,
        muted: Bool? = nil,
        uri: String? = nil,
        bytes: Double? = nil,
        layer: Double? = nil,
        durationMs: Double? = nil,
        source: TtffSource? = nil,
        error: String? = nil,
        videoBitrateBps: Double? = nil,
        audioBitrateBps: Double? = nil,
        adBreakIndex: Double? = nil,
        adBreakTimeMs: Double? = nil,
        totalAdsInBreak: Double? = nil,
        adId: String? = nil,
        adTitle: String? = nil,
        adDurationMs: Double? = nil,
        adCurrentMs: Double? = nil,
        adIndexInBreak: Double? = nil,
        isSkippable: Bool? = nil,
        skipOffsetMs: Double? = nil,
        began: Bool? = nil,
        shouldResume: Bool? = nil,
        resumePolicy: AviationInterruptionMode? = nil
    ) -> DomainEvent {
        DomainEvent(
            type: type, queueIndex: queueIndex, oldState: oldState, newState: newState,
            position: position, itemSource: itemSource,
            errorMessage: errorMessage, errorCode: errorCode,
            rate: rate, volume: volume, muted: muted,
            uri: uri, bytes: bytes, layer: layer, durationMs: durationMs,
            source: source, error: error,
            videoBitrateBps: videoBitrateBps, audioBitrateBps: audioBitrateBps,
            adBreakIndex: adBreakIndex, adBreakTimeMs: adBreakTimeMs,
            totalAdsInBreak: totalAdsInBreak, adId: adId, adTitle: adTitle,
            adDurationMs: adDurationMs, adCurrentMs: adCurrentMs,
            adIndexInBreak: adIndexInBreak, isSkippable: isSkippable,
            skipOffsetMs: skipOffsetMs, began: began, shouldResume: shouldResume,
            resumePolicy: resumePolicy
        )
    }

    private func jsTtffSource(_ raw: String) -> TtffSource? {
        TtffSource(fromString: raw == "warm_pool" ? "warmPool" : (raw == "cache_hit" ? "cacheHit" : "cold"))
    }

    /// Extension code -> JS type name. Must stay in step with ExtCodes.kt
    /// (generated) and the DomainEventType union in types.nitro.ts.
    private func jsExtensionType(_ code: Int32) -> DomainEventType? {
        switch code {
        case AviationExtCode.adBreakStarted: DomainEventType(fromString: "adBreakStarted")
        case AviationExtCode.adBreakEnded: DomainEventType(fromString: "adBreakEnded")
        case AviationExtCode.adStarted: DomainEventType(fromString: "adStarted")
        case AviationExtCode.adCompleted: DomainEventType(fromString: "adCompleted")
        case AviationExtCode.adProgress: DomainEventType(fromString: "adProgress")
        case AviationExtCode.adSkipped: DomainEventType(fromString: "adSkipped")
        case AviationExtCode.adTapped: DomainEventType(fromString: "adTapped")
        case AviationExtCode.adError: DomainEventType(fromString: "adError")
        case AviationExtCode.adBuffering: DomainEventType(fromString: "adBuffering")
        case AviationExtCode.allAdsCompleted: DomainEventType(fromString: "allAdsCompleted")
        case AviationExtCode.cacheHit: DomainEventType(fromString: "cacheHit")
        case AviationExtCode.cacheMiss: DomainEventType(fromString: "cacheMiss")
        case AviationExtCode.preloadStarted: DomainEventType(fromString: "preloadStarted")
        case AviationExtCode.preloadCompleted: DomainEventType(fromString: "preloadCompleted")
        case AviationExtCode.ttff: DomainEventType(fromString: "ttff")
        default: nil
        }
    }

    /// Decode adapter telemetry into its JS payload shape. Slot conventions
    /// in aviation_extension_events.h; must mirror buildJsExtensionEvent on
    /// Android (HybridPlaybackEngine.kt).
    @discardableResult
    private func buildJsExtensionEvent(_ ext: AviationExtensionEvent) -> DomainEvent? {
        guard let eventType = jsExtensionType(ext.code) else { return nil }
        let nonEmpty: (String) -> String? = { $0.isEmpty ? nil : $0 }
        let double = { slot in Double(bitPattern: UInt64(bitPattern: AviationExt.value(ext, slot))) }

        if ext.code >= AviationExtCode.adFamilyFirst,
           ext.code <= AviationExtCode.adFamilyLast
        {
            return jsDomainEvent(
                type: eventType,
                queueIndex: 0,
                error: nonEmpty(String(cCharArray: ext.aux)),
                adBreakIndex: double(0),
                adBreakTimeMs: double(1),
                totalAdsInBreak: double(2),
                adId: nonEmpty(String(cCharArray: ext.text)),
                adTitle: nonEmpty(String(cCharArray: ext.detail)),
                adDurationMs: double(3),
                adCurrentMs: double(4),
                adIndexInBreak: double(5),
                isSkippable: ext.flags & AviationExtCode.flagAdSkippable != 0,
                skipOffsetMs: double(6)
            )
        }

        switch ext.code {
        case AviationExtCode.cacheHit:
            return jsDomainEvent(type: eventType, uri: String(cCharArray: ext.text), bytes: double(0))
        case AviationExtCode.cacheMiss:
            return jsDomainEvent(type: eventType, uri: String(cCharArray: ext.text))
        case AviationExtCode.preloadStarted:
            return jsDomainEvent(
                type: eventType,
                uri: String(cCharArray: ext.text),
                layer: double(0)
            )
        case AviationExtCode.preloadCompleted:
            return jsDomainEvent(
                type: eventType,
                uri: String(cCharArray: ext.text),
                layer: double(0),
                durationMs: double(1)
            )
        case AviationExtCode.ttff:
            return jsDomainEvent(
                type: eventType,
                uri: String(cCharArray: ext.text),
                durationMs: double(0),
                source: jsTtffSource(String(cCharArray: ext.detail))
            )
        default:
            return nil
        }
    }

    private func buildJsDomainEvent(_ event: AviationDomainEvent) -> DomainEvent? {
        guard let eventType = jsDomainEventType(event.type) else { return nil }
        let sourceNames = ["none", "queue", "direct", "external"]

        switch AviationDomainEventType(rawValue: UInt32(event.type)) {
        case AVIATION_EVT_STATE_CHANGED:
            return jsDomainEvent(
                type: eventType,
                oldState: cStateToSwift(Int(event.oldState)),
                newState: cStateToSwift(Int(event.newState))
            )

        case AVIATION_EVT_POSITION_UPDATED:
            let pos = event.position
            let progress: Double = pos.isLive || pos.durationMs <= 0
                ? 0
                : min(max(Double(pos.currentMs) / Double(pos.durationMs), 0), 1)
            let liveEdgeOffset: Double = pos.isLive
                ? max(0, Double(pos.seekableEndMs - pos.currentMs))
                : 0
            return jsDomainEvent(
                type: eventType,
                position: MediaPosition(
                    currentMs: Double(pos.currentMs),
                    durationMs: pos.isLive ? Double.infinity : Double(pos.durationMs),
                    seekableStartMs: Double(pos.seekableStartMs),
                    seekableEndMs: Double(pos.seekableEndMs),
                    isLive: pos.isLive,
                    progress: progress,
                    liveEdgeOffsetMs: liveEdgeOffset,
                    isAtLiveEdge: pos.isLive && liveEdgeOffset < 15000
                )
            )

        case AVIATION_EVT_CURRENT_ITEM_CHANGED:
            let sourceIdx = min(max(Int(event.itemSource), 0), sourceNames.count - 1)
            return jsDomainEvent(
                type: eventType,
                queueIndex: Double(event.queueIndex),
                itemSource: ItemSource(fromString: sourceNames[sourceIdx])
            )

        case AVIATION_EVT_ERROR_OCCURRED:
            return jsDomainEvent(
                type: eventType,
                errorMessage: String(cCharArray: event.errorMessage),
                errorCode: String(cCharArray: event.errorCode)
            )

        case AVIATION_EVT_RATE_CHANGED:
            return jsDomainEvent(type: eventType, rate: event.rate)

        case AVIATION_EVT_VOLUME_CHANGED:
            return jsDomainEvent(type: eventType, volume: event.volume)

        case AVIATION_EVT_MUTED_CHANGED:
            return jsDomainEvent(type: eventType, muted: event.muted)

        case AVIATION_EVT_BITRATE_CHANGED:
            return jsDomainEvent(
                type: eventType,
                videoBitrateBps: event.videoBitrateBps > 0 ? Double(event.videoBitrateBps) : nil,
                audioBitrateBps: event.audioBitrateBps > 0 ? Double(event.audioBitrateBps) : nil
            )

        case AVIATION_EVT_INTERRUPTION:
            let policyName = switch Int(event.interruptionResumePolicy) {
            case 0: "resume"
            case 1: "pause"
            default: "ignore"
            }
            return jsDomainEvent(
                type: eventType,
                began: event.interruptionBegan,
                shouldResume: event.interruptionShouldResume,
                resumePolicy: AviationInterruptionMode(fromString: policyName)
            )

        case AVIATION_EVT_EXTENSION:
            return buildJsExtensionEvent(event.extension)

        default:
            return nil
        }
    }

    /// Resolve or reject pending promises based on state transitions from the C coordinator.
    private func handlePromisesForStateChange(from oldState: PlaybackState, to newState: PlaybackState) {
        switch (oldState, newState) {
        case (.loading, .ready):
            // Item loaded successfully
            safeResolve(loadPromise)
            loadPromise = nil

        case (_, .playing):
            // Playback started/resumed
            safeResolve(playPromise)
            playPromise = nil
            // Also resolve load promise if transitioning directly (auto-play)
            safeResolve(loadPromise)
            loadPromise = nil

        case (_, .error):
            // Error — reject pending promises with actual decoder error details
            let desc = lastErrorMessage ?? "Playback error"
            let errCode = lastErrorCode ?? "UNKNOWN"
            let error = NSError(domain: "aviation", code: -8,
                                userInfo: [
                                    NSLocalizedDescriptionKey: "\(desc) [\(errCode)]",
                                ])
            lastErrorMessage = nil
            lastErrorCode = nil
            safeReject(loadPromise, error: error)
            loadPromise = nil
            safeReject(playPromise, error: error)
            playPromise = nil
            // CRSH-04: Also reject seekPromise on error
            safeReject(seekPromise, error: error)
            seekPromise = nil

        case (_, .stopped):
            // Stopped — resolve any pending seek
            safeResolve(seekPromise)
            seekPromise = nil

        default:
            break
        }
    }
}
