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

import Foundation

//	How long should the cue stick sit still before and after the stroke?
//
//		Note:  The human player may see a computer opponent's
//		cue stick fade in when the computer opponent gets the turn,
//		but if the computer opponent takes additional shots as part
//		of the same turn, the transitions will be abrupt.
//
let preStrokeInterval			//	in seconds
	= 1.0 + max(colorSaturationChangeDuration, cueStickOpacityChangeDuration)
let postStrokeInterval = 1.0	//	in seconds

//	How much error should the easiest difficulty setting introduce?
let maxAngleStdDev = 0.1	//	radians
let maxLogSpeedStdDev = 0.2	//	dimensionless

extension NEBRenderer {

	struct NEBAutomatedCueStroke {
		
		//	The strokeStartTime tells when the request to shoot
		//	is received. cueStickPose() then leave the cue stick
		//	motionless for preStrokeInterval seconds before it
		//	starts it moving.
		var strokeStartTime: Double

		//	Pose at which the cue stick's tip reaches
		//	the cue ball's surface. In table coordinates.
		var poseAtImpact: simd_double4x4
		
		//	The comments in planShot() explain these parameters.
		var strokeHalfLength: Double
		var strokePeriod: Double
	}
		
	func shotIsInProgress(
	) -> Bool {
	
		return automatedCueStroke != nil
	}


// MARK: -
// MARK: Cue stick pose

	func cueStickPose(
		atTime time: TimeInterval	//	time since last reboot
	) -> simd_double4x4? {

		if let theAutomatedCueStroke = automatedCueStroke {
		
			//	The comments in planShot() explain these parameters.
			let theStrokeStartTime = theAutomatedCueStroke.strokeStartTime
			let thePoseAtImpact = theAutomatedCueStroke.poseAtImpact
			let d = theAutomatedCueStroke.strokeHalfLength
			let p = theAutomatedCueStroke.strokePeriod

			let totalElapsedStrokeTime = time - theStrokeStartTime
			
			let t: Double
			if totalElapsedStrokeTime < preStrokeInterval {
			
				t = 0.0
				
			} else if totalElapsedStrokeTime < preStrokeInterval + p {
			
				t = totalElapsedStrokeTime - preStrokeInterval
				
			} else if totalElapsedStrokeTime < preStrokeInterval + p + postStrokeInterval {
			
				t = p	//	same effect as t = 0.0
				
			} else {
			
				//	The shot is over.
				automatedCueStroke = nil
				
				return nil
			}

			//	The comments in planShot() explain the formula
			//
			//		z = d cos(2π t/p)
			//
			let z = d * cos(2.0 * Double.pi * t / p)	//	in meters
			
			let theOffset = axisAlignedTranslation(
				axis: 2,
				distanceInRadians: z / spaceGeometry.spaceRadius,
				geometryType: spaceGeometry.geometryType)
			
			let theCueStickPose = thePoseAtImpact * theOffset
									//	right-to-left matrix action

			return theCueStickPose
			
		} else {
		
			return nil	//	no shot in progress
		}
	}
		

// MARK: -
// MARK: Plan shot
		
	func planShot(
		difficulty: Double,	//	∈ 0.0 (easy) ... 1.0 (hard)
		startTime: TimeInterval	//	time since last reboot
	) {
		//	Decide what shot to take.
		
		//	Note #1:  We don't modify or save the gameState.
		//	We examine it here to decide on our shot and then we forget it.
		//	We affect the game only through the values we return
		//	from cueStickPose().
		
		//	Note #2:  Because it considers only direct shots and
		//	ignores shots involving the cushions (for the reasons
		//	explained in detail in feasibleShots()), planShot() is fast.
		//	So there's no problem running it synchronously
		//	on the rendering thread.
		
		precondition(
			gameState.balls[0].status == .inPlay,
			"planShot() called when cue ball is not in play")

		let theFeasibleShots = gameState.feasibleShots(
								for: .computer,
								guaranteeNonemptyResult: true)
		
		//	Select the ShotCandidate with the slowest startSpeed,
		//	because such shots tend to be simple and robust
		//	even after applying Gaussian error.
		let thePreciseShot = slowestShot(of: theFeasibleShots)

		let theImpreciseShot = applyGaussianError(
								to: thePreciseShot,
								difficulty: difficulty)

		requestShot(
			theImpreciseShot,
			startTime: startTime)
	}

	private func requestShot(
		_ requestedShot: ShotCandidate,
		startTime: TimeInterval	//	time since last reboot
	) {
	
		let thePoseAtBallCenter				//	in table coordinates
			= gameState.balls[0].placement	//	right-to-left matrix action
			* spaceGeometry.ballLift
		
		let theRotation = axisAlignedRotation(
			from: 0,
			towards: 2,
			angle: requestedShot.angle)

		let theOffsetAtImpact = axisAlignedTranslation(
			axis: 2,
			distanceInRadians: ballRadius / spaceGeometry.spaceRadius,
			geometryType: spaceGeometry.geometryType)

		let thePoseAtImpact			//	in table coordinates
			= thePoseAtBallCenter	//	right-to-left matrix action
			* theRotation
			* theOffsetAtImpact

		//	Keeping in mind that the z-axis points backwards,
		//	we can model the full forward and backward stroke as
		//
		//		z = d cos(2π t/p)
		//
		//	where d is the "half length" of stroke
		//	and p is the period (even though it's not really periodic
		//	here because we're doing only one cycle).
		//	z is measured relative to point of impact,
		//	so the cue tip strikes the cue ball at t = p/4.
		
		//	We can set d to whatever distance looks good,
		//	then compute the required period p to get the speed right.
		let d = 0.05	//	in meters
		
		let theCueTipSpeedAtImpact = requestedShot.startSpeed / cueStickBounceFactor
		
		//	The cue tip's speed at any time t is
		//
		//		dz
		//		-- = -d (2π/p) sin(2π t/p)     in meters/second
		//		dt
		//
		//	so at the moment of impact, when t = p/4, the speed is
		//
		//		dz
		//		-- = -d (2π/p) sin(2π (p/4)/p)
		//		dt
		//		   = -d (2π/p) sin(π/2)
		//		   = -d (2π/p)                 in meters/second
		//
		//	If we set
		//
		//		-cueTipSpeedAtImpact = -d (2π/p)
		//
		//	we can solve for the period
		//
		//		p = 2π d / cueTipSpeedAtImpact    in seconds
		//
		let p = 2.0 * Double.pi * d / theCueTipSpeedAtImpact

		automatedCueStroke = NEBAutomatedCueStroke(
								strokeStartTime: startTime,
								poseAtImpact: thePoseAtImpact,
								strokeHalfLength: d,
								strokePeriod: p)
	}

	struct ShotCandidate {

		//	Rotation in the XZ coordinate plane
		//	(rotating the positive X axis towards the positive Z axis)
		//	in the cue ball's post-rotation coordinate system.
		//
		//	All our coordinate systems follow Metal's convention
		//	of letting the positive Z axis point towards the observer,
		//	so the cue ball's direction of motion is the negative Z axis.
		//	ShotCandidate.angle tells how to rotate that negative Z axis
		//	to the proposed direction for a shot.
		//
		let angle: Double		//	radians

		let startSpeed: Double	//	meters/second
	}

	private func slowestShot(
		of shotCandidates: [ShotCandidate]	//	shotCandidates must not be empty
	) -> ShotCandidate {
		
		//	Select an arbitrary ShotCandidate to initialize the reduce() call below.
		guard let theFirstShotCandidate = shotCandidates.first else {
			preconditionFailure("Internal error: shotCandidates() is unexpectedly empty")
		}
		
		let theSlowestShot = shotCandidates.reduce(theFirstShotCandidate) {
			theBestSoFar, theCandidate in
			
			return theCandidate.startSpeed < theBestSoFar.startSpeed ?
					theCandidate : theBestSoFar
		}
		
		return theSlowestShot
	}

	private func applyGaussianError(
		to preciseShot: ShotCandidate,
		difficulty: Double	//	∈ 0.0 (easy) ... 1.0 (hard)
	) -> ShotCandidate {

		//	When the difficulty is "easy",
		//		we introduce the largest errors into the computer's shots.
		//	When the difficulty is "hard"
		//		we introduce no errors at all.
		let theEasiness = 1.0 - difficulty	//	∈ 0.0 (hard) ... 1.0 (easy)

		let theAngleStdDev    = theEasiness * maxAngleStdDev	//	radians
		let theLogSpeedStdDev = theEasiness * maxLogSpeedStdDev	//	dimensionless

		let theAngleError    =    theAngleStdDev * getRandomGaussian()
		let theLogSpeedError = theLogSpeedStdDev * getRandomGaussian()

		let theImpreciseAngle = preciseShot.angle + theAngleError
		let theImpreciseSpeed = exp( log(preciseShot.startSpeed) + theLogSpeedError )

		let theImpreciseShot = ShotCandidate(
								angle: theImpreciseAngle,
								startSpeed: theImpreciseSpeed)

		return theImpreciseShot
	}


// MARK: -
// MARK: Place cue ball near center

	func validCueBallPlacementNearCenter() -> simd_double4x4 {
		
		//	Someday we might want to place the cue ball in some
		//	strategically chosen spot, for example one from which
		//	it would be easy to sink one of our assigned target balls.
		//	For now, though, let's just try a bunch of arbitary places
		//	until we find one that's unobstructed by other balls.

		let theBallCenters = gameState.getAllBallCentersXZW()	//	excludes cue ball

		if cueBall(
				at: spaceGeometry.northPoleXZW,
				avoids: theBallCenters) {
				
			return matrix_identity_double4x4
		}
		
		//	Try some moderately spaced positions.
		//	There's nothing scientific about numRadialSteps or numAzimuthalSteps
		//	-- it's all pretty arbitrary.
		//
		let numRadialSteps = 5
		for i in 1...numRadialSteps {	//	number of radial steps
		
			let r = Double(i) * spaceGeometry.ballShadowRadius
			
			let theCircumference = NEBSpaceGeometry.computeCircleCircumference(
													radius: r,
													geometryType: spaceGeometry.geometryType,
													spaceRadius: spaceGeometry.spaceRadius)
			let numAzimuthalSteps = floor( theCircumference / spaceGeometry.ballShadowRadius )
			let Δθ = 2.0 * Double.pi / numAzimuthalSteps
			
			for j in 0 ..< Int(numAzimuthalSteps) {
			
				let θ = Double(j) * Δθ
				
				let theProtoTranslation = r * SIMD3<Double>(cos(θ), 0.0, sin(θ))
				
				let theCueBallPlacement = geodesicTranslation(
					v: theProtoTranslation / spaceGeometry.spaceRadius,
					geometryType: spaceGeometry.geometryType)
				
				let theCueBallCenter = theCueBallPlacement * spaceGeometry.northPole
				let theCueBallCenterXZW = VectorXZW(
					theCueBallCenter.x,	//	x
					//	omit y
					theCueBallCenter.z,	//	z
					theCueBallCenter.w )	//	w
				
				if cueBall(
					at: theCueBallCenterXZW,
					avoids: theBallCenters) {
					
					return theCueBallPlacement
				}
			}
		}
		
		preconditionFailure("Unexpectedly failed to find a valid place to put the cue ball in chooseCueBallPlacement()")
	}

	private func cueBall(
		at cueBallCenterXZW: VectorXZW,
		avoids ballCentersXZW: [VectorXZW]
	) -> Bool {	//	Does the candidate cue ball position avoid the other balls?

		let theMinDistance = 2.0 * spaceGeometry.ballShadowRadius

		for theBallCenterXZW in ballCentersXZW {
		
			if spaceGeometry.tableDistanceFunction(
								cueBallCenterXZW,
								theBallCenterXZW)
				<= theMinDistance {
			
				return false	//	cue ball would overlap this other ball
			}
		}
		
		return true	//	cue ball avoids all other balls
	}

	private func getRandomGaussian() -> Double {
					//	Returns values sampled from a Gaussian distribution
					//		with mean 0.0 and standard deviation 1.0

		//	Use the Box-Muller transform to generate
		//	a normally distributed random variable.

		var u₁: Double
		repeat {
			u₁ = Double.random(in: 0.0 ..< 1.0)	//	uniform in 0.0 ≤ u₁ ≤ 1.0
		} while u₁ == 0.0	//	almost never occurs
		
		let u₂ = Double.random(in: 0.0 ..< 1.0)	//	uniform in 0.0 ≤ u₂ ≤ 1.0

		//	x values will be Gaussian with mean 0.0 and standard deviation 1.0
		let x = sqrt( -2.0 * log(u₁) ) * cos( 2.0 * Double.pi * u₂ )

		return x
	}
}
