# What a unit test cannot check by reading the source: that every command really
# is on a key, that rebinding one works and survives a restart, that a key two
# actions want is swapped rather than stolen, that opening a screen puts the
# keyboard somewhere, and that the text the game paints on its own colours clears
# 4.5:1.
#
# `godot --headless res://tests/a11y_test.tscn`
extends Node

const MENU := preload("res://scenes/ui/menu.tscn")
const OPTIONS := preload("res://scenes/ui/options.tscn")
const CONTROLS := preload("res://scenes/ui/controls.tscn")
const END_SCREEN := preload("res://scenes/ui/end_screen.tscn")
const ERROR_SCREEN := preload("res://scenes/ui/error_screen.tscn")
const VICTORY := preload("res://scenes/ui/victory.tscn")
const PHONE := preload("res://scenes/ui/phone.gd")
const OBSERVATION := preload("res://scenes/ui/observation.gd")
const ISO_CAMERA := preload("res://scenes/camera/iso_camera.tscn")

var _fails: Array[String] = []
# The player's own settings file, put back exactly as it was: a test that leaves
# someone's keys rebound has broken the thing it was checking.
var _saved_settings := ""
var _had_settings := false

func _ready() -> void:
	_keep_settings()
	await _check_every_command_has_a_key()
	_check_rebinding()
	_check_swap()
	_check_reserved_keys()
	_check_persistence()
	await _check_focus()
	_check_labels()
	_check_contrast()
	_check_glyphs()
	await _check_the_exit_sign_fits(get_tree())
	_check_reduced_motion()
	_restore_settings()
	_report()

# --- Keys ------------------------------------------------------------------

func _check_every_command_has_a_key() -> void:
	# The panel is asked what it calls each command, rather than a table being
	# trusted to agree with it: an unnamed row is a key nobody can identify.
	var panel: Control = CONTROLS.instantiate()
	add_child(panel)
	await get_tree().process_frame
	for action: String in Settings.BINDINGS:
		_check(InputMap.has_action(action), "no such action: %s" % action)
		if not InputMap.has_action(action):
			continue
		_check(not InputMap.action_get_events(action).is_empty(),
			"%s has no event bound — the command would be unreachable" % action)
		_check(Settings.key_of(action) != KEY_NONE,
			"%s has no key to print in the Controls panel" % action)
		var named: String = panel.row_name(action)
		_check(named != action and not named.strip_edges().is_empty(),
			"%s is listed in the Controls panel without a name" % action)
	panel.queue_free()
	await get_tree().process_frame

func _check_rebinding() -> void:
	Settings.reset_keybinds()
	var before := Settings.key_of("judge_fake")
	_check(Settings.rebind("judge_fake", KEY_Z), "rebinding judge_fake to Z was refused")
	_check(Settings.key_of("judge_fake") == KEY_Z, "judge_fake did not take Z")
	# The new key answers and the old one does not.
	_check(_action_answers_to("judge_fake", KEY_Z), "Z does not fire judge_fake")
	_check(not _action_answers_to("judge_fake", before), "the old key still fires judge_fake")

# The swap: taking a key another action holds hands that action the key just
# freed, rather than leaving it with nothing to press.
func _check_swap() -> void:
	Settings.reset_keybinds()
	var fake_key := Settings.key_of("judge_fake")
	var original_key := Settings.key_of("judge_original")
	Settings.rebind("judge_original", fake_key)
	_check(Settings.key_of("judge_original") == fake_key, "judge_original did not take the key")
	_check(Settings.key_of("judge_fake") == original_key,
		"judge_fake was left with %s rather than the freed key" % Settings.key_name("judge_fake"))
	_check(Settings.key_of("judge_fake") != KEY_NONE, "the swap left judge_fake unreachable")

func _check_reserved_keys() -> void:
	Settings.reset_keybinds()
	for key: int in [KEY_ESCAPE, KEY_TAB, KEY_F5, KEY_F11]:
		_check(not Settings.rebind("mute", key),
			"%s was taken from the browser/window" % OS.get_keycode_string(key))
	_check(Settings.key_of("mute") == KEY_M, "a refused rebind changed the binding anyway")

# Rebound, saved, and still rebound after the file is read back — the settings
# file is the only thing that carries a choice across a launch.
func _check_persistence() -> void:
	Settings.reset_keybinds()
	Settings.rebind("clipboard", KEY_K)
	Settings.keybinds = {}
	Settings.apply_keybinds()
	Settings.load_settings()
	_check(Settings.key_of("clipboard") == KEY_K,
		"the rebind did not survive a reload (got %s)" % Settings.key_name("clipboard"))
	_check(_action_answers_to("clipboard", KEY_K), "the reloaded key does not fire the action")
	Settings.reset_keybinds()

func _action_answers_to(action: String, keycode: int) -> bool:
	var event := InputEventKey.new()
	event.keycode = keycode
	event.pressed = true
	return event.is_action_pressed(action)

# --- Focus -----------------------------------------------------------------

# Every screen that comes up on its own puts the keyboard on the thing to press.
# Without this the focus sits on nothing and Tab starts from nowhere — which on a
# full-screen panel means tabbing about behind it.
func _check_focus() -> void:
	for entry: Dictionary in [
			{"scene": MENU, "name": "Menu", "open": "focus_on"},
			{"scene": OPTIONS, "name": "Options", "open": ""},
			{"scene": CONTROLS, "name": "Controls", "open": ""},
			{"scene": END_SCREEN, "name": "EndScreen", "open": ""},
			{"scene": VICTORY, "name": "Victory", "open": ""},
			{"scene": ERROR_SCREEN, "name": "ErrorScreen", "open": "failed"}]:
		var screen: Control = entry["scene"].instantiate()
		add_child(screen)
		if not str(entry["open"]).is_empty():
			screen.call(entry["open"])
		# The grabs are deferred, so give them the frame they asked for.
		await get_tree().process_frame
		await get_tree().process_frame
		var focused := get_viewport().gui_get_focus_owner()
		_check(focused != null and screen.is_ancestor_of(focused),
			"%s came up without putting the focus inside it" % entry["name"])
		screen.queue_free()
		await get_tree().process_frame

# Every button says what it does — on its own face, or in the label standing
# beside it in its row. A key button reading "W" and a language picker reading
# nothing at all are both fine, because the row says what they are for; a button
# with neither is one only the person who wrote it can use.
func _check_labels() -> void:
	for entry: Dictionary in [{"scene": MENU, "name": "Menu"},
			{"scene": OPTIONS, "name": "Options"}, {"scene": CONTROLS, "name": "Controls"},
			{"scene": END_SCREEN, "name": "EndScreen"}, {"scene": VICTORY, "name": "Victory"},
			{"scene": ERROR_SCREEN, "name": "ErrorScreen"}]:
		var screen: Control = entry["scene"].instantiate()
		add_child(screen)
		for button: Button in _buttons(screen):
			_check(_named(button), "%s/%s is unlabelled: neither its face (%s) nor its row says what it does"
				% [entry["name"], button.name, button.text])
		screen.queue_free()

func _named(button: Button) -> bool:
	if button.text.strip_edges().length() >= 2:
		return true
	var row := button.get_parent()
	if row == null:
		return false
	# The label immediately before it in the same box is the one a sighted player
	# reads as its name — the row is the label.
	for sibling in row.get_children():
		if sibling is Label and not (sibling as Label).text.strip_edges().is_empty():
			return true
	return false

func _buttons(node: Node) -> Array:
	var out: Array = []
	if node is Button:
		out.append(node)
	for child in node.get_children():
		out.append_array(_buttons(child))
	return out

# --- Contrast --------------------------------------------------------------

# Measured on what is actually painted: the colour the game sets, against the
# surface it sets it on. WCAG AA for body text is 4.5:1.
const AA := 4.5

func _check_contrast() -> void:
	var pairs := [
		{"what": "FAKE stamp on the cartel", "ink": Artwork.STAMP_RED, "on": Artwork.CARTEL_FACE},
		{"what": "the tick on the cartel", "ink": Artwork.TICK_GREEN, "on": Artwork.CARTEL_FACE},
		{"what": "the cartel's own text", "ink": Artwork.CARTEL_INK, "on": Artwork.CARTEL_FACE},
		{"what": "EXIT on its plate", "ink": Room.SIGN_INK, "on": Room.SIGN_PLATE},
		{"what": "the phone's buttons", "ink": PHONE.BUTTON_INK, "on": PHONE.BUTTON_FACE},
		{"what": "the phone's page text", "ink": PHONE.INK, "on": PHONE.PAPER},
		{"what": "the ? badge on the cartel", "ink": Artwork.HELP_INK, "on": Artwork.HELP_DISC},
	]
	for pair: Dictionary in pairs:
		var ratio := _contrast(pair["ink"], pair["on"])
		_check(ratio >= AA, "%s measures %.2f:1, under %.1f:1" % [pair["what"], ratio, AA])

# --- Marks -----------------------------------------------------------------

# A mark is only a second dimension beside the colour if it actually draws. The
# engine's own font is the one every build has — a web export has no system fonts
# at all — so every glyph the game uses to *mean* something has to be in it, or it
# is an empty box and the call is back to being told by colour alone.
func _check_glyphs() -> void:
	var font: Font = ThemeDB.get_default_theme().get_font("font", "Label")
	for mark: String in [Artwork.TICK_MARK, OBSERVATION.FAKE_MARK, OBSERVATION.ORIGINAL_MARK, "·", "—"]:
		_check(font.has_char(mark.unicode_at(0)),
			"the engine font has no %s (U+%04X) — it would draw as an empty box"
				% [mark, mark.unicode_at(0)])

func _contrast(a: Color, b: Color) -> float:
	var high := maxf(_luminance(a), _luminance(b))
	var low := minf(_luminance(a), _luminance(b))
	return (high + 0.05) / (low + 0.05)

func _luminance(c: Color) -> float:
	return 0.2126 * _linear(c.r) + 0.7152 * _linear(c.g) + 0.0722 * _linear(c.b)

func _linear(channel: float) -> float:
	return channel / 12.92 if channel <= 0.03928 else pow((channel + 0.055) / 1.055, 2.4)

# --- Signage ---------------------------------------------------------------

# EXIT fits the plate it is painted on. SORTIE, AUSGANG and ВЫХОД are longer, and
# a word painted past the edge of its green is a word on a wall. The type comes
# down to whatever fits; the English, which already fits, is left alone.
func _check_the_exit_sign_fits(tree: SceneTree) -> void:
	for word: String in ["EXIT", "SORTIE", "AUSGANG", "ВЫХОД", "USCITA DI SICUREZZA"]:
		var label := Label3D.new()
		label.text = word
		label.font_size = 44
		label.pixel_size = 0.0042
		add_child(label)
		await tree.process_frame
		await tree.process_frame
		Room.fit_to_plate(label, Room.SIGN_PLATE_SIZE, Room.SIGN_MARGIN)
		await tree.process_frame
		var painted := label.get_aabb().size.x
		var room_for := Room.SIGN_PLATE_SIZE.x - Room.SIGN_MARGIN * 2.0
		_check(painted <= room_for + 0.001,
			"%s runs %.2f across a plate with %.2f of green" % [word, painted, room_for])
		if word == "EXIT":
			_check(is_equal_approx(label.pixel_size, 0.0042),
				"the English was shrunk to fit a plate it already fitted")
		label.queue_free()

# --- Motion ----------------------------------------------------------------

# Asked to reduce motion, the camera cuts rather than travels. Checked on the rig
# itself: every move it makes on its own reads its travel time from one place.
func _check_reduced_motion() -> void:
	var was := Settings.reduced_motion
	var rig: Node3D = ISO_CAMERA.instantiate()
	add_child(rig)
	Settings.reduced_motion = false
	_check(rig._travel_time() > 0.0, "the camera does not travel with motion left alone")
	Settings.reduced_motion = true
	_check(is_zero_approx(rig._travel_time()), "the camera still travels with motion reduced")
	rig.queue_free()
	Settings.reduced_motion = was

# --- Plumbing --------------------------------------------------------------

func _keep_settings() -> void:
	_had_settings = FileAccess.file_exists(Settings.PATH)
	if _had_settings:
		_saved_settings = FileAccess.get_file_as_string(Settings.PATH)

func _restore_settings() -> void:
	if _had_settings:
		var file := FileAccess.open(Settings.PATH, FileAccess.WRITE)
		if file != null:
			file.store_string(_saved_settings)
			file.close()
	else:
		DirAccess.remove_absolute(ProjectSettings.globalize_path(Settings.PATH))
	Settings.load_settings()

func _check(ok: bool, message: String) -> void:
	if not ok:
		_fails.append(message)

func _report() -> void:
	if _fails.is_empty():
		print("A11Y OK")
	else:
		for f in _fails:
			print("A11Y FAIL: ", f)
	get_tree().quit(0 if _fails.is_empty() else 1)
