"""
----------
Browser front end for Six Neighbors puzzle applet, recreating a java AWT
implementation of the puzzle from 2001

Architecture:

                 Browser
            (HTML / CSS / DOM)
                    │
                    │
          SixNeighborsApp (main.py)
                    │
     ┌──────────────┼──────────────┐
     │              │              │
     ▼              ▼              ▼
  Puzzle         Geometry       Renderer
     ▲                              │
     │                              ▼
     └──────── Generator ───────── Canvas

Everything is coordinated by SixNeighborsApp.
The browser knows nothing about the puzzles.
The puzzle knows nothing about browsers.
The renderer knows nothing about HTML controls.

Project files:
main.py           SixNeighborsApp object. This runs the show, and is the glue
                  between browser events and everything else.
puzzle.py         Puzzle object. Owns the puzzle state. This is essentially the
                  game engine.
renderer.py       Render object. Draws the puzzle.
geometry.py       Geometry object. Manages the hexagon grid and pixel
                  coordinates.
generator.py      Generator object. Generates puzzles.
settings.py       Settings object. Manages persistent user settings.
keystring.py      Maps between puzzle name strings and numeric key values.
hashing.py        A stable hash function.
splitmix_prng.py  A stable random number generator.
about.txt         A description of the puzzles and the user interface.

puzzle.html, puzzle.css, pyscript.json are as is typical for a pyscript project.
----------
"""


from js          import URLSearchParams,document,localStorage,window
from pyodide.ffi import create_proxy

import random
import time
import json
from enum import Enum

from geometry    import Geometry
from generator   import PuzzleGenerator
from puzzle      import Puzzle
from renderer    import Renderer
from settings    import Settings
from keystring   import keyMax,keystring_to_key,key_to_keystring,next_key,previous_key


mouseHelpText = "Click a cell. Hover over a cell to preview a move."
touchHelpText = "Tap a cell. Use Inspect to preview a move."


class InputMode(Enum):
	normal  = 0
	inspect = 1
	cheat   = 2


class SixNeighborsApp:

	base62prefix = "[]"

	def __init__(self):
		document.body.dataset.redrawCount = "0"
		self.redrawCount = 0

		self.has_permission()

		#document.body.dataset.moveAnimating = "false"

		self.canvas                   = document.getElementById("puzzle-canvas")
		self.ctx                      = self.canvas.getContext("2d")

		self.debug                    = (document.body.dataset.debug == "true")
		self.hasHover                 = bool(window.matchMedia("(hover: hover)").matches)
		if (self.hasHover): self.simpleHelpText = mouseHelpText
		else:               self.simpleHelpText = touchHelpText

		self.movesTakenField          = document.getElementById("moves-taken")
		self.minimumMovesField        = document.getElementById("minimum-moves")
		self.puzzleNameField          = document.getElementById("puzzle-name")
		self.puzzleGroupField         = document.getElementById("puzzle-group")
		self.puzzleGroupNames         = [option.text for option in self.puzzleGroupField.options]
		self.status                   = document.getElementById("status")
		self.aboutOverlay             = document.getElementById("about-overlay")
		self.aboutText                = document.getElementById("about-text")
		self.aboutCloseButton         = document.getElementById("about-close-button")
		self.undoButton               = document.getElementById("undo-button")
		self.redoButton               = document.getElementById("redo-button")
		self.resetButton              = document.getElementById("reset-button")
		self.showMovesUsedCheckbox    = document.getElementById("moves-used-checkbox")
		self.showMovesNeededCheckbox  = document.getElementById("moves-needed-checkbox")
		self.inspectCheckbox          = document.getElementById("inspect-checkbox")
		self.cheatCheckbox            = document.getElementById("cheat-checkbox")
		self.inspectCheckbox.disabled = self.hasHover
		self.cheatCheckbox.disabled   = self.hasHover
		self.aboutText.textContent    = self.fetch_text("about.txt")

		self.puzzleNameMaxLength     = 20
		self.numPuzzleGroups         = 4
		self.moveAnimationDurationMs = 130.0   # (in milliseconds)

		self.settings = Settings()
		self.load_preferences()

		deepLink = self.read_deep_links()
		self.showMovesUsed          = deepLink.get("showMovesUsed",self.settings.showMovesUsed)
		self.showMovesNeeded        = deepLink.get("showMovesNeeded",self.settings.showMovesNeeded)
		self.hasUsedMovesNeeded     = False
		self.hasCheated             = False
		self.solvedAlready          = False
		self.inspectionCellIx       = None
		self.inputMode              = InputMode.normal
		self.resizeFrameId          = None
		self.moveAnimationFrameId   = None
		self.moveAnimationStart     = None
		self.moveAnimationOldValues = None
		self.moveAnimationProgress  = None

		# set up a body class to reflect whether we're screen grabbing

		self.screenGrabbing = ("screenGrabbing" in deepLink)
		document.body.classList.toggle("screen-grabbing-mode",self.screenGrabbing)

		# choose a starting puzzle key, either from a deep link or from fresh
		# runtime entropy; this key is not used to generate a puzzle yet; for
		# now, it only initializes the displayed puzzle name and the saved key
		# state
		#
		# nota bene: we'll use the keyOffset value to modify values in the key
		# sequence so that a particular keystring ("garden") will produce a
		# puzzle well-suited for a tutorial.

		self.keyOffset = 19834

		if ("puzzleName" in deepLink):
			# $$$ eventually, this should call something like fetch_puzzle_name()
			#     to check for base62 names, but currently we are not supporting
			#     base62 from deep links
			self.puzzleName = deepLink["puzzleName"]
			self.puzzleKey = (keystring_to_key(self.puzzleName) + self.keyOffset) % keyMax
			self.puzzleNameField.value = self.puzzleName
		else:
			seedValue = time.time_ns()
			self.rng = random.Random(seedValue)
			self.puzzleKey = self.rng.randrange(keyMax)
			self.puzzleName = key_to_keystring((self.puzzleKey-self.keyOffset) % keyMax)
			self.puzzleNameField.value = self.puzzleName
		self.puzzleNameHidden = False

		puzzleGroup  = deepLink.get("puzzleGroup",self.settings.puzzleGroup)
		minimumMoves = deepLink.get("minimumMoves",self.settings.minimumMoves)
		self.puzzleGroupField.selectedIndex = puzzleGroup - 1
		self.minimumMovesField.value = str(minimumMoves)

		self.resize_canvas_to_display()

		# create objects to manage the puzzle

		self.geometry  = Geometry(self.canvasDisplayWidth,self.canvasDisplayHeight)
		self.puzzle    = Puzzle(self.geometry.neighborTable)
		self.generator = PuzzleGenerator(self.geometry.neighborTable,self.puzzle.numColors)
		self.renderer  = Renderer(self.puzzle,self.geometry,
		                          isDarkMode=self.should_use_dark_mode(),
		                          isNumericMode=("numericMode" in deepLink),
		                          showCellIds=("showCellIds" in deepLink),
		                          debug=self.debug)

		if (self.screenGrabbing):
			self.publish_cell_centers()

		# hook up the callbacks

		self.callbacks = {
			"canvasClick":         create_proxy(self.on_canvas_click),
			"canvasMouseMove":     create_proxy(self.on_canvas_mousemove),
			"canvasMouseLeave":    create_proxy(self.on_canvas_mouseleave),
			"about":               create_proxy(self.on_about),
			"aboutClose":          create_proxy(self.on_about_close),
			"aboutKeyDown":        create_proxy(self.on_about_keydown),
			"documentKeyDown":     create_proxy(self.on_document_keydown),
			"clear":               create_proxy(self.on_clear),
			"reset":               create_proxy(self.on_reset),
			"undo":                create_proxy(self.on_undo),
			"redo":                create_proxy(self.on_redo),
			"nextPuzzle":          create_proxy(self.on_next_puzzle),
			"showMovesUsed":       create_proxy(self.on_show_moves_used),
			"showMovesNeeded":     create_proxy(self.on_show_moves_needed),
			"inspect":             create_proxy(self.on_inspect),
			"cheat":               create_proxy(self.on_cheat),
			"puzzleNameKeyDown":   create_proxy(self.on_puzzle_name_keydown),
			"puzzleNameChange":    create_proxy(self.on_puzzle_name_change),
			"minimumMovesKeyDown": create_proxy(self.on_minimum_moves_keydown),
			"minimumMovesChange":  create_proxy(self.on_minimum_moves_change),
			"minimumMovesInput":   create_proxy(self.on_minimum_moves_input),
			"puzzleGroupChange":   create_proxy(self.on_puzzle_group_change),
			"windowResize":        create_proxy(self.on_window_resize),
			"resizeFrame":         create_proxy(self.on_resize_frame),
			"moveAnimationFrame":  create_proxy(self.on_move_animation_frame),
			}

		aboutButton      = document.getElementById("about-button")
		clearButton      = document.getElementById("clear-button")
		nextPuzzleButton = document.getElementById("next-puzzle-button")

		self.canvas                 .addEventListener("click",            self.callbacks["canvasClick"])
		self.canvas                 .addEventListener("mousemove",        self.callbacks["canvasMouseMove"])
		self.canvas                 .addEventListener("mouseleave",       self.callbacks["canvasMouseLeave"])
		aboutButton                 .addEventListener("click",            self.callbacks["about"])
		self.aboutCloseButton       .addEventListener("click",            self.callbacks["aboutClose"])
		self.aboutOverlay           .addEventListener("click",            self.callbacks["aboutClose"])
		self.aboutOverlay           .addEventListener("keydown",          self.callbacks["aboutKeyDown"])
		document                    .addEventListener("keydown",          self.callbacks["documentKeyDown"])
		clearButton                 .addEventListener("click",            self.callbacks["clear"])
		self.resetButton            .addEventListener("click",            self.callbacks["reset"])
		self.undoButton             .addEventListener("click",            self.callbacks["undo"])
		self.redoButton             .addEventListener("click",            self.callbacks["redo"])
		nextPuzzleButton            .addEventListener("click",            self.callbacks["nextPuzzle"])
		self.showMovesUsedCheckbox  .addEventListener("change",          self.callbacks["showMovesUsed"])
		self.showMovesNeededCheckbox.addEventListener("change",          self.callbacks["showMovesNeeded"])
		self.inspectCheckbox        .addEventListener("change",           self.callbacks["inspect"])
		self.cheatCheckbox          .addEventListener("change",           self.callbacks["cheat"])
		self.puzzleNameField        .addEventListener("keydown",          self.callbacks["puzzleNameKeyDown"])
		self.puzzleNameField        .addEventListener("change",           self.callbacks["puzzleNameChange"])
		self.minimumMovesField      .addEventListener("keydown",          self.callbacks["minimumMovesKeyDown"])
		self.minimumMovesField      .addEventListener("change",           self.callbacks["minimumMovesChange"])
		self.minimumMovesField      .addEventListener("input",            self.callbacks["minimumMovesInput"])
		self.puzzleGroupField       .addEventListener("change",           self.callbacks["puzzleGroupChange"])
		window                      .addEventListener("resize",           self.callbacks["windowResize"])
		window                      .addEventListener("orientationchange",self.callbacks["windowResize"])

		# initialize the puzzle

		self.update_show_moves_used_checkbox()
		self.update_show_moves_needed_checkbox()
		self.update_input_mode_checkboxes()
		self.scramble_puzzle(self.simpleHelpText)

		loadingMessage = document.querySelector("#loading-message")
		loadingMessage.hidden = True


	def load_preferences(self,
		override:dict = None
		) -> None:
		""" load user preferences from browser storage """

		try:
			jsonText = localStorage.getItem(Settings.storageKey)
			if (jsonText is not None):
				self.settings.from_json(str(jsonText))
			if (override is not None):
				self.settings.from_dict(override)
		except Exception:
			pass


	def save_preferences(self) -> None:
		""" save user preferences to browser storage """

		try:
			localStorage.setItem(Settings.storageKey,self.settings.to_json())
		except Exception:
			return


	def read_deep_links(self) -> dict:
		""" return (and sanitize) validated startup overrides from the page URL """

		# URL parameters affect only this page load; in particular, opening a
		# tutorial link must not silently replace the user's saved preferences

		params = URLSearchParams.new(window.location.search)
		deepLink = {}

		numericMode = str(params.get("numeric") or "").lower().strip()
		if (numericMode in ("true","1","on","yes")):
			deepLink["numericMode"] = True

		cellIds = str(params.get("cellids") or "").lower().strip()
		if (cellIds in ("true","1","on","yes")):
			deepLink["showCellIds"] = True

		screenGrabbing = str(params.get("selfie") or "").lower().strip()
		if (screenGrabbing in ("true","1","on","yes")):
			deepLink["screenGrabbing"] = True

		puzzleName = str(params.get("name") or "").lower().strip()
		if (puzzleName != "") and (len(puzzleName) <= self.puzzleNameMaxLength):
			deepLink["puzzleName"] = puzzleName

		puzzleGroupText = params.get("group")
		try:
			puzzleGroup = int(str(puzzleGroupText))
		except (TypeError,ValueError):
			puzzleGroup = None
		if (puzzleGroup is not None) and (1 <= puzzleGroup <= self.numPuzzleGroups):
			deepLink["puzzleGroup"] = puzzleGroup
			maxMinimumMoves = 27 * puzzleGroup
		else:
			maxMinimumMoves = 27

		minimumMovesText = params.get("moves")
		try:
			minimumMoves = int(str(minimumMovesText))
		except (TypeError,ValueError):
			minimumMoves = None
		if (minimumMoves is not None) and (1 <= minimumMoves <= maxMinimumMoves):
			deepLink["minimumMoves"] = minimumMoves

		showSolutionText = str(params.get("showused") or "").lower().strip()
		if (showSolutionText in ("true","1","on","yes")):
			deepLink["showMovesUsed"] = True
		elif (showSolutionText in ("false","0","off","no")):
			deepLink["showMovesUsed"] = False

		showSolutionText = str(params.get("showneeded") or "").lower().strip()
		if (showSolutionText in ("true","1","on","yes")):
			deepLink["showMovesNeeded"] = True
		elif (showSolutionText in ("false","0","off","no")):
			deepLink["showMovesNeeded"] = False

		return deepLink


	def should_use_dark_mode(self) -> bool:
		""" determine whether the current settings request dark rendering """

		# this is how we should normally do this, but I think the app looks
		# so much better in dark mode that unless the user specifically denies
		# dark, we'll use dark
		#	if (self.settings.theme == "dark"): return True
		#	if (self.settings.theme == "light"): return False
		#	return bool(window.matchMedia("(prefers-color-scheme: dark)").matches)

		return (self.settings.theme != "light")

	def resize_canvas_to_display(self) -> bool:
		""" match the canvas backing store to its CSS size and pixel density """

		defaultWidth  = Geometry.designCanvasWidth
		defaultHeight = Geometry.designCanvasHeight
		canvasRect    = self.canvas.getBoundingClientRect()
		displayWidth  = round(canvasRect.width)
		if (displayWidth <= 0): displayWidth = defaultWidth
		displayHeight = round((displayWidth * defaultHeight) / defaultWidth)

		# geometry and rendering use CSS-pixel coordinates. The larger backing
		# store supplies the additional device pixels, while this transform keeps
		# the drawing code independent of the display's pixel density.
		pixelRatio = float(window.devicePixelRatio or 1.0)
		backingWidth  = round(displayWidth  * pixelRatio)
		backingHeight = round(displayHeight * pixelRatio)

		if   ((self.canvas.width  == backingWidth)
		  and (self.canvas.height == backingHeight)
		  and (getattr(self,"canvasDisplayWidth",None)  == displayWidth)
		  and (getattr(self,"canvasDisplayHeight",None) == displayHeight)
		  and (getattr(self,"canvasPixelRatio",None)    == pixelRatio)):
			return False

		self.canvas.width  = backingWidth
		self.canvas.height = backingHeight
		self.ctx.setTransform(pixelRatio,0,0,pixelRatio,0,0)

		self.canvasDisplayWidth  = displayWidth
		self.canvasDisplayHeight = displayHeight
		self.canvasPixelRatio    = pixelRatio
		return True


	def on_window_resize(self,_event=None) -> None:
		""" schedule one board resize for the next animation frame """

		# browsers can generate many resize events while a window is being
		# dragged; waiting for the next animation frame lets the CSS layout
		# settle and coalesces the burst into one canvas update.

		if (self.resizeFrameId is not None):
			return
		self.resizeFrameId = window.requestAnimationFrame(self.callbacks["resizeFrame"])


	def on_resize_frame(self,_timestamp=None) -> None:
		""" resize and redraw the board after CSS layout has settled """

		self.resizeFrameId = None

		if (not self.resize_canvas_to_display()):
			return

		self.geometry.resize(self.canvasDisplayWidth,self.canvasDisplayHeight)
		if (self.screenGrabbing):
			self.publish_cell_centers()
		self.inspectionCellIx = None
		self.redraw()


	def on_about(self,
		_event=None
		) -> None:
		self.aboutOverlay.classList.remove("hidden")
		self.aboutCloseButton.focus()


	def on_about_close(self,
		event=None
		) -> None:
		if ((event is not None)
		 and (event.target != self.aboutOverlay)
		 and (event.target != self.aboutCloseButton)):
			return

		self.aboutOverlay.classList.add("hidden")


	def on_about_keydown(self,
		event
		) -> None:
		""" allow escape-key to close the About box """

		if (event.key != "Escape"):
			return

		event.preventDefault()
		self.on_about_close()


	def on_document_keydown(self,
		event
		) -> None:
		""" handle application shortcuts outside editable controls """

		if (not self.aboutOverlay.classList.contains("hidden")):
			return

		if (event.repeat):
			return

		if (event.ctrlKey) or (event.metaKey) or (event.altKey):
			return

		target = event.target
		tagName = str(target.tagName).lower() if (target.tagName is not None) else ""
		isEditable = ((tagName in ["input","select","textarea"])
		           or bool(target.isContentEditable))
		if (isEditable):
			return

		key = str(event.key).lower()

		# space normally activates a focused button; do not also interpret that
		# same keypress as the Next Puzzle shortcut.

		if (tagName == "button") and (key == " "):
			return

		# handle any recognized shortcut key

		if (key == "escape"):  # ESC ⇒ get out of inspect or cheat mode
			if (self.inputMode == InputMode.normal):
				return
			self.set_input_mode(InputMode.normal)
			self.tell_user(self.simpleHelpText)
		elif (key == "#"):  # waffle/pound ⇒ toggle numeric mode on and off
			self.renderer.toggle_numeric_mode()
			self.redraw()
			if (self.renderer.isNumericMode):
				self.tell_user("Switching into numeric display.")
			else:
				self.tell_user("Switching out of numeric display.")
		elif (key == "="):  # equals sign ⇒ toggle cell identifiers
			self.renderer.toggle_cell_ids()
			self.redraw()
			if (self.renderer.showCellIds):
				self.tell_user("Showing cell identifiers.")
			else:
				self.tell_user("Hiding cell identifiers.")
		elif (key == "@"):  # at sign ⇒ toggle light/dark theme
			if (self.should_use_dark_mode()):
				self.settings.theme = "light"
				self.redraw()
				self.tell_user("Switching to light theme.")
			else:
				self.settings.theme = "dark"
				self.redraw()
				self.tell_user("Switching to dark theme.")
		elif (key == "i"):  # I ⇒ toggle inspect mode
			if (self.inputMode == InputMode.inspect):
				self.set_input_mode(InputMode.normal)
				self.tell_user(self.simpleHelpText)
			else:
				self.set_input_mode(InputMode.inspect)
				self.tell_user(
					"Entering inspection mode: click or tap a cell to preview its effect.",
					withMode=False)
		elif (key == "c"):  # C ⇒ toggle cheat mode
			if (self.inputMode != InputMode.cheat):
				self.set_input_mode(InputMode.cheat)
				self.tell_user(
					"Entering cheat mode: click or tap a cell to change only that cell.",
					withMode=False)
				# self.hide_puzzle_name()   # (we don't need to hide until she actually cheats)
			else:
				self.set_input_mode(InputMode.normal)
				self.tell_user(self.simpleHelpText)
				self.unhide_puzzle_name()
		elif (key == "r"):  # R ⇒ reset the puzzle
			self.on_reset()
		elif (key == "s"):  # S ⇒ enable show moves needed
			self.showMovesNeededCheckbox.checked = not self.showMovesNeeded
			self.on_show_moves_needed()
		elif (key == " "):  # SPACE ⇒ go to the next puzzle
			self.on_next_puzzle(event)
		elif (key == "%"):  # (unadvertised) percent ⇒ report the key value of the current puzzle
			self.tell_user(f"puzzle #{self.puzzleKey}")
		elif (key == "["):  # (unadvertised) left square bracket ⇒ report the current puzzle state in base62
			base62 = self.puzzle.to_base62(mixBits=True)
			self.tell_user(f"puzzle state {SixNeighborsApp.base62prefix}{base62}")
		else:
			return

		event.preventDefault()


	def event_to_canvas_point(self,
	    event
	    ) -> tuple[float,float]:
		""" convert a browser pointer event to canvas coordinates """

		rect = self.canvas.getBoundingClientRect()
		xScale = self.canvasDisplayWidth  / rect.width
		yScale = self.canvasDisplayHeight / rect.height
		x = (event.clientX - rect.left) * xScale
		y = (event.clientY - rect.top)  * yScale

		return (x,y)


	def on_canvas_mousemove(self,
		event
		) -> None:
		""" track the cell currently under the mouse pointer """

		if (not self.hasHover):
			return

		if (self.inputMode == InputMode.inspect):
			return

		(x,y) = self.event_to_canvas_point(event)
		cellIx = self.geometry.pick_cell(x,y)   # (nb: this may be None)
		if (cellIx == self.inspectionCellIx):
			return

		self.inspectionCellIx = cellIx
		self.redraw()


	def on_canvas_mouseleave(self,
		_event=None
		) -> None:
		""" clear the inspection indicator when the pointer leaves the board """

		# nota bene: the following defensive test isn't currently needed,
		# because on_canvas_mousemove() ignores compatibility mouse events on
		# touch devices (technically, when using compatibility mouse events),
		# so there is never any inspection state to clear
		#
		#if (not self.hasHover):
		#	return

		if (self.inspectionCellIx is None):
			return

		if (self.inputMode == InputMode.inspect):
			return

		self.inspectionCellIx = None
		self.redraw()

		# nota bene: it turns out this is not needed
		#if (self.moveAnimationProgress is not None) and (self.moveAnimationProgress >= 1.0):
		#	self.finish_move_animation()
		#	return
		#
		#self.moveAnimationFrameId = window.requestAnimationFrame(self.callbacks["moveAnimationFrame"])


	def on_canvas_click(self,
		event
		) -> None:
		""" interpret a click or tap according to the current input mode """

		isAuraClick = (event.metaKey)  # command-key on mac, windows-key on windows

		(x,y) = self.event_to_canvas_point(event)
		cellIx = self.geometry.pick_cell(x,y)
		if (cellIx is None):
			if (isAuraClick):
				self.renderer.clear_cell_auras()
				self.redraw()
			return

		if (self.inputMode == InputMode.inspect):
			self.inspectionCellIx = cellIx
			self.tell_user(f"Inspecting the effect of cell {self.geometry.cellInfos[cellIx].cellId}.")
			self.redraw()
			return

		if (isAuraClick):
			self.renderer.toggle_cell_aura(cellIx)
			self.redraw()
			return

		# the modifier-key shortcut is available for desktop users
		# (in the original java implementation, this was control-alt-click,
		# but some browsers intercept that)
		isCheat = ((self.inputMode == InputMode.cheat)
		        or (event.shiftKey and event.altKey))

		if   (isCheat):        step =  1
		elif (event.shiftKey): step = -1
		else:                  step =  1

		self.finish_move_animation()

		if (isCheat):
			affectedCells = (cellIx,)
			oldCellValues = {cellIx:self.puzzle.cellValues[cellIx]}
			self.puzzle.apply_cheat(cellIx,step)
			self.has_cheated()
			self.tell_user(f"Cell {self.geometry.cellInfos[cellIx].cellId}: cheat; one cell changed.")
		else:
			affectedCells = self.geometry.neighborTable[cellIx]
			oldCellValues = {ngbrIx:self.puzzle.cellValues[ngbrIx] for ngbrIx in affectedCells}
			self.puzzle.apply_move(cellIx,step)
			direction = "reverse move" if (step < 0) else "move"
			neighborCount = len(affectedCells)
			self.tell_user(f"Cell {self.geometry.cellInfos[cellIx].cellId}: {direction}; {neighborCount} neighbors changed.")

		self.check_solved()
		self.start_move_animation(oldCellValues)


	def start_move_animation(self,
		oldCellValues:dict[int,int]
		) -> None:
		""" animate affected cells from their previous colors to their new colors """

		self.finish_move_animation()

		# if the user has set her OS preference to 'reduced motion', honor
		# that; since the puzzle state has already changed, skipping the
		# animation only changes the presentation
		if (window.matchMedia("(prefers-reduced-motion: reduce)").matches):
			self.redraw()
			return

		self.moveAnimationOldValues = oldCellValues
		self.moveAnimationStart     = None
		self.moveAnimationProgress  = 0.0
		self.moveAnimationFrameId   = window.requestAnimationFrame(self.callbacks["moveAnimationFrame"])

		#document.body.dataset.moveAnimating = "true"


	def finish_move_animation(self) -> None:
		""" finish any active move animation at its final board state """

		if (self.moveAnimationFrameId is not None):
			window.cancelAnimationFrame(self.moveAnimationFrameId)

		self.moveAnimationFrameId  = None
		self.moveAnimationStart    = None
		self.moveAnimationOldValues = None
		self.moveAnimationProgress  = None

		#document.body.dataset.moveAnimating = "false"


	def on_move_animation_frame(self,
		timestamp:float
		) -> None:
		""" draw one frame of the current move animation """

		if (self.moveAnimationStart is None):
			self.moveAnimationStart = timestamp

		elapsed = timestamp - self.moveAnimationStart
		progress = min(1.0,elapsed/self.moveAnimationDurationMs)

		# apply a smooth easing function to the animation progress, to make the
		# short transition easier to perceive without making the puzzle feel
		# sluggish
		#
		# the cubic 3x²-2x³ (known as "smoothstep") maps [0,1] onto itself
		# while preserving both endpoints; it's symmetric about the midpoint,
		# with zero slope at both ends; thus it starts with a smooth
		# acceleration and finishes with a smooth deceleration, avoiding an
		# abrupt start or stop

		self.moveAnimationProgress = progress * progress * (3.0-(2.0*progress))

		self.redraw()

		if (progress >= 1.0):
			self.finish_move_animation()
			return

		self.moveAnimationFrameId = window.requestAnimationFrame(self.callbacks["moveAnimationFrame"])


	def set_input_mode(self,
		inputMode:InputMode
		) -> None:
		""" specify how subsequent cell presses should be interpreted """

		self.inputMode = inputMode
		self.inspectionCellIx = None
		self.update_input_mode_checkboxes()
		self.redraw()


	def exit_special_input_modes(self) -> None:
		""" make subsequent cell presses be normal puzzle operation """

		if (self.inputMode == InputMode.normal):
			return

		self.inputMode = InputMode.normal
		self.inspectionCellIx = None
		self.update_input_mode_checkboxes()


	def on_inspect(self,_event=None) -> None:
		""" select or clear inspection mode """

		if (self.inspectCheckbox.disabled):
			return

		if (self.inspectCheckbox.checked):
			self.set_input_mode(InputMode.inspect)
			self.tell_user("Inspection mode: click or tap a cell to preview its effect.")
		else:
			self.set_input_mode(InputMode.normal)
			self.tell_user(self.simpleHelpText)


	def on_cheat(self,_event=None) -> None:
		""" select or clear cheat mode """

		if (self.cheatCheckbox.disabled):
			return

		if (self.cheatCheckbox.checked):
			self.set_input_mode(InputMode.cheat)
			self.tell_user("Cheat mode: click or tap a cell to change only that cell.")
		else:
			self.set_input_mode(InputMode.normal)
			self.tell_user(self.simpleHelpText)


	def has_cheated(self) -> None:
		self.hasCheated = True
		self.hide_puzzle_name()


	def update_input_mode_checkboxes(self) -> None:
		""" synchronize the checkboxes with the current input mode """

		self.inspectCheckbox.checked = (self.inputMode == InputMode.inspect)
		self.cheatCheckbox.checked   = (self.inputMode == InputMode.cheat)


	def on_clear(self,_event=None) -> None:
		self.finish_move_animation()
		self.exit_special_input_modes()
		self.puzzle.clear_board()
		self.hide_puzzle_name()
		self.solvedAlready = True
		self.tell_user("Board cleared.")
		self.redraw()


	def on_reset(self,_event=None) -> None:
		""" reset the current generated puzzle to its starting state """

		self.finish_move_animation()
		self.exit_special_input_modes()
		self.unhide_puzzle_name()
		self.puzzle.reset_puzzle()
		self.solvedAlready = False
		self.hasCheated = False
		self.hasUsedMovesNeeded = self.showMovesNeeded
		self.tell_user("Puzzle reset.")
		self.redraw()


	def on_undo(self,_event=None) -> None:
		""" undo the most recent move """

		if (len(self.puzzle.moveStack) == 0):
			return

		self.finish_move_animation()
		moveRecord = self.puzzle.moveStack[-1]
		if (moveRecord.isCheat): affectedCells = (moveRecord.cellIx,)
		else:                    affectedCells = self.geometry.neighborTable[moveRecord.cellIx]
		oldCellValues = {cellIx:self.puzzle.cellValues[cellIx] for cellIx in affectedCells}

		moveRecord = self.puzzle.undo_move()
		self.tell_user(f"Undid move on cell {self.geometry.cellInfos[moveRecord.cellIx].cellId}.")
		self.check_solved()
		self.start_move_animation(oldCellValues)


	def on_redo(self,_event=None) -> None:
		""" redo the most recently undone move """

		if (len(self.puzzle.redoStack) == 0):
			return

		self.finish_move_animation()
		moveRecord = self.puzzle.redoStack[-1]
		if (moveRecord.isCheat): affectedCells = (moveRecord.cellIx,)
		else:                    affectedCells = self.geometry.neighborTable[moveRecord.cellIx]
		oldCellValues = {cellIx:self.puzzle.cellValues[cellIx] for cellIx in affectedCells}

		moveRecord = self.puzzle.redo_move()
		self.tell_user(f"Redid move on cell {self.geometry.cellInfos[moveRecord.cellIx].cellId}.")
		self.check_solved()
		self.start_move_animation(oldCellValues)


	def puzzle_group(self) -> int:
		""" return the selected puzzle group, a value from 1 to 4 """

		return 1+self.puzzleGroupField.selectedIndex


	def on_puzzle_group_change(self,_event=None) -> None:
		""" handle a committed change to the puzzle group field """

		self.settings.puzzleGroup = self.puzzle_group()
		self.save_preferences()
		self.scramble_puzzle()


	def minimum_moves(self) -> int:
		""" return the requested minimum puzzle move count """

		movesText = self.minimumMovesField.value.strip()
		if (movesText == ""):
			return 1

		try:               movesToMake = int(movesText)
		except ValueError: movesToMake = 1

		if (movesToMake < 1): movesToMake = 1
		return movesToMake


	def on_puzzle_name_keydown(self,
		event
		) -> None:
		""" handle return/enter in the puzzle name field """

		if (event.key != "Enter"):
			return

		event.preventDefault()

		isSpecial = self.fetch_puzzle_name()
		if (isSpecial):
			self.redraw()
			return

		self.puzzleNameField.blur()
		self.scramble_puzzle()


	def on_puzzle_name_change(self,_event=None) -> None:
		""" handle a committed change to the puzzle name field """

		isSpecial = self.fetch_puzzle_name()
		if (isSpecial):
			self.redraw()
			return

		self.scramble_puzzle()


	def fetch_puzzle_name(self) -> bool:
		""" fetch the puzzle name field and check for special cases """

		puzzleName = self.puzzleNameField.value
		if (puzzleName.startswith(SixNeighborsApp.base62prefix)):
			try:
				self.puzzle.from_base62(puzzleName[len(SixNeighborsApp.base62prefix):],mixBits=True)
				self.puzzleNameField.blur()
				return True  # (name is a special case)
			except ValueError:
				pass

		self.puzzleName = puzzleName
		self.save_puzzle_name_key()
		return False  # (name is not a special case)


	def save_puzzle_name_key(self) -> None:
		""" convert the puzzle name field to a key and save it """

		puzzleName = self.puzzleNameField.value.lower().strip()
		if (puzzleName == ""):
			self.puzzleKey = self.rng.randrange(keyMax)
			self.puzzleNameField.value = key_to_keystring((self.puzzleKey-self.keyOffset) % keyMax)
		else:
			self.puzzleNameField.value = puzzleName
			self.puzzleKey = (keystring_to_key(puzzleName) + self.keyOffset) % keyMax


	def hide_puzzle_name(self) -> None:
		if (self.puzzleNameHidden): return
		self.hiddenPuzzleName         = self.puzzleNameField.value
		self.puzzleNameField.value    = "••••••"
		self.hiddenMinimumMoves       = self.minimumMovesField.value
		self.minimumMovesField.disabled = True
		self.puzzleNameHidden = True


	def unhide_puzzle_name(self,
		restoreName:bool = True,
		restoreMinimumMoves:bool = True
		) -> None:
		if (self.puzzleNameHidden):
			if (restoreName):
				self.puzzleNameField.value      = self.hiddenPuzzleName
			if (restoreMinimumMoves):
				self.minimumMovesField.value    = self.hiddenMinimumMoves
				self.minimumMovesField.disabled = False
		self.puzzleNameHidden = False


	def determine_solution(self) ->None:
		(numSolutionMoves,_) = self.puzzle.compute_solution()
		if (self.minimumMovesField.disabled):
			self.minimumMovesField.value = str(numSolutionMoves)


	def scramble_puzzle(self,
		statusText:str | None = None
		) -> None:
		""" generate a new puzzle from the current control settings """

		self.finish_move_animation()
		self.exit_special_input_modes()
		self.unhide_puzzle_name(restoreName=False)
		puzzleGroup = self.puzzle_group()
		movesToMake = self.minimum_moves()
		(cellValues,movesToMake) = self.generator.generate(self.puzzleKey,puzzleGroup,movesToMake)
		self.puzzle.load_board(cellValues)
		self.solvedAlready = False
		self.hasUsedMovesNeeded = self.showMovesNeeded
		self.hasCheated = False
		self.minimumMovesField.value = str(movesToMake)

		if (self.showMovesNeeded):
			self.determine_solution()

		if (statusText is None):
			puzzleName = self.puzzleNameField.value
			puzzleGroupName = self.puzzleGroupNames[puzzleGroup-1]
			statusText = f"Puzzle {puzzleName} in {puzzleGroupName} group, {movesToMake} moves."

		self.tell_user(statusText)
		self.redraw()


	def on_minimum_moves_keydown(self,
		event
		) -> None:
		""" handle return/enter in the puzzle moves field """

		if (event.key != "Enter"):
			return

		event.preventDefault()
		self.sanitize_minimum_moves()
		self.minimumMovesField.blur()
		self.settings.minimumMoves = self.minimum_moves()
		self.save_preferences()
		self.scramble_puzzle()


	def on_minimum_moves_change(self,_event=None) -> None:
		""" handle a committed change to the puzzle moves field """

		self.sanitize_minimum_moves()
		self.minimumMovesField.blur()
		self.settings.minimumMoves = self.minimum_moves()
		self.save_preferences()
		self.scramble_puzzle()


	def on_minimum_moves_input(self,_event=None) -> None:
		""" keep the puzzle moves field numeric while editing """

		self.sanitize_minimum_moves()


	def sanitize_minimum_moves(self) -> None:
		""" remove non-digits from the puzzle moves field """

		oldText = self.minimumMovesField.value
		newText = "".join([c for c in oldText if (c.isdigit())])

		if (newText != oldText):
			self.minimumMovesField.value = newText


	def on_next_puzzle(self,event=None) -> None:
		""" advance to the next puzzle key and generate that puzzle """

		if (event is not None) and (event.shiftKey):
			# (undocumented feature; reverse the key sequence)
			self.puzzleKey = previous_key(self.puzzleKey)
		else:
			self.puzzleKey = next_key(self.puzzleKey)
		self.puzzleName = key_to_keystring((self.puzzleKey-self.keyOffset) % keyMax)
		self.puzzleNameField.value = self.puzzleName
		self.scramble_puzzle()


	def on_show_moves_used(self,_event=None) -> None:
		""" toggle display of pips showing moves used """

		self.showMovesUsed = bool(self.showMovesUsedCheckbox.checked)

		self.settings.showMovesUsed = self.showMovesUsed
		self.save_preferences()
		self.update_show_moves_used_checkbox()

		if (self.showMovesUsed):
			self.determine_solution()
			self.tell_user("Showing moves used, as square pips.")
		else:
			self.tell_user("Hiding moves used.")

		self.redraw()


	def update_show_moves_used_checkbox(self) -> None:
		""" synchronize the show moves used checkbox with the current setting """

		self.showMovesUsedCheckbox.checked = self.showMovesUsed


	def on_show_moves_needed(self,_event=None) -> None:
		""" toggle display of pips showing moves needed to solve the puzzle """

		self.showMovesNeeded = bool(self.showMovesNeededCheckbox.checked)

		self.settings.showMovesNeeded = self.showMovesNeeded
		self.save_preferences()
		self.update_show_moves_needed_checkbox()

		if (self.showMovesNeeded):
			self.hasUsedMovesNeeded = True
			self.determine_solution()
			self.tell_user("Showing moves needed, as pips.")
		else:
			self.tell_user("Hiding moves needed.")

		self.redraw()


	def update_show_moves_needed_checkbox(self) -> None:
		""" synchronize the show moves needed checkbox with the current setting """

		self.showMovesNeededCheckbox.checked = self.showMovesNeeded

	def check_solved(self) -> None:
		""" report whether the current puzzle has just been solved """

		if (self.solvedAlready) or (not self.puzzle.is_solved()):
			return

		self.solvedAlready = True
		if (self.hasCheated):
			self.tell_user("Solved, but you did cheat, right?")
		elif (self.hasUsedMovesNeeded):
			self.tell_user("Solved, but you saw what moves were needed.")
		else:
			self.tell_user("Kudos! You solved the puzzle.")


	def tell_user(self,
		message: str,
		withMode: bool = True
		):
		prefix = None
		if (withMode):
			if (self.inputMode == InputMode.inspect):
				prefix = "(inspection mode is on; ESC to clear)"
			elif (self.inputMode == InputMode.cheat):
				prefix = "(cheating mode is on; ESC to clear)"
		if (prefix != None):
			message = prefix + "\n" + message
		self.status.textContent = message


	def redraw(self) -> None:
		""" redraw the board and update controls """

		if (self.showMovesNeeded):
			self.determine_solution()

		if (self.should_use_dark_mode()): self.renderer.be_dark_mode()
		else:                             self.renderer.be_light_mode()

		stampMessage = ["bumblebeagle.org/sixneighbors"]
		if (self.screenGrabbing):
			stampMessage = [f"{self.puzzleName}",
			                self.puzzleGroupNames[self.puzzle_group()-1],
			                str(self.minimum_moves())] \
			             + stampMessage

		self.renderer.draw_board(self.ctx,self.showMovesUsed,self.showMovesNeeded,self.inspectionCellIx,
		                         self.moveAnimationOldValues,self.moveAnimationProgress,
		                         stampMessage="\n".join(stampMessage))
		movesTaken = self.puzzle.move_count()
		self.movesTakenField.textContent = f"Moves Taken: {movesTaken}"
		self.undoButton.disabled = (movesTaken == 0)
		self.redoButton.disabled = (len(self.puzzle.redoStack) == 0)

		self.redrawCount += 1
		document.body.dataset.redrawCount = str(self.redrawCount)


	def fetch_text(self,filename):
		with open(filename,encoding="utf-8") as f:
			text = f.read()
		return self.unwrap_text(text.strip())


	def unwrap_text(self,text):
		paragraphs = text.split("\n\n")
		paragraphs = [" ".join(p.split()) for p in paragraphs]
		return "\n\n".join(paragraphs)


	def publish_cell_centers(self) -> None:
		""" make cell centers available to browser automation """

		cellCenters = { str(cellInfo.cellId) : (cellInfo.x,cellInfo.y)
		                for cellInfo in self.geometry.cellInfos }
		self.canvas.dataset.cellCenters = json.dumps(cellCenters)


	def has_permission(self) -> bool:
		permittedDomain = "bumblebeagle.org"
		host = str(window.location.hostname).lower()

		# also consider adding these hosts: "127.0.0.1","::1"

		if (   (host == permittedDomain)
			or (host.endswith("." + permittedDomain))
			or (host in ("localhost",))    # (consider adding other hosts here)
			):
			return True

		window.document.querySelector(".app").remove()
		window.alert(
			"This application is distributed only from bumblebeagle.org."
			"If you reached this page elsewhere, please use the official site instead."
		)

		raise SystemExit()
		return False


# create the app instance

app = SixNeighborsApp()
