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

import SwiftUI


let minTableHeight = 0.7	//	in meters, above the floor

//	When there's an elevated stage, visionOS can get confused
//	about where the floor is. When this happened in Leipzig in 2024,
//	the pool table ended up ~20 cm lower than it was supposed to be,
//	forcing players to bend over to play. In case this ever happens
//	again anywhere, let's allow the table to be much higher than
//	would be reasonable for normal home use.
let maxTableHeight = 1.5	//	in meters, above the floor

#if targetEnvironment(simulator)

//	The only reason for supporting the simulator is that
//	it's a convenient environment for making screenshots
//	of the Control Panel. You can't actually play pool
//	in the simulator because it doesn't support hand tracking.

//	The simulator seems to be placing the coordinate origin at the player's eyes,
//	in contrast to the Vision Pro itself which (as expected and as documented)
//	places the coordinate origin at the player's feet.
let defaultTableHeight	= -0.7	//	in meters, "above" the player's eyes, i.e. below them

#else

//	A regulation billiards table must have a bed that sits
//	between 29.25" and 31" above the floor (roughly 74-79 cm).
//	To use it, players typically extend their bodies
//	horizontally and rest some weight on the table.
//	But Curved-Space Pool Hall uses no physical table,
//	and has no place for the player to rest any weight.
//	So let's assume the player will be standing more-or-less
//	vertical and will prefer a slightly higher table.
//
//	A higher table is also more convenient when playing
//	in hyperbolic space with keepTableLevel disabled,
//	when the table slopes downward towards the player.
//
//	In addition, during lectures a regulation-height table
//	is too low to be viewed comfortably edge-on.
//
let defaultTableHeight	= 0.90	//	in meters, above the floor
								//	(use 0.76 for regulation height)

#endif


struct NEBControlPanel: View {

	@Binding var playerHandedness: PlayersHand
	@Binding var computerPlayerDifficulty: Double	//	∈ 0.0 … 1.0
	@Binding var guardianAngelFactor: Double		//	∈ 0.0 … 1.0
	@Binding var tableHeight: Double
	@Binding var keepTableLevel: Bool
	@Binding var multicolorLattice: Bool
	@Binding var brieferPrompts: Bool
	@Binding var hideHands: Bool

	@AppStorage("Azimuthal projection") var azimuthalProjection: GGVRAzimuthalProjection = .equidistant

	@State var showRecenterMessage: Bool = false
	@State var showSettingsView: Bool = false
	@State var showHelpView: Bool = false
	@State var showSphericalCentralProjectionConfirmationView: Bool = false
	
	@State var waitProgress: Double = 0.0	//	∈ 0.0 … 1.0
	
	@Environment(\.dismissWindow) var dismissWindow
	@Environment(\.openImmersiveSpace) var openImmersiveSpace

	var body: some View {
	
		//	The order {euc, hyp, sph} encourages players to try
		//	the three geometries in ascending order of difficulty,
		//	which is probably more important than presenting them
		//	in their geometrically natural order {sph, euc, hyp}.
		//
		let theRecommendedGeometryOrder: [GeometryType] = [.euclidean, .hyperbolic, .spherical]
		
		let startPoolGame = { (
				geometryType: GeometryType,
				azimuthalProjection: GGVRAzimuthalProjection
			) -> Void in

			do {
			
				//	During talks and do-it-yourself-sessions we skip
				//	the tedious eye calibration procedure and instead
				//	let players use the saved Recent Guest Setup. As a result
				//	the eye tracking is likely to be poor and players will need
				//	to walk over to the menu to tap on the buttons they want
				//	to select. When they tap on one of the Play…Pool buttons,
				//	we'll need to give them a few seconds to get back
				//	to the center of the room before we start the game.
				//
				//	Hmmm... come to think of it, even during normal home use
				//	people tend to start a new game from wherever they happen
				//	to be standing at the end of the preceding game. So let's
				//	remind them to start in the center of their available play area
				//	and give them a few seconds to get there.
				//
				let theTotalTime = 3			//	in sec
				let theUpdatesPerSecond = 30	//	in sec⁻¹
				let theNumUpdates = theTotalTime * theUpdatesPerSecond
				let theWaitInterval: UInt64 = 1_000_000_000 / UInt64(theUpdatesPerSecond)
												//	in nanoseconds
				showRecenterMessage = true
				waitProgress = 0.0
				for i in 1 ... theNumUpdates {
					
					do { try await Task.sleep(nanoseconds: theWaitInterval ) }
					catch { assertionFailure("Task.sleep() failed") }
					
					waitProgress = Double(i) / Double(theNumUpdates)
				}
				
				//	The comment accompanying hideHands in NEBApp
				//	explains why we need to open the immersive space
				//	with the player's hands visible and then wait
				//	for it to appear before hiding the hands.
				//
				hideHands = false
			
				if await openImmersiveSpace(value:
						ImmersiveSpaceParameters(
							geometryType: geometryType,
							azimuthalProjection: azimuthalProjection)
					) == .opened {

					dismissWindow(id: "Control panel")
					
					//	If we set hideHands=true too soon, our request
					//	sometimes gets ignored. So let's wait a second.
					//
					//	As an added bonus, it's good for the player
					//	to see his/her physical hands briefly overlapping
					//	the virtual hands before the former fade away.
					//
					try await Task.sleep(nanoseconds: 1_000_000_000)
					
					hideHands = true

				} else {
					assertionFailure("Failed to create billiards immersive space")
				}

				showRecenterMessage = false

			} catch {
				assertionFailure("Error in startPoolGame: \(error)")
			}
		}
		
		if showRecenterMessage {
		
			GGVRReturnToCenterView(waitProgress: $waitProgress)

		} else {
	
			VStack(spacing: 24.0) {

				VStack() {
				
					Picker("n/a", selection: $playerHandedness) {

						Image(systemName: "hand.raised.fingers.spread")
						.tag(PlayersHand.left)
		
						Image("right hand")	//	my custom SF Symbol
						.tag(PlayersHand.right)
					}
					.pickerStyle(.segmented)
					.fixedSize()

					Text("I'm " + playerHandedness.name + " handed")
					.font(.caption)
				}

				VStack(spacing: 12.0) {
				
					ForEach(theRecommendedGeometryOrder, id: \.id) { theGeometryType in
					
						Button() {
						
							if theGeometryType == .spherical
							&& azimuthalProjection == .central {
							
								showSphericalCentralProjectionConfirmationView = true
								
							} else {
							
								Task() { await startPoolGame(
												theGeometryType,
												azimuthalProjection) }
							}

						} label: {
							Text(theGeometryType.playButtonTitle)
							.multilineTextAlignment(.center)
							.frame(maxWidth: .infinity)
						}
						.alert(
							"Tourist vision\nin spherical space",
							isPresented: $showSphericalCentralProjectionConfirmationView
						) {

							Button("Use Native perception\nto see full sphere") { Task() {
								await startPoolGame(.spherical, .equidistant) } }

							Button("Use Tourist perception\nto see near hemisphere only") { Task() {
								await startPoolGame(.spherical, .central) } }

							Button("Cancel", role: .cancel) {}

						} message: {
							Text("A tourist in spherical space would need to look walleyed to focus on objects in the far hemisphere. To avoid forcing your eyes into that uncomfortable position, Curved-Space Pool Hall suppresses the far hemisphere when you use Tourist perception in spherical pool.\n\nRecommended alternative:\nUse Native perception instead.")
						}
					}
				}
										
				HStack() {

					Button(
						action: { showSettingsView = true },
						label: { Image(systemName: "gear") }
					)
					.sheet(isPresented: $showSettingsView) {
						SettingsView(
							computerPlayerDifficulty: $computerPlayerDifficulty,
							guardianAngelFactor: $guardianAngelFactor,
							tableHeight: $tableHeight,
							keepTableLevel: $keepTableLevel,
							azimuthalProjection: $azimuthalProjection,
							multicolorLattice: $multicolorLattice)
					}

					Button(
						action: { showHelpView = true },
						label: { Image(systemName: "questionmark.circle") }
					)
					.sheet(isPresented: $showHelpView) {
						HelpView()
					}
					
					if forTalks && allowBrieferPrompts {
						Button(
							action: { brieferPrompts.toggle() },
							label: { Image(systemName: "info.bubble") }
						)
						.buttonStyle(.bordered)
						.tint(brieferPrompts ? .red : .green)
					}
				}
				.frame(maxWidth: .infinity, alignment: .center)
			}
			.padding(32.0)
		}
	}
}

struct SettingsView: View {

	@Binding var computerPlayerDifficulty: Double	//	∈ 0.0 ... 1.0
	@Binding var guardianAngelFactor: Double		//	∈ 0.0 ... 1.0
	@Binding var tableHeight: Double
	@Binding var keepTableLevel: Bool
	@Binding var azimuthalProjection: GGVRAzimuthalProjection
	@Binding var multicolorLattice: Bool

	@State var showComputerOppenentInfo: Bool = false
	@State var showGuardianAngelInfo: Bool = false
	@State var showKeepTableLevelInfo: Bool = false
	@State var showAzimuthalProjectionInfo: Bool = false

	@Environment(\.dismiss) var dismiss	//	to dismiss a sheet

	var body: some View {

		Grid(horizontalSpacing: 24.0, verticalSpacing: 24.0) {
		
			GridRow() {

				VStack(alignment: .leading) {

					HStack() {
					
						Text("Computer opponent")
						
						Spacer()
						
						Text(
							difficultyDescription()
						  + String(format:" (%d %%)", Int(computerPlayerDifficulty * 100.0))
										//	uses 'thin space'
						)
						.foregroundColor(.secondary)
					}
				
					Slider(
						value: $computerPlayerDifficulty,
						in: 0.0 ... 1.0)
				}
					
				Button() {
					showComputerOppenentInfo = true
				} label: {
					Image(systemName: "info.circle")
				}
				.sheet(isPresented: $showComputerOppenentInfo) {
					ComputerOppenentInfo()
				}
			}

			GridRow() {
			
				VStack() {
								
					HStack() {
					
						Text("Guardian angel")
						
						Spacer()
						
						Text(String(format:"%d %%", Int(guardianAngelFactor * 100.0)))
									//	uses 'thin space'
						.foregroundColor(.secondary)
					}
				
					Slider(
						value: $guardianAngelFactor,
						in: 0.0 ... 1.0)
				}
					
				Button() {
					showGuardianAngelInfo = true
				} label: {
					Image(systemName: "info.circle")
				}
				.sheet(isPresented: $showGuardianAngelInfo) {
					GuardianAngelInfo()
				}
			}
		
			GridRow() {
				VStack() {
					HStack() {
					
						Text("Table height")
						
						Spacer()
						
						Text(String(format:"%.1f cm", tableHeight * 100.0))
						.foregroundColor(.secondary)
					}
					Slider(
						value: $tableHeight,
						in: minTableHeight ... maxTableHeight)
				}
			}

			GridRow() {
			
				HStack() {
					
					Text("Perceive distances like a")
					
					Picker("n/a", selection: $azimuthalProjection) {
						ForEach(GGVRAzimuthalProjection.allCases, id: \.id) { theProjection in
							Text(theProjection.titleInPicker).tag(theProjection)
						}
					}
					.pickerStyle(.segmented)
				}
				
				Button() {
					showAzimuthalProjectionInfo = true
				} label: {
					Image(systemName: "info.circle")
				}
				.sheet(isPresented: $showAzimuthalProjectionInfo) {
					AzimuthalProjectionInfo()
				}
			}

			GridRow() {
			
				Toggle(isOn: $keepTableLevel) {
					Text("Keep table level")
				}
				
				Button() {
					showKeepTableLevelInfo = true
				} label: {
					Image(systemName: "info.circle")
				}
				.sheet(isPresented: $showKeepTableLevelInfo) {
					KeepTableLevelInfo()
				}
			}

			GridRow() {
				Toggle(isOn: $multicolorLattice) {
					Text("Multicolor lattice")
				}
			}

			Button("Done") {
				dismiss()
			}
		}
		.monospacedDigit()
		.padding(EdgeInsets(top: 32.0, leading: 48.0, bottom: 32.0, trailing: 48.0))
	}

	func difficultyDescription() -> String {
	
		if computerPlayerDifficulty == 0.0 {
			return "Weak"
		} else
		if computerPlayerDifficulty == 1.0 {
			return "Strong"
		} else {
			return "Intermediate"
		}
	}
}

struct HelpView: View {

	@Environment(\.dismiss) var dismiss	//	to dismiss a sheet

	enum HelpItem {
		case whyMiddleOfTable
		case cutCorners
	}

	@State var helpItem: HelpItem = .whyMiddleOfTable
	@State var translatePerson: Bool = false

	var body: some View {
	
		VStack(spacing: 16.0) {
		
			switch helpItem {
			
			case .whyMiddleOfTable:

				ZStack() {

					Image("pool table")
					.resizable()
					.scaledToFit()
					.frame(width: 144.0, height: 144.0)
					.frame(width: 192.0, height: 192.0, alignment: .top)

					Image("person")
					.resizable()
					.scaledToFit()
					.frame(width: 96.0, height: 96.0)
					.offset(y: translatePerson ? +48.0 : -48.0)
					.frame(width: 192.0, height: 192.0)
					.task {
						withAnimation(
							.linear(duration: 2.0).delay(2.0))
						{
							translatePerson = true
						}
					}
				}
	
				Text("Why does the app start me\nin the middle of the table?")
				.font(.title)
				.fixedSize(horizontal: false, vertical: true)
					//	fixedSize() is needed to allow multiple lines
					//	if the enclosing frame's height isn't specified
					//	(unintuitive but true)
				.multilineTextAlignment(.center)

				Text("visionOS limits you to a radius of 1.5 meters from your starting point — if you walk any further it will fade you back to real reality, for safety reasons. Curved-Space Pool Hall centers the pool table within that 1.5 m safe circle, so you can walk freely all the way around the table without leaving the simulation (well, except in spherical space — more on that later). This works fine during play, but it means that each time you start a new game you find yourself at the center of the safe circle — and therefore at the center of the table — which is why you need to take a few steps backwards before you take your first shot.")
				.fixedSize(horizontal: false, vertical: true)
					//	fixedSize() is needed to allow multiple lines
					//	if the enclosing frame's height isn't specified
					//	(unintuitive but true)
				.multilineTextAlignment(.leading)

				Button("Next") {
					helpItem = .cutCorners
				}

			case .cutCorners:

				Text("Why is spherical space different?")
				.font(.title)
				.fixedSize(horizontal: false, vertical: true)
					//	fixedSize() is needed to allow multiple lines
					//	if the enclosing frame's height isn't specified
					//	(unintuitive but true)
				.multilineTextAlignment(.center)

				Text("Imagine walking around the center of the virtual pool table in a perfect circle of radius 1 meter. If you're playing Euclidean pool, then you of course find yourself walking around a 1‑meter circle in your real-world room as well. But if you're playing hyperbolic pool and walk around a 1‑meter virtual circle, you find yourself walking around a circle of radius *less* than 1 meter in the real world. By contrast, if you're playing spherical pool, you find yourself walking around a circle of radius *more* than 1 meter in the real world. Sometimes a lot more. That's why the app suggests that you “cut the corners” to get from one side of the pool table to the next: if you walked the long way around a corner, you'd leave the simulation's 1.5 m safe circle and visionOS would fade you back to real reality.")

				DisclosureGroup(
					content: {

						VStack(alignment: .leading, spacing: 8.0) {
						
							Text("If you walk around a circle of radius *r* in a virtual spherical space of radius *ρ*, you'll end up walking around a circle of radius *R* = *ρ* tan(*r*/*ρ*) in your real-world room. Note that if you walk around a circle of virtual radius *r* = (π/2)*ρ*, the real-world radius becomes *R* = ∞ ! At that point you're walking around the sphere's equator, which ‘unrolls’ to a straight line in your real-world room.")

							Text("In a virtual hyperbolic space, the formula is almost the same: *R* = *ρ* tanh(*r*/*ρ*). But because it uses tanh() instead of tan(), no matter how large the circle's virtual radius *r* becomes, tanh() is always less than 1, so the real-world radius *R* is always less than the spatial radius *ρ*.")

							Text("Hints to discover the spherical formula on your own: Draw a circle on an ordinary sphere, add a conical “dunce cap” tangent to that circle, then slice the dunce cap open along a ray from its tip to its edge, and unroll it onto a flat plane. The construction for the hyperbolic formula works exactly the same way, but it's harder for novice geometers because you need to do it in Minkowski space instead of in Euclidean space.")
						}
						.font(.caption2)
						.fixedSize(horizontal: false, vertical: true)
							//	fixedSize() is needed to allow multiple lines
							//	if the enclosing frame's height isn't specified
							//	(unintuitive but true)
						.multilineTextAlignment(.leading)
						.padding([.leading, .trailing], 16.0)

					},
					label: {
						Text("Show details")
						.font(.caption2)
					}
				)

				Button("Done") {
					dismiss()
				}
			}
		}
		.padding(32.0)
		.frame(maxWidth: 480.0)
	}
}

struct ComputerOppenentInfo: View {

	@Environment(\.dismiss) var dismiss	//	to dismiss a sheet

	var body: some View {
	
		VStack(spacing: 16.0) {
			
			Text("Computer opponent")
			.font(.title)
		
			Text("Your computer opponent considers all possible direct shots (no combination shots) and chooses the one it likes best. The computer could of course easily make that shot, sinking the object ball every time. And if you set it to *Strong (100%)*, that's exactly what it will do. But that's no fun for you, the human, to just watch the computer win every time. Instead you can adjust the computer's strength for a more interesting game. As you push the slider towards *Weak (0%)*, the app adds a progressively larger “Gaussian random error” to the computer's shots, making it play more like another human.")
			
			Button("Dismiss") {
				dismiss()
			}
		}
		.padding(32.0)
		.frame(maxWidth: 512.0)
	}
}

struct GuardianAngelInfo: View {

	@Environment(\.dismiss) var dismiss	//	to dismiss a sheet

	var body: some View {
	
		VStack(spacing: 16.0) {
			
			Text("Guardian angel")
			.font(.title)
		
			Text("The guardian angel tries to see which ball you're attempting to sink in which pocket. If you've set the guardian angel to 100%, it removes all the error from your shot and you sink the ball exactly. By contrast, if you've set it to 0% it lets your shot proceed unmodified — you're on your own! Otherwise, with the guardian angel set to some intermediate strength, it interpolates between your actual shot and the perfect shot that you intended.")
			
			Button("Dismiss") {
				dismiss()
			}
		}
		.padding(32.0)
		.frame(maxWidth: 512.0)
	}
}

struct KeepTableLevelInfo: View {

	@Environment(\.dismiss) var dismiss	//	to dismiss a sheet

	var body: some View {

		VStack(spacing: 16.0) {
			
			Text("Keep table level")
			.font(.title)
			
			Text("When *Keep table level* is disabled, the motions of your virtual head in the curved virtual space exactly match the motions of your physical head in the real world. When you move your physical head a centimeter to the left, your virtual head also moves a centimeter to its left; when you rotate your physical head by 3°, your virtual head also rotates by 3°; and so on. This is an excellent way to experience curved space, but if you find the pool table's surprising upward or downward tilt to be awkward during play, you may want to enable the *Keep table level* option.")
		
			Text("When *Keep table level* is enabled, the app focuses not on your head itself, but rather on your head's 'orthogonal shadow' onto the pool table. When your physical head's shadow moves a centimeter to its left, your virtual head's shadow also moves a centimeter to its left, and so on. The app then places your virtual head the same distance above the virtual table that your physical head would sit above a real-world pool table. This approach keeps the table horizontal and always at the expected height.")
			
			Button("Dismiss") {
				dismiss()
			}
		}
		.padding(32.0)
		.frame(maxWidth: 512.0)
	}
}
