//
//  OpusPlayer.swift
//  SwiftOpusPlayer
//
//  Created by Kartik Venugopal on 8/4/21.
//
import AVFoundation

class OpusPlayer: OpusPlayerProtocol {
    
    let engine: AudioEngine = AudioEngine()
    
    /// The current playback state of the player
    var state: PlayerState = .stopped
    
    ///
    /// The number of audio buffers currently scheduled for playback by the player.
    ///
    /// Used to determine:
    /// 1. when playback has completed.
    /// 2. whether or not a scheduling task was successful and whether or not playback should begin.
    ///
    var scheduledBufferCount: AtomicCounter<Int> = AtomicCounter<Int>()
    
    ///
    /// A context associated with the currently playing file.
    /// May be nil (if no file is currently playing).
    ///
    var playingFile: OpusFile!
    
    /// A helper object that does the actual decoding. This is tied to playingFile.
    var decoder: OpusDecoder!
    
    ///
    /// The audio format for the currently playing file.
    /// May be nil (if no file is currently playing).
    ///
    /// # Note #
    ///
    /// All audio buffers will be set to this format when scheduled for playback.
    ///
    var audioFormat: AVAudioFormat!
    
    ///
    /// The maximum number of samples that will be read, decoded, and scheduled for **immediate** playback,
    /// i.e. when **play(file)** is called, triggered by the user.
    ///
    /// # Notes #
    ///
    /// 1. This value should be small enough so that, when starting playback
    /// of a file, there is little to no perceived lag. Typically, this should represent about 2-5 seconds of audio (depending on sample rate).
    ///
    /// 2. This value should generally be smaller than *sampleCountForDeferredPlayback*.
    ///
    var sampleCountForImmediatePlayback: Int32 = 0
    
    ///
    /// The maximum number of samples that will be read, decoded, and scheduled for **deferred** playback, i.e. playback that will occur
    /// at a later time, as the result, of a recursive scheduling task automatically triggered when a previously scheduled audio buffer has finished playing.
    ///
    /// # Notes #
    ///
    /// 1. The greater this value, the longer each recursive scheduling task will take to complete, and the larger the memory footNSLog of each audio buffer.
    /// The smaller this value, the more often disk reads will occur. Choose a value that is a good balance between memory usage, decoding / resampling time, and frequency of disk reads.
    /// Example: 10-20 seconds of audio (depending on sample rate).
    ///
    /// 2. This value should generally be larger than *sampleCountForImmediatePlayback*.
    ///
    var sampleCountForDeferredPlayback: Int32 = 0
    
    ///
    /// A **serial** operation queue on which all *deferred* scheduling tasks are enqueued, i.e. tasks scheduling buffers that will be played back at a later time.
    ///
    /// ```
    /// The use of this queue allows monitoring and cancellation of scheduling tasks
    /// (e.g. when seeking invalidates previous scheduling tasks).
    /// ```
    /// # Notes #
    ///
    /// 1. Uses the global dispatch queue.
    ///
    /// 2. This is a *serial* queue, meaning that only one operation can execute at any given time. This is very important, because we don't want a race condition when scheduling buffers.
    ///
    /// 3. Scheduling tasks for *immediate* playback will **not** be enqueued on this queue. They will be run immediately on the main thread.
    ///
    let schedulingOpQueue: OperationQueue = {
        
        let queue = OperationQueue()
        queue.underlyingQueue = DispatchQueue.global(qos: .userInitiated)
        queue.qualityOfService = .userInitiated
        queue.maxConcurrentOperationCount = 1
        
        return queue
    }()
    
    ///
    /// Seek position (in seconds) from which playback for the currently playing file started.
    ///
    /// # Notes #
    ///
    /// 1. If no seeks are performed, this will be 0, denoting the start of the track.
    ///
    /// 2. Every time a seek is performed, this variable will be set to the new seek time.
    ///
    var playbackStartPosition: Double = 0
    
    ///
    /// Accesses the player's current seek position within the currently playing file (in seconds).
    ///
    /// ```
    /// Uses the audio engine's seek position (number of frames played) and
    /// playbackStartPosition to compute the current seek position.
    /// ```
    ///
    var seekPosition: Double {
        
        if playingFile == nil {return 0}
        
        return playbackStartPosition + (Double(engine.framePosition) / OpusFile.standardSampleRate)
    }
    
    ///
    /// Property to adjust the player's volume.
    /// Must be a floating-point value between 0 and 1.
    ///
    var volume: Float {
        
        get {engine.volume}
        set {engine.volume = min(1, max(0, newValue))}
    }
    
    init() {
        engine.volume = 1
    }
    
    ///
    /// Prepares the player to play a given audio file.
    ///
    /// ```
    /// This function will be called exactly once when a file is chosen for immediate playback.
    /// ```
    ///
    /// - Parameter file: A context through which decoding of the audio file can be performed.
    ///
    private func initialize(with file: OpusFile) {
        
        self.playingFile = file
        
        self.decoder = OpusDecoder(forFile: file)
        
        let sampleRate: Int32 = Int32(OpusFile.standardSampleRate)
        let channelCount: Int32 = file.channelCount

        // The effective sample rate, which also takes into account the channel count, gives us a better idea
        // of the computational cost of decoding and resampling the given file, as opposed to just the
        // sample rate.
        let effectiveSampleRate: Int32 = sampleRate * channelCount
        let channelLayout: AVAudioChannelLayout = file.channelLayout

        // Determine the audio format for all audio buffers that will be scheduled for playback.
        self.audioFormat = AVAudioFormat(standardFormatWithSampleRate: Double(sampleRate), channelLayout: channelLayout)

        // Inform the audio engine that the audio buffers for this file will be of this format, so that
        // it can prepare itself accordingly.
        engine.prepareForFile(with: audioFormat)

        // Given the effective sample rate, determine how many samples we should schedule for immediate and deferred playback.

        switch effectiveSampleRate {

        case 0..<100000:

            // 44.1 / 48 KHz stereo

            sampleCountForImmediatePlayback = 5 * sampleRate    // 5 seconds of audio
            sampleCountForDeferredPlayback = 10 * sampleRate    // 10 seconds of audio

        case 100000..<500000:

            // 96 / 192 KHz stereo

            sampleCountForImmediatePlayback = 3 * sampleRate    // 3 seconds of audio
            sampleCountForDeferredPlayback = 10 * sampleRate    // 10 seconds of audio

        default:

            // 96 KHz surround and higher sample rates

            sampleCountForImmediatePlayback = 2 * sampleRate    // 2 seconds of audio
            sampleCountForDeferredPlayback = 7 * sampleRate     // 7 seconds of audio
        }

        scheduledBufferCount.value = 0
        playbackStartPosition = 0
    }
    
    func play(file: URL, isStream: Bool = false) -> OpusFile? {
        
        // Reset player and decoder state before playback.
        playbackCompleted(false)
        
        guard let opusFile = OpusFile(file: file, isStream: isStream) else {
            
            NSLog("\nUnable to play file: \(file.path).")
            return nil
        }
        
        print("\nPlaying \(file.absoluteString) ...")
        
        // Prepare decoder and audio engine.
        initialize(with: opusFile)
        
        // Initiate scheduling of audio buffers on the audio engine's playback queue.
        initiateDecodingAndScheduling()
        
        // Check that at least one audio buffer was successfully scheduled, before beginning playback.
        if scheduledBufferCount.value > 0 {
            beginPlayback()
        }
        
        return playingFile
    }
    
    func seek(to time: Double) {
        
        guard let playingFile = self.playingFile else {return}
        
        guard playingFile.isSeekable else {

            NSLog("The file '\(playingFile.fileDebugString)' is not seekable.")
            return
        }
        
        let wasPlaying: Bool = engine.isPlaying
        
        haltPlayback()
        initiateDecodingAndScheduling(from: time)
        
        guard scheduledBufferCount.value > 0 else {return}
        
        if wasPlaying {
            beginPlayback(from: time)
            
        } else {
            playbackStartPosition = time
        }
    }
    
    func pauseOrResume() {
        
        if playingFile != nil {
            
            engine.pauseOrResume()
            state = engine.isPlaying ? .playing : .paused
        }
    }
    
    ///
    /// Stops playback of the currently playing file, and performs all appropriate cleanup (deallocation of memory, etc).
    ///
    /// # Notes #
    ///
    /// Does nothing if no file is currently playing.
    ///
    func stop() {
        
        if playingFile != nil {
            playbackCompleted()
        }
    }
    
    ///
    /// Stops playback of the currently playing file.
    ///
    /// # Notes #
    ///
    /// Does nothing if no file is currently playing.
    ///
    private func haltPlayback() {
        
        state = .stopped
        
        stopScheduling()
        engine.stop()
    }
    
    ///
    /// Initiates playback of the audio engine.
    ///
    /// - Parameter seekPosition:   The seek position, specified in seconds, from which playback is
    ///                             beginning.
    ///
    /// # Notes #
    ///
    /// Does nothing if no file is currently playing.
    ///
    func beginPlayback(from seekPosition: Double = 0) {

        playbackStartPosition = seekPosition
        
        engine.play()
        state = .playing
    }
    
    ///
    /// Performs cleanup and (optionally) notifies observers when playback of the currently playing file has completed.
    ///
    /// - Parameter notifyObservers: When true, an NSNotification will be published. True is the default value.
    ///
    /// # Notes #
    ///
    /// The function may also be used for cleanup without notifying observers. e.g. before starting playback of a file.
    ///
    func playbackCompleted(_ notifyObservers: Bool = true) {
        
        haltPlayback()
        
        playingFile = nil

        if notifyObservers {
            NotificationCenter.default.post(name: .player_playbackCompleted, object: self)
        }
    }
}

///
/// Enumerates all possible playback states the player can be in.
///
enum PlayerState {
    
    /// Not playing any track
    case stopped
    
    /// Playing a track
    case playing
    
    /// Paused while playing a track
    case paused
}

///
/// Names for custom notifications published by the player
///
extension Notification.Name {
    
    static let player_playbackCompleted = NSNotification.Name("player_playbackCompleted")
}
