//	NEBSoundPlayer.swift
//
//	© 2026 by Jeff Weeks
//	See TermsOfUse.txt

import AVFAudio



actor NEBSoundPlayer {

	static let shared = NEBSoundPlayer()

	enum NEBSound: CaseIterable {

		case ballOnTable
		case cueOnBall
		case ballOnBall
		case ballOnCushion
		case ballSunk
		case gameWon
		case gameLost
		case miscue
		
		var fileName: String {
			switch self {
			case .ballOnTable:		return "BallOnTable.wav"
			case .cueOnBall:		return "CueOnBall.wav"
			case .ballOnBall:		return "BallOnBall.wav"
			case .ballOnCushion:	return "BallOnCushion.m4a"
			case .ballSunk:			return "BallSunk.wav"
			case .gameWon:			return "GameWon.m4a"
			case .gameLost:			return "GameLost.m4a"
			case .miscue:			return "Miscue.m4a"
			}
		}
		
		var maxSimultaneousInstances: Int {
		
			//	We'll want to allow for 4 simultaneously playing
			//	instances of the ballOnBall sound, but only
			//	1 or 2 instances for each of the other sounds.
			
			switch self {
			case .ballOnTable:		return 1
			case .cueOnBall:		return 1
			case .ballOnBall:		return 4
			case .ballOnCushion:	return 2
			case .ballSunk:			return 2
			case .gameWon:			return 1
			case .gameLost:			return 1
			case .miscue:			return 1
			}
		}
	}

	//	For each NEBSound we'll keep a fixed collection of AVAudioPlayers.
	//	Each call to play() will take the first AVAudioPlayer
	//	from the array assigned to that NEBSound, start it playing,
	//	and move that AVAudioPlayer to the end of its array.
	//	That way when lots of play() calls arrive in a short time span
	//	(as happens during the "break"), the AVAudioPlayers that got
	//	called first will get re-purposed to handle later calls.
	//	Having just a few instances of a sound playing is enough,
	//	and we don't want to overwork the sound system trying
	//	to play any more than that.
	private var audioPlayers: [NEBSound : [AVAudioPlayer]]
	
	func hello() {

		//	Loading and preparing the AVAudioPlayers is a slow process
		//	(several seconds when running in the visionOS simulator)
		//	and visionOS won't create NEBSoundPlayer.shared until
		//	we call one of its functions. So the app should call
		//	this hello() function as soon as possible, well before
		//	the player takes his/her first shot.
	}

	private init() {

		NEBSoundPlayer.startAudioSession()

		//	For each NEBSound, create an array containing
		//	the desired number of AVAudioPlayers.
		var theSoundsDictionary: [NEBSound : [AVAudioPlayer]] = [:]
		for theSound in NEBSound.allCases {
		
			var theAudioPlayerArray: [AVAudioPlayer] = []

			for _ in 0 ..< theSound.maxSimultaneousInstances {
				if let theAudioPlayer = NEBSoundPlayer.loadAudioPlayer(for: theSound) {
					theAudioPlayerArray.append(theAudioPlayer)
				}
			}
			
			theSoundsDictionary[theSound] = theAudioPlayerArray
		}
		audioPlayers = theSoundsDictionary
	}

	private static func startAudioSession() {

		//	Even without the following code, the app would
		//	play its sounds correctly. The purpose of these calls
		//	is just to let AVAudioSession know what we're planning to do.

		do {
			let audioSession = AVAudioSession.sharedInstance()
			try audioSession.setCategory(.playback, mode: .default)
			try audioSession.setActive(true)
			
		} catch {
			assertionFailure("Failed to initialize audio: \(error)")
		}
	}

	private static func loadAudioPlayer(
		for sound: NEBSound
	) -> AVAudioPlayer? {
	
		//	Split a filename like "my sound.wav"
		//	into its components ["my sound", "wav"].
		let theFileName = sound.fileName
		let theNameComponents = theFileName.split(separator: ".")
		if theNameComponents.count != 2 {
			return nil
		}

		guard let theSoundURL = Bundle.main.url(
			forResource: "Sounds/" + String(theNameComponents[0]),
			withExtension: String(theNameComponents[1]))
		else {
			assertionFailure("Failed to create URL for \(theFileName)")
			return nil
		}

		let theAudioPlayer: AVAudioPlayer
		do {
			theAudioPlayer = try AVAudioPlayer(contentsOf: theSoundURL)
		} catch {
			assertionFailure("Failed to create AVAudioPlayer for \(theFileName): \(error)")
			return nil
		}
		
		theAudioPlayer.prepareToPlay()
		
		return theAudioPlayer
	}
	
	func play(
		_ sound: NEBSound,
		volume: Double	//	∈  0.0 ... 1.0
	) {

		//	Pull the first AVAudioPlayer assigned to the given NEBSound.
		guard let theAudioPlayer = audioPlayers[sound]?.removeFirst() else {
			assertionFailure("Internal error: failed to find an AVAudioPlayer for \(sound)")
			return
		}
			
		//	theAudioPlayer may or may not be already playing its sound.
		//	But it really don't matter. Either way we start the sound
		//	playing from its beginning.
		theAudioPlayer.currentTime = 0.0		//	begin or continue, playing from start
		theAudioPlayer.volume = Float(volume)	//	∈  0.0 ... 1.0
		theAudioPlayer.play()	//	OK to call play() even if theAudioPlayer is already playing

		//	Put theAudioPlayer at the end of the array,
		//	so the AVAudioPlayers cycle around as we use them.
		audioPlayers[sound]?.append(theAudioPlayer)

		//	Several years ago, on iOS, AVAudioPlayer's play() function
		//	was slow to return. It wasn't a big deal when we called it
		//	just once for, say, one ball bouncing off another.
		//	But when we called it many times in quick succession,
		//	for example during the "break" at the beginning
		//	of a billiards game, those calls could completely freeze
		//	the animation for a noticeably long fraction of a second.
		//	The discussion at
		//
		//		https://developer.apple.com/forums/thread/123540
		//
		//	notes that other people have encountered this same problem.
		//	It then goes on to say that while Apple's documentation never
		//	clearly says whether AVAudioPlayer is thread-safe or not,
		//	the working assumption in the programming community is that
		//	it's OK to call it from multiple threads, just so at most
		//	one thread is calling it at any given time.
		//
		//	I haven't tested whether play() is slow on visionOS,
		//	but in any case the present code avoids the issue entirely.
		//	This NEBSoundPlayer is its own actor, and we call play()
		//	from independent Tasks, so those Tasks shouldn't block
		//	the main actor or the rendering actor. And in practice
		//	it all works great.
	}
}
