//
//  RNTrackPlayer.swift
//  RNTrackPlayer
//

import Foundation
import AVFoundation
import MediaPlayer
import React
import UIKit

@objc public protocol RNTPDelegate {
    func sendEvent(name: String, body: Any)
}

@objc(RNTrackPlayer)
public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {

    // newarch swift event emitter
    @objc public weak var delegate: RNTPDelegate? = nil
    // MARK: - Attributes
    private var hasInitialized = false
    private let player = QueuedAudioPlayer()
    private let audioSessionController = AudioSessionController.shared
    /// Synchronous playback state for getPlaybackState() and events. Set on play()/pause() (and remote) so the next getPlaybackState() returns the new state before the wrapper's async state update. Updated from handleAudioPlayerStateChange so ended/failed/loading etc. are correct.
    private var effectivePlaybackState: AVPlayerWrapperState? = nil
    private var shouldEmitProgressEvent: Bool = false
    /// When > 0 and <= 1, the progress tick already fires every second; we use it to update Now Playing instead of a separate timer.
    private var progressUpdateEventIntervalSeconds: Double = 0
    private var shouldResumePlaybackAfterInterruptionEnds: Bool = false
    private var forwardJumpInterval: NSNumber? = nil;
    private var backwardJumpInterval: NSNumber? = nil;
    private var sessionCategory: AVAudioSession.Category = .playback
    /// Timer work item for updating Now Playing elapsed/duration every second so the lock screen widget stays in sync.
    private var nowPlayingUpdateWorkItem: DispatchWorkItem? = nil
    private let nowPlayingUpdateQueue = DispatchQueue.main
    private var sessionCategoryMode: AVAudioSession.Mode = .default
    private var sessionCategoryPolicy: AVAudioSession.RouteSharingPolicy = .longFormAudio
    private var sessionCategoryOptions: AVAudioSession.CategoryOptions = []

    // MARK: - Lifecycle Methods

    /// With `.longFormAudio`, iOS rejects most category options (allowBluetooth, allowAirPlay, duckOthers, etc.).
    private static let defaultPlaybackCategoryOptions: AVAudioSession.CategoryOptions = []

    private static func sanitizedCategoryOptions(
        _ options: AVAudioSession.CategoryOptions,
        policy: AVAudioSession.RouteSharingPolicy
    ) -> AVAudioSession.CategoryOptions {
        if policy == .longFormAudio {
            return []
        }
        return options.subtracting([.duckOthers, .mixWithOthers])
    }

    public override init() {
        super.init()
        audioSessionController.delegate = self
        player.playWhenReady = false
        player.event.receiveChapterMetadata.addListener(self, handleAudioPlayerChapterMetadataReceived)
        player.event.receiveTimedMetadata.addListener(self, handleAudioPlayerTimedMetadataReceived)
        player.event.receiveCommonMetadata.addListener(self, handleAudioPlayerCommonMetadataReceived)
        player.event.stateChange.addListener(self, handleAudioPlayerStateChange)
        player.event.fail.addListener(self, handleAudioPlayerFailed)
        player.event.currentItem.addListener(self, handleAudioPlayerCurrentItemChange)
        player.event.secondElapse.addListener(self, handleAudioPlayerSecondElapse)
        player.event.playWhenReadyChange.addListener(self, handlePlayWhenReadyChange)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleDidEnterBackground),
            name: UIApplication.didEnterBackgroundNotification,
            object: nil
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleWillEnterForeground),
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleWillResignActive),
            name: UIApplication.willResignActiveNotification,
            object: nil
        )
    }

    deinit {
        reset(resolve: { _ in }, reject: { _, _, _  in })
    }

    private func emit(event: EventType, body: Any? = nil) {
        // RCT event emitters must run on the main queue (especially New Architecture). AVPlayer callbacks are not on main.
        let send: () -> Void = { [weak self] in
            guard let self = self else { return }
            self.delegate?.sendEvent(name: event.rawValue, body: body as Any)
        }
        if Thread.isMainThread {
            send()
        } else {
            DispatchQueue.main.async(execute: send)
        }
    }

    /// AVPlayer, UIKit, and MPNowPlayingInfoCenter must run on the main queue (RN New Architecture invokes native modules off-main).
    private func onMain(_ work: @escaping () -> Void) {
        if Thread.isMainThread {
            work()
        } else {
            DispatchQueue.main.async(execute: work)
        }
    }

    // MARK: - AudioSessionControllerDelegate

    public func handleInterruption(type: InterruptionType) {
        switch type {
        case .began:
            // Interruption began, take appropriate actions (save state, update user interface)
            emit(event: EventType.RemoteDuck, body: [
                "paused": true
            ])
        case let .ended(shouldResume):
            if shouldResume {
                do {
                    try AVAudioSession.sharedInstance().setActive(true, options: [])
                } catch {}
                if shouldResumePlaybackAfterInterruptionEnds {
                    player.play()
                }
                emit(event: EventType.RemoteDuck, body: [
                    "paused": false
                ])
            } else {
                // Interruption Ended - playback should NOT resume
                emit(event: EventType.RemoteDuck, body: [
                    "paused": true,
                    "permanent": true
                ])
            }
        }
    }

    // MARK: - Bridged Methods

    private func rejectWhenNotInitialized(reject: RCTPromiseRejectBlock) -> Bool {
        let rejected = !hasInitialized;
        if (rejected) {
            reject("player_not_initialized", "The player is not initialized. Call setupPlayer first.", nil)
        }
        return rejected;
    }

    private func rejectWhenTrackIndexOutOfBounds(
        index: Int,
        min: Int? = nil,
        max : Int? = nil,
        message : String? = "The track index is out of bounds",
        reject: RCTPromiseRejectBlock
    ) -> Bool {
        let rejected = index < (min ?? 0) || index > (max ?? player.items.count - 1);
        if (rejected) {
            reject("index_out_of_bounds", message, nil)
        }
        return rejected
    }

    @objc(setupPlayer:resolver:rejecter:)
    public func setupPlayer(config: [String: Any], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
        onMain { [weak self] in
            guard let self = self else {
                resolve(NSNull())
                return
            }
            self.setupPlayerOnMain(config: config, resolve: resolve, reject: reject)
        }
    }

    private func setupPlayerOnMain(config: [String: Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if hasInitialized {
            reject("player_already_initialized", "The player has already been initialized via setupPlayer.", nil)
            return
        }

        // configure the FFT audio tap
        if let fftLength = config["useFFTProcessor"] as? Int {
            player.audioTap = WaveformAudioTap(mFFTLength: fftLength, mEmit: {data in
                self.emit(event:EventType.FFTUpdated, body:data)})
        }

        // configure buffer size
        if let bufferDuration = config["minBuffer"] as? TimeInterval {
            player.bufferDuration = bufferDuration
        }

        self.shouldResumePlaybackAfterInterruptionEnds = config["autoHandleInterruptions"] as? Bool ?? true

        // configure wether player waits to play (deprecated)
        if let waitForBuffer = config["waitForBuffer"] as? Bool {
            player.automaticallyWaitsToMinimizeStalling = waitForBuffer
        }

        // configure wether control center metdata should auto update
        player.automaticallyUpdateNowPlayingInfo = config["autoUpdateMetadata"] as? Bool ?? true

        // configure audio session - category, options & mode
        if
            let sessionCategoryStr = config["iosCategory"] as? String,
            let mappedCategory = SessionCategory(rawValue: sessionCategoryStr) {
            sessionCategory = mappedCategory.mapConfigToAVAudioSessionCategory()
        }

        if
            let sessionCategoryModeStr = config["iosCategoryMode"] as? String,
            let mappedCategoryMode = SessionCategoryMode(rawValue: sessionCategoryModeStr) {
            sessionCategoryMode = mappedCategoryMode.mapConfigToAVAudioSessionCategoryMode()
        }

        if
            let sessionCategoryPolicyStr = config["iosCategoryPolicy"] as? String,
            let mappedCategoryPolicy = SessionCategoryPolicy(rawValue: sessionCategoryPolicyStr) {
            sessionCategoryPolicy = mappedCategoryPolicy.mapConfigToAVAudioSessionCategoryPolicy()
        }

        let sessionCategoryOptsStr = config["iosCategoryOptions"] as? [String]
        if let opts = sessionCategoryOptsStr?.compactMap({ SessionCategoryOptions(rawValue: $0)?.mapConfigToAVAudioSessionCategoryOptions() }), !opts.isEmpty {
            sessionCategoryOptions = AVAudioSession.CategoryOptions(opts)
        } else if sessionCategory == .playback {
            sessionCategoryOptions = Self.defaultPlaybackCategoryOptions
        }

        if config["iosCategoryPolicy"] == nil && sessionCategory == .playback {
            sessionCategoryPolicy = .longFormAudio
        }

        configureAudioSessionForBackgroundPlayback()
        configureAudioSession()

        // So the lock screen / Control Center widget can show as soon as nowPlayingInfo is set (first track add).
        if Thread.isMainThread {
            UIApplication.shared.beginReceivingRemoteControlEvents()
        } else {
            DispatchQueue.main.async {
                UIApplication.shared.beginReceivingRemoteControlEvents()
            }
        }

        // Remote command handlers: perform native action first (so lock screen/Control Center work when JS is suspended), then emit for UI sync.
        player.remoteCommandController.handleChangePlaybackPositionCommand = { [weak self] event in
            guard let self = self, let event = event as? MPChangePlaybackPositionCommandEvent else {
                return MPRemoteCommandHandlerStatus.commandFailed
            }
            self.player.seek(to: event.positionTime)
            self.emit(event: EventType.RemoteSeek, body: ["position": event.positionTime])
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handleNextTrackCommand = { [weak self] _ in
            guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
            self.player.next()
            self.emit(event: EventType.RemoteNext)
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handlePauseCommand = { [weak self] _ in
            guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
            self.player.pause()
            self.refreshNowPlayingPlaybackValuesOnly()
            let state = self.resolvePlaybackState(from: .paused)
            self.effectivePlaybackState = state
            self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: state))
            self.emit(event: EventType.RemotePause)
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handlePlayCommand = { [weak self] _ in
            guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
            self.player.play()
            self.configureAudioSession()
            self.refreshNowPlayingPlaybackValuesOnly()
            let state = self.resolvePlaybackState()
            self.effectivePlaybackState = state
            self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: state))
            self.emit(event: EventType.RemotePlay)
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handlePreviousTrackCommand = { [weak self] _ in
            guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
            self.player.previous()
            self.emit(event: EventType.RemotePrevious)
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handleSkipBackwardCommand = { [weak self] event in
            guard let self = self,
                  let command = event.command as? MPSkipIntervalCommand,
                  let interval = command.preferredIntervals.first else {
                return MPRemoteCommandHandlerStatus.commandFailed
            }
            let secs = Double(truncating: interval)
            self.player.seek(to: self.player.currentTime - secs)
            self.emit(event: EventType.RemoteJumpBackward, body: ["interval": interval])
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handleSkipForwardCommand = { [weak self] event in
            guard let self = self,
                  let command = event.command as? MPSkipIntervalCommand,
                  let interval = command.preferredIntervals.first else {
                return MPRemoteCommandHandlerStatus.commandFailed
            }
            let secs = Double(truncating: interval)
            self.player.seek(to: self.player.currentTime + secs)
            self.emit(event: EventType.RemoteJumpForward, body: ["interval": interval])
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handleStopCommand = { [weak self] _ in
            guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
            self.player.stop()
            self.refreshNowPlayingInfoCenter()
            let state = self.resolvePlaybackState(from: .stopped)
            self.effectivePlaybackState = state
            self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: state))
            self.emit(event: EventType.RemoteStop)
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handleTogglePlayPauseCommand = { [weak self] _ in
            guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
            let currentState = self.resolvePlaybackState()
            if currentState == .paused || currentState == .stopped || currentState == .ended {
                self.player.play()
                self.configureAudioSession()
            } else {
                self.player.pause()
            }
            self.refreshNowPlayingInfoCenter()
            let state = self.resolvePlaybackState()
            self.effectivePlaybackState = state
            self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: state))
            if state == .playing || state == .buffering {
                self.emit(event: EventType.RemotePlay)
            } else {
                self.emit(event: EventType.RemotePause)
            }
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handleLikeCommand = { [weak self] _ in
            self?.emit(event: EventType.RemoteLike)
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handleDislikeCommand = { [weak self] _ in
            self?.emit(event: EventType.RemoteDislike)
            return MPRemoteCommandHandlerStatus.success
        }

        player.remoteCommandController.handleBookmarkCommand = { [weak self] _ in
            self?.emit(event: EventType.RemoteBookmark)
            return MPRemoteCommandHandlerStatus.success
        }

        hasInitialized = true
        resolve(NSNull())
    }


    private func configureAudioSessionForBackgroundPlayback() {
        let session = AVAudioSession.sharedInstance()
        let options = Self.sanitizedCategoryOptions(sessionCategoryOptions, policy: sessionCategoryPolicy)
        do {
            if #available(iOS 11.0, *) {
                try session.setCategory(sessionCategory, mode: sessionCategoryMode, policy: sessionCategoryPolicy, options: options)
            } else {
                try session.setCategory(sessionCategory, mode: sessionCategoryMode, options: options)
            }
            try session.setActive(true, options: [])
        } catch {
            #if DEBUG
            print("[react-native-mp3-player] AVAudioSession error: \(error.localizedDescription)")
            #endif
        }
    }

    private func configureAudioSession() {
        // Hot path: track loaded — activate session without touching UIApplication (must be main thread).
        if player.currentItem != nil {
            configureAudioSessionForBackgroundPlayback()
            return
        }

        let run: () -> Void = { [weak self] in
            self?.configureAudioSessionWhenNoCurrentItem()
        }
        if Thread.isMainThread {
            run()
        } else {
            DispatchQueue.main.async(execute: run)
        }
    }

    /// Session policy when nothing is loaded yet — uses UIApplication and must run on main.
    private func configureAudioSessionWhenNoCurrentItem() {
        let hasQueue = !player.items.isEmpty
        let state = effectivePlaybackState ?? player.playerState
        let playbackActive = state == .playing || state == .paused || state == .ready || state == .buffering || state == .loading
        let isBackground = UIApplication.shared.applicationState != .active
        if UIApplication.shared.applicationState == .active && !hasQueue && !playbackActive {
            try? audioSessionController.deactivateSession()
        } else if isBackground || hasQueue || playbackActive {
            configureAudioSessionForBackgroundPlayback()
        }
    }

    /// Resolve the authoritative playback state. When the user has paused (playWhenReady == false),
    /// never report .playing even if AVPlayer hasn't caught up yet — fixes lock screen / in-app icon drift.
    private func resolvePlaybackState(from wrapperState: AVPlayerWrapperState? = nil) -> AVPlayerWrapperState {
        let s = wrapperState ?? player.playerState
        if !player.playWhenReady {
            switch s {
            case .ended: return .ended
            case .stopped: return .stopped
            case .failed: return .failed
            default: return .paused
            }
        }
        switch s {
        case .playing: return .playing
        case .paused, .ready: return .buffering
        case .loading, .buffering: return .buffering
        default: return s
        }
    }

    /// Re-push metadata + playback values to MPNowPlayingInfoCenter (main queue only — safe from RN bridge thread).
    private func refreshNowPlayingInfoCenter() {
        guard player.currentItem != nil, player.automaticallyUpdateNowPlayingInfo else { return }
        let update: () -> Void = { [weak self] in
            guard let self = self, self.player.currentItem != nil else { return }
            UIApplication.shared.beginReceivingRemoteControlEvents()
            self.player.loadNowPlayingMetaValues()
            self.player.updateNowPlayingPlaybackValuesSync()
        }
        if Thread.isMainThread {
            update()
        } else {
            DispatchQueue.main.async(execute: update)
        }
    }

    /// Lightweight playback-rate/elapsed refresh without reloading metadata (safe during state transitions).
    private func refreshNowPlayingPlaybackValuesOnly() {
        guard player.currentItem != nil, player.automaticallyUpdateNowPlayingInfo else { return }
        let update: () -> Void = { [weak self] in
            guard let self = self, self.player.currentItem != nil else { return }
            self.player.updateNowPlayingPlaybackValuesSync()
        }
        if Thread.isMainThread {
            update()
        } else {
            DispatchQueue.main.async(execute: update)
        }
    }

    @objc private func handleDidEnterBackground() {
        guard player.currentItem != nil else { return }
        configureAudioSessionForBackgroundPlayback()
        refreshNowPlayingInfoCenter()
    }

    @objc private func handleWillEnterForeground() {
        guard player.currentItem != nil else { return }
        configureAudioSessionForBackgroundPlayback()
        refreshNowPlayingInfoCenter()
    }

    /// Device is locking or app is resigning active — refresh lock screen Now Playing widget immediately.
    @objc private func handleWillResignActive() {
        guard player.currentItem != nil else { return }
        configureAudioSessionForBackgroundPlayback()
        refreshNowPlayingInfoCenter()
    }

    @objc(isServiceRunning:rejecter:)
    public func isServiceRunning(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        // TODO That is probably always true
        resolve(player != nil)
    }

    @objc(updateOptions:resolver:rejecter:)
    public func update(options: [String: Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        var capabilitiesStr = options["capabilities"] as? [String] ?? []
        if (capabilitiesStr.contains("play") && capabilitiesStr.contains("pause")) {
            capabilitiesStr.append("togglePlayPause");
        }

        forwardJumpInterval = options["forwardJumpInterval"] as? NSNumber ?? forwardJumpInterval
        backwardJumpInterval = options["backwardJumpInterval"] as? NSNumber ?? backwardJumpInterval

        // When both jump intervals are set, use only skip-forward/skip-backward for the main transport
        // so the lock screen shows 15s rewind (left) and 15s forward (right), not previous/next track.
        let useJumpButtonsForTransport = (forwardJumpInterval.map { $0.intValue > 0 } ?? false)
            && (backwardJumpInterval.map { $0.intValue > 0 } ?? false)
        if useJumpButtonsForTransport {
            capabilitiesStr = capabilitiesStr.filter { $0 != "previous" && $0 != "next" }
        }

        player.remoteCommands = capabilitiesStr
            .compactMap { Capability(rawValue: $0) }
            .map { capability in
                capability.mapToPlayerCommand(
                    forwardJumpInterval: forwardJumpInterval,
                    backwardJumpInterval: backwardJumpInterval,
                    likeOptions: options["likeOptions"] as? [String: Any],
                    dislikeOptions: options["dislikeOptions"] as? [String: Any],
                    bookmarkOptions: options["bookmarkOptions"] as? [String: Any]
                )
            }

        let interval = ((options["progressUpdateEventInterval"] as? NSNumber) ?? 0).doubleValue
        progressUpdateEventIntervalSeconds = interval
        configureProgressUpdateEvent(interval: interval)

        resolve(NSNull())
    }

    private func configureProgressUpdateEvent(interval: Double) {
        shouldEmitProgressEvent = interval > 0
        self.player.timeEventFrequency = shouldEmitProgressEvent
            ? .custom(time: CMTime(seconds: interval, preferredTimescale: 1000))
            : .everySecond
    }

    @objc(add:before:resolver:rejecter:)
    public func add(
        trackDicts: [[String: Any]],
        before trackIndex: Int,
        resolve: @escaping RCTPromiseResolveBlock,
        reject: @escaping RCTPromiseRejectBlock
    ) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        var tracks = [Track]()
        for trackDict in trackDicts {
            guard let track = Track(dictionary: trackDict) else {
                reject("invalid_track_object", "Track is missing a required key", nil)
                return
            }
            tracks.append(track)
        }

        onMain { [weak self] in
            guard let self = self else {
                resolve(NSNull())
                return
            }
            let index = trackIndex == -1 ? self.player.items.count : trackIndex
            if self.rejectWhenTrackIndexOutOfBounds(
                index: index,
                max: self.player.items.count,
                reject: reject
            ) { return }

            try? self.player.add(items: tracks, at: index)
            self.configureAudioSession()
            self.refreshNowPlayingInfoCenter()
            resolve(index)
        }
    }

    @objc(load:resolver:rejecter:)
    public func load(
        trackDict: [String: Any],
        resolve: @escaping RCTPromiseResolveBlock,
        reject: @escaping RCTPromiseRejectBlock
    ) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        guard let track = Track(dictionary: trackDict) else {
            reject("invalid_track_object", "Track is missing a required key", nil)
            return
        }

        onMain { [weak self] in
            guard let self = self else {
                resolve(NSNull())
                return
            }
            self.player.load(item: track)
            self.configureAudioSession()
            self.refreshNowPlayingInfoCenter()
            resolve(self.player.currentIndex)
        }
    }

    @objc(remove:resolver:rejecter:)
    public func remove(tracks indexes: [Int], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        for index in indexes {
            if (rejectWhenTrackIndexOutOfBounds(index: index, message: "One or more of the indexes were out of bounds.", reject: reject)) {
                return
            }
        }

        // Sort the indexes in descending order so we can safely remove them one by one
        // without having the next index possibly newly pointing to another item than intended:
        for index in indexes.sorted().reversed() {
            try? player.removeItem(at: index)
        }

        resolve(NSNull())
    }

    @objc(move:toIndex:resolver:rejecter:)
    public func move(
        fromIndex: Int,
        toIndex: Int,
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        if (rejectWhenTrackIndexOutOfBounds(
            index: fromIndex,
            message: "The fromIndex is out of bounds",
            reject: reject)
        ) { return }
        if (rejectWhenTrackIndexOutOfBounds(
            index: toIndex,
            max: Int.max,
            message: "The toIndex is out of bounds",
            reject: reject)
        ) { return }
        try? player.moveItem(fromIndex: fromIndex, toIndex: toIndex)
        resolve(NSNull())
    }


    @objc(removeUpcomingTracks:rejecter:)
    public func removeUpcomingTracks(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.removeUpcomingItems()
        resolve(NSNull())
    }

    @objc(skip:initialTime:resolver:rejecter:)
    public func skip(
        to trackIndex: Int,
        initialTime: Double,
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        let index = trackIndex;
        if (rejectWhenTrackIndexOutOfBounds(index: index, reject: reject)) { return }

        if (rejectWhenNotInitialized(reject: reject)) { return }

        print("Skipping to track:", index)
        try? player.jumpToItem(atIndex: index, playWhenReady: player.playerState == .playing)

        // if an initialTime is passed the seek to it
        if (initialTime >= 0) {
            self.seekTo(time: initialTime, resolve: resolve, reject: reject)
        } else {
            resolve(NSNull())
        }
    }

    @objc(skipToNext:resolver:rejecter:)
    public func skipToNext(
        initialTime: Double,
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.next()

        // if an initialTime is passed the seek to it
        if (initialTime >= 0) {
            self.seekTo(time: initialTime, resolve: resolve, reject: reject)
        } else {
            resolve(NSNull())
        }
    }

    @objc(skipToPrevious:resolver:rejecter:)
    public func skipToPrevious(
        initialTime: Double,
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.previous()

        // if an initialTime is passed the seek to it
        if (initialTime >= 0) {
            self.seekTo(time: initialTime, resolve: resolve, reject: reject)
        } else {
            resolve(NSNull())
        }
    }

    @objc(reset:rejecter:)
    public func reset(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        onMain { [weak self] in
            guard let self = self else {
                resolve(NSNull())
                return
            }
            self.effectivePlaybackState = nil
            self.stopNowPlayingUpdateTimer()
            self.player.stop()
            self.player.clear()
            resolve(NSNull())
        }
    }

    @objc(play:rejecter:)
    public func play(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        onMain { [weak self] in
            guard let self = self else {
                resolve(NSNull())
                return
            }
            self.player.play()
            self.configureAudioSession()
            self.refreshNowPlayingInfoCenter()
            let state = self.resolvePlaybackState()
            self.effectivePlaybackState = state
            resolve(NSNull())
            self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: state))
        }
    }

    @objc(pause:rejecter:)
    public func pause(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        onMain { [weak self] in
            guard let self = self else {
                resolve(NSNull())
                return
            }
            self.player.pause()
            self.refreshNowPlayingPlaybackValuesOnly()
            let state = self.resolvePlaybackState(from: .paused)
            self.effectivePlaybackState = state
            resolve(NSNull())
            self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: state))
        }
    }

    @objc(setPlayWhenReady:resolver:rejecter:)
    public func setPlayWhenReady(playWhenReady: Bool, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        player.playWhenReady = playWhenReady
        resolve(NSNull())
    }

    @objc(getPlayWhenReady:rejecter:)
    public func getPlayWhenReady(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        resolve(player.playWhenReady)
    }

    @objc(stop:rejecter:)
    public func stop(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.stop()
        resolve(NSNull())
    }

    @objc(seekTo:resolver:rejecter:)
    public func seekTo(time: Double, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.seek(to: time)
        resolve(NSNull())
    }

    @objc(seekBy:resolver:rejecter:)
    public func seekBy(offset: Double, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.seek(by: offset)
        resolve(NSNull())
    }

    @objc(retry:rejecter:)
    public func retry(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        player.reload(startFromCurrentTime: true)
        resolve(NSNull())
    }

    @objc(setRepeatMode:resolver:rejecter:)
    public func setRepeatMode(repeatMode: Int, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.repeatMode = RepeatMode(rawValue: repeatMode) ?? .off
        resolve(NSNull())
    }

    @objc(getRepeatMode:rejecter:)
    public func getRepeatMode(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        resolve(player.repeatMode.rawValue)
    }

    @objc(setVolume:resolver:rejecter:)
    public func setVolume(level: Float, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.volume = level
        resolve(NSNull())
    }

    @objc(getVolume:rejecter:)
    public func getVolume(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        resolve(player.volume)
    }

    @objc(setRate:resolver:rejecter:)
    public func setRate(rate: Float, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.rate = rate
        resolve(NSNull())
    }

    @objc(getRate:rejecter:)
    public func getRate(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        resolve(player.rate)
    }

    @objc(getTrack:resolver:rejecter:)
    public func getTrack(index: Int, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        if (index >= 0 && index < player.items.count) {
            let track = player.items[index]
            resolve((track as? Track)?.toObject())
        } else {
            resolve(NSNull())
        }
    }

    @objc(getQueue:rejecter:)
    public func getQueue(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        let serializedQueue = player.items.map { ($0 as! Track).toObject() }
        resolve(serializedQueue)
    }

    @objc(setQueue:resolver:rejecter:)
    public func setQueue(
        trackDicts: [[String: Any]],
        resolve: RCTPromiseResolveBlock,
        reject: RCTPromiseRejectBlock
    ) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        var tracks = [Track]()
        for trackDict in trackDicts {
            guard let track = Track(dictionary: trackDict) else {
                reject("invalid_track_object", "Track is missing a required key", nil)
                return
            }

            tracks.append(track)
        }
        player.clear()
        try? player.add(items: tracks)
        resolve(NSNull())
    }

    @objc(getActiveTrack:rejecter:)
    public func getActiveTrack(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        let index = player.currentIndex
        if (index >= 0 && index < player.items.count) {
            let track = player.items[index]
            resolve((track as? Track)?.toObject())
        } else {
            resolve(NSNull())
        }
    }

    @objc(getActiveTrackIndex:rejecter:)
    public func getActiveTrackIndex(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        let index = player.currentIndex
        if index < 0 || index >= player.items.count {
            resolve(NSNull())
        } else {
            resolve(index)
        }
    }

    @objc(getDuration:rejecter:)
    public func getDuration(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        resolve(player.duration)
    }

    @objc(getBufferedPosition:rejecter:)
    public func getBufferedPosition(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        resolve(player.bufferedPosition)
    }

    @objc(getPosition:rejecter:)
    public func getPosition(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        resolve(player.currentTime)
    }

    @objc(getProgress:rejecter:)
    public func getProgress(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        resolve([
            "position": player.currentTime,
            "duration": player.duration,
            "buffered": player.bufferedPosition
        ])
    }

    @objc(getPlaybackState:rejecter:)
    public func getPlaybackState(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }
        let state = resolvePlaybackState(from: effectivePlaybackState ?? player.playerState)
        effectivePlaybackState = state
        resolve(getPlaybackStateBodyKeyValues(state: state))
    }

    @objc(updateMetadataForTrack:metadata:resolver:rejecter:)
    public func updateMetadata(for trackIndex: Int, metadata: [String: Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        let index = trackIndex;
        if (rejectWhenNotInitialized(reject: reject)) { return }
        if (rejectWhenTrackIndexOutOfBounds(index: index, reject: reject)) { return }

        let track : Track = player.items[index] as! Track;
        track.updateMetadata(dictionary: metadata)

        if (player.currentIndex == index) {
            Metadata.update(for: player, with: metadata)
        }

        resolve(NSNull())
    }

    @objc(clearNowPlayingMetadata:rejecter:)
    public func clearNowPlayingMetadata(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        player.nowPlayingInfoController.clear()
        resolve(NSNull())
    }

    @objc(updateNowPlayingMetadata:resolver:rejecter:)
    public func updateNowPlayingMetadata(metadata: [String: Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
        if (rejectWhenNotInitialized(reject: reject)) { return }

        Metadata.update(for: player, with: metadata)
        resolve(NSNull())
    }

    private func getPlaybackStateErrorKeyValues() -> Dictionary<String, Any> {
        switch player.playbackError {
            case .failedToLoadKeyValue: return [
                "message": "Failed to load resource",
                "code": "ios_failed_to_load_resource"
            ]
            case .invalidSourceUrl: return [
                "message": "The source url was invalid",
                "code": "ios_invalid_source_url"
            ]
            case .notConnectedToInternet: return [
                "message": "A network resource was requested, but an internet connection has not been established and can’t be established automatically.",
                "code": "ios_not_connected_to_internet"
            ]
            case .playbackFailed: return [
                "message": "Playback of the track failed",
                "code": "ios_playback_failed"
            ]
            case .itemWasUnplayable: return [
                "message": "The track could not be played",
                "code": "ios_track_unplayable"
            ]
            default: return [
                "message": "A playback error occurred",
                "code": "ios_playback_error"
            ]
        }
    }

    private func getPlaybackStateBodyKeyValues(state: AudioPlayerState) -> Dictionary<String, Any> {
        var body: Dictionary<String, Any> = ["state": State.fromPlayerState(state: state).rawValue]
        if (state == AudioPlayerState.failed) {
            body["error"] = getPlaybackStateErrorKeyValues()
        }
        return body
    }

    // MARK: - QueuedAudioPlayer Event Handlers

    func handleAudioPlayerStateChange(state: AVPlayerWrapperState) {
        let resolved = resolvePlaybackState(from: state)
        effectivePlaybackState = resolved
        emit(event: EventType.PlaybackState, body: getPlaybackStateBodyKeyValues(state: resolved))
        if player.currentItem != nil && player.automaticallyUpdateNowPlayingInfo {
            if state == .ready {
                refreshNowPlayingInfoCenter()
            } else {
                refreshNowPlayingPlaybackValuesOnly()
            }
        }
        if (state == .ended) {
            emit(event: EventType.PlaybackQueueEnded, body: [
                "track": player.currentIndex,
                "position": player.currentTime,
            ] as [String : Any])
        }
        // Keep Now Playing widget elapsed/duration in sync. When progress events fire every second (interval > 0 and <= 1), use that tick instead of a separate timer.
        switch state {
        case .ready, .playing, .paused:
            if player.currentItem != nil && player.automaticallyUpdateNowPlayingInfo {
                let useProgressTickForNowPlaying = shouldEmitProgressEvent && progressUpdateEventIntervalSeconds > 0 && progressUpdateEventIntervalSeconds <= 1.0
                if !useProgressTickForNowPlaying {
                    scheduleNextNowPlayingUpdate()
                }
            } else {
                stopNowPlayingUpdateTimer()
            }
        default:
            stopNowPlayingUpdateTimer()
        }
    }

    private func scheduleNextNowPlayingUpdate() {
        stopNowPlayingUpdateTimer()
        let workItem = DispatchWorkItem { [weak self] in
            guard let self = self, self.player.currentItem != nil, self.player.automaticallyUpdateNowPlayingInfo else { return }
            self.player.updateNowPlayingPlaybackValuesFromCurrentState()
            self.scheduleNextNowPlayingUpdate()
        }
        nowPlayingUpdateWorkItem = workItem
        nowPlayingUpdateQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem)
    }

    private func stopNowPlayingUpdateTimer() {
        nowPlayingUpdateWorkItem?.cancel()
        nowPlayingUpdateWorkItem = nil
    }

    /// Updates MPNowPlayingInfoCenter (rate, elapsed, duration) synchronously so the widget reflects play/pause before we return to JS. Call after play()/pause() from JS or remote.
    private func updateNowPlayingPlaybackValuesOnMainIfNeeded() {
        guard player.currentItem != nil, player.automaticallyUpdateNowPlayingInfo else { return }
        player.updateNowPlayingPlaybackValuesSync()
    }
    
    func handleAudioPlayerCommonMetadataReceived(metadata: [AVMetadataItem]) {
        let commonMetadata = MetadataAdapter.convertToCommonMetadata(metadata: metadata, skipRaw: true)
        emit(event: EventType.MetadataCommonReceived, body: ["metadata": commonMetadata])
    }
    
    func handleAudioPlayerChapterMetadataReceived(metadata: [AVTimedMetadataGroup]) {
        let metadataItems = MetadataAdapter.convertToGroupedMetadata(metadataGroups: metadata);
        emit(event: EventType.MetadataChapterReceived, body:  ["metadata": metadataItems])
    }

    func handleAudioPlayerTimedMetadataReceived(metadata: [AVTimedMetadataGroup]) {
        let metadataItems = MetadataAdapter.convertToGroupedMetadata(metadataGroups: metadata);
        emit(event: EventType.MetadataTimedReceived, body: ["metadata": metadataItems])
        
        // SwiftAudioEx was updated to return the array of timed metadata
        // Until we have support for that in RNTP, we take the first item to keep existing behaviour.
        let metadata = metadata.first?.items ?? []
        let metadataItem = MetadataAdapter.legacyConversion(metadata: metadata)
        emit(event: EventType.PlaybackMetadataReceived, body: metadataItem)
    }

    func handleAudioPlayerFailed(error: Error?) {
        emit(event: EventType.PlaybackError, body: ["error": error?.localizedDescription])
    }

    func handleAudioPlayerCurrentItemChange(
        item: AudioItem?,
        index: Int?,
        lastItem: AudioItem?,
        lastIndex: Int?,
        lastPosition: Double?
    ) {

        if let item = item {
            DispatchQueue.main.async {
                UIApplication.shared.beginReceivingRemoteControlEvents();
            }
            // Update now playing controller with isLiveStream option from track
            if self.player.automaticallyUpdateNowPlayingInfo {
                let isTrackLiveStream = (item as? Track)?.isLiveStream ?? false
                self.player.nowPlayingInfoController.set(keyValue: NowPlayingInfoProperty.isLiveStream(isTrackLiveStream))
            }
        } else {
            DispatchQueue.main.async {
                UIApplication.shared.endReceivingRemoteControlEvents();
            }
        }

        if ((item != nil && lastItem == nil) || item == nil) {
            configureAudioSession();
        }

        var a: Dictionary<String, Any> = ["lastPosition": lastPosition ?? 0]
        if let lastIndex = lastIndex {
            a["lastIndex"] = lastIndex
        }

        if let lastTrack = (lastItem as? Track)?.toObject() {
            a["lastTrack"] = lastTrack
        }

        if let index = index {
            a["index"] = index
        }

        if let track = (item as? Track)?.toObject() {
            a["track"] = track
        }
        emit(event: EventType.PlaybackActiveTrackChanged, body: a)

        // deprecated:
        var b: Dictionary<String, Any> = ["position": lastPosition ?? 0]
        if let lastIndex = lastIndex {
            b["lastIndex"] = lastIndex
        }
        if let index = index {
            b["nextTrack"] = index
        }
        emit(event: EventType.PlaybackTrackChanged, body: b)
    }

    func handleAudioPlayerSecondElapse(seconds: Double) {
        // because you cannot prevent the `event.secondElapse` from firing
        // do not emit an event if `progressUpdateEventInterval` is nil
        // additionally, there are certain instances in which this event is emitted
        // _after_ a manipulation to the queu causing no currentItem to exist (see reset)
        // in which case we shouldn't emit anything or we'll get an exception.
        if !shouldEmitProgressEvent || player.currentItem == nil { return }
        if player.automaticallyUpdateNowPlayingInfo {
            player.updateNowPlayingPlaybackValuesFromCurrentState()
        }
        emit(
            event: EventType.PlaybackProgressUpdated,
            body: [
                "position": player.currentTime,
                "duration": player.duration,
                "buffered": player.bufferedPosition,
                "track": player.currentIndex,
            ]
        )
    }

    func handlePlayWhenReadyChange(playWhenReady: Bool) {
        configureAudioSession()
        if player.currentItem != nil {
            let state = resolvePlaybackState()
            effectivePlaybackState = state
            refreshNowPlayingPlaybackValuesOnly()
        }
        emit(
            event: EventType.PlaybackPlayWhenReadyChanged,
            body: [
                "playWhenReady": playWhenReady
            ]
        )
    }
}

extension RNTrackPlayer {
    @objc
    public static var constantsToExport: [AnyHashable: Any] {
            return [
                "STATE_NONE": State.none.rawValue,
                "STATE_READY": State.ready.rawValue,
                "STATE_PLAYING": State.playing.rawValue,
                "STATE_PAUSED": State.paused.rawValue,
                "STATE_STOPPED": State.stopped.rawValue,
                "STATE_BUFFERING": State.buffering.rawValue,
                "STATE_LOADING": State.loading.rawValue,
                "STATE_ERROR": State.error.rawValue,

                "TRACK_PLAYBACK_ENDED_REASON_END": PlaybackEndedReason.playedUntilEnd.rawValue,
                "TRACK_PLAYBACK_ENDED_REASON_JUMPED": PlaybackEndedReason.jumpedToIndex.rawValue,
                "TRACK_PLAYBACK_ENDED_REASON_NEXT": PlaybackEndedReason.skippedToNext.rawValue,
                "TRACK_PLAYBACK_ENDED_REASON_PREVIOUS": PlaybackEndedReason.skippedToPrevious.rawValue,
                "TRACK_PLAYBACK_ENDED_REASON_STOPPED": PlaybackEndedReason.playerStopped.rawValue,

                "PITCH_ALGORITHM_LINEAR": PitchAlgorithm.linear.rawValue,
                "PITCH_ALGORITHM_MUSIC": PitchAlgorithm.music.rawValue,
                "PITCH_ALGORITHM_VOICE": PitchAlgorithm.voice.rawValue,

                "CAPABILITY_PLAY": Capability.play.rawValue,
                "CAPABILITY_PLAY_FROM_ID": "NOOP",
                "CAPABILITY_PLAY_FROM_SEARCH": "NOOP",
                "CAPABILITY_PAUSE": Capability.pause.rawValue,
                "CAPABILITY_STOP": Capability.stop.rawValue,
                "CAPABILITY_SEEK_TO": Capability.seek.rawValue,
                "CAPABILITY_SKIP": "NOOP",
                "CAPABILITY_SKIP_TO_NEXT": Capability.next.rawValue,
                "CAPABILITY_SKIP_TO_PREVIOUS": Capability.previous.rawValue,
                "CAPABILITY_SET_RATING": "NOOP",
                "CAPABILITY_JUMP_FORWARD": Capability.jumpForward.rawValue,
                "CAPABILITY_JUMP_BACKWARD": Capability.jumpBackward.rawValue,
                "CAPABILITY_LIKE": Capability.like.rawValue,
                "CAPABILITY_DISLIKE": Capability.dislike.rawValue,
                "CAPABILITY_BOOKMARK": Capability.bookmark.rawValue,

                "REPEAT_OFF": RepeatMode.off.rawValue,
                "REPEAT_TRACK": RepeatMode.track.rawValue,
                "REPEAT_QUEUE": RepeatMode.queue.rawValue,
            ]
        }

    @objc
    public static var supportedEvents: [String] {
        return EventType.allRawValues()
    }
}
