#!/usr/bin/env bash
# The input map, and the stick latch -- asserted against Godot, not reasoned about.
#
#   tools/port/verify-input
#   tools/port/verify-input --control    # each check fails when its subject is removed
#
# 🔴 WHY THIS EXISTS. A human played the port on a real controller and Ⓐ did
# nothing. Skipping the intro did nothing; opening a submenu did nothing. The
# unattended P5 walk had passed on every iteration while this was true, and the
# reason is exact:
#
#   `--script` sends `InputEventAction`, which BYPASSES the input map.
#
# So the harness asserted every line of code *after* the input map and nothing
# about the map itself -- and the map was missing half the actions. Godot 4.7.2
# binds NO joypad button to `ui_accept` or `ui_cancel`, while it binds the d-pad
# AND the left stick to `ui_up`/`ui_down`. Four actions worked on the pad, two
# did not, which reads as a broken controller.
#
# The second defect had the same blind spot: `InputEventAction` is not an analog
# axis, so the harness could not have seen that a held stick fires once per
# jitter. The human's words were "moves the cursor too fast".
#
# ⚠️ THE GENERAL LESSON, worth more than either fix: **a synthetic-input test
# cannot assert the input map.** Anything injected below the map is evidence
# about the code above it only.
#
# ## The control, and what it can and cannot cover
#
# 🔴 The first version of `--control` inverted ALL NINE assertions and demanded
# every one fail with the fixup skipped. Seven of them do not depend on the
# fixup, so it reported them as broken -- a control that fails a correct check
# is the same defect as one that passes a dead check, and this file would have
# shipped claiming its checks were untrustworthy. Each check now names its
# SUBJECT, and the control removes exactly that subject:
#
#   bind   -- skip `Gamepad.bind_missing()`; the check must fail
#   latch  -- run the same events through no latch at all; the count must differ
#   godot  -- NOT CONTROLLABLE HERE, and said so rather than faked. These assert
#             what Godot itself binds. There is nothing of ours to remove; they
#             exist to make a future Godot dropping the d-pad a failing check
#             instead of a bug report.
set -euo pipefail
cd "${PROJECT_DIR:-$(git rev-parse --show-toplevel)}"
GODOT="${GODOT:-godot}"
mode="assert"
[ "${1:-}" = "--control" ] && mode="control"

probe="port/.verify-input-probe.gd"
trap 'rm -f "$probe" "${probe}.uid"' EXIT INT TERM

cat > "$probe" <<'GD'
extends SceneTree

var mode := OS.get_environment("VERIFY_INPUT_MODE")
var fail := 0
var ran := 0

## `subject` is what the check depends on, and decides whether the control
## removes it. A check whose subject cannot be removed is skipped there and
## counted, not silently dropped -- a control that quietly tests four of nine
## things reports the same green line as one that tests all nine.
func ok(name: String, subject: String, cond: bool, detail: String = "", control_row: String = "the stick row (6 -> 1)") -> void:
	if mode == "control" and subject == "godot":
		print("  %-44s -- not controllable (Godot's own binding)" % name)
		return
	if mode == "control" and subject == "negative":
		# 🔴 R4: a NEGATIVE carries a positive control, it does not carry an
		# inversion. "The latch must not touch buttons" cannot be controlled by
		# removing the latch -- with no latch, buttons pass, which is the same
		# answer. What shows the method has power is that the SAME counter, on
		# the same code path, reduces 6 stick events to 1. That row is the
		# positive control for this one, and naming it is the honest move;
		# inverting it would have been a green line that meant nothing.
		# 🔴 The control row was HARDCODED here and a second negative arrived.
		# A negative that names someone else's control is not controlled; it is
		# borrowing a green line. `control_row` now defaults to the original
		# text so that row is unchanged, and any new negative must say what
		# actually backs it.
		print("  %-44s -- negative; positive control is %s" % [name, control_row])
		return
	ran += 1
	var want: bool = cond if mode != "control" else not cond
	print("  %-44s %s%s" % [name, "ok" if want else "🔴 FAILED",
		("  " + detail) if detail != "" else ""])
	if not want:
		fail = 1

func has_button(action: String, button: int) -> bool:
	for e in InputMap.action_get_events(action):
		if e is InputEventJoypadButton and e.button_index == button:
			return true
	return false

## Feed a run of axis values through a latch (or through none) and count the
## presses it would produce.
func steps(values: Array, latched: bool) -> int:
	var pad := Gamepad.new()
	var n := 0
	for v: float in values:
		var e := InputEventJoypadMotion.new()
		e.axis = JOY_AXIS_LEFT_Y
		e.axis_value = v
		# No latch = what the port did before: every event above the action
		# deadzone is a press. That is the bug, reproduced, as the control.
		if pad.accepts(e) if latched else absf(v) >= Gamepad.ENTER:
			n += 1
	return n

## The latch as the port actually uses it -- and REMOVED under `--control`, so
## the rows that depend on it invert.
func nav(values: Array) -> int:
	return steps(values, mode != "control")

func _init() -> void:
	# The control removes the repair. Everything else runs with it applied.
	if mode != "control":
		Gamepad.bind_missing()

	# ── 1. subject `bind` -- the two actions Godot leaves unbound ─────────────
	ok("Ⓐ reaches ui_accept", "bind", has_button("ui_accept", JOY_BUTTON_A),
		"JOY_BUTTON_A")
	ok("Ⓑ reaches ui_cancel", "bind", has_button("ui_cancel", JOY_BUTTON_B),
		"JOY_BUTTON_B")

	# ── 2. subject `godot` -- what the engine binds, and must keep binding ────
	#
	# The keyboard events must SURVIVE the fixup: declaring `ui_accept` in
	# project.godot would have replaced the built-in wholesale and dropped them
	# silently. Adding to the action must not.
	var keys := 0
	for e in InputMap.action_get_events("ui_accept"):
		if e is InputEventKey:
			keys += 1
	ok("ui_accept keeps its keyboard events", "godot", keys >= 2,
		"%d key event(s)" % keys)
	ok("d-pad reaches ui_down", "godot", has_button("ui_down", JOY_BUTTON_DPAD_DOWN))
	var axis := false
	for e in InputMap.action_get_events("ui_down"):
		if e is InputEventJoypadMotion and e.axis == JOY_AXIS_LEFT_Y:
			axis = true
	ok("left stick reaches ui_down", "godot", axis, "axis %d" % JOY_AXIS_LEFT_Y)

	# ── 3. subject `latch` -- one step per deflection, not one per jitter ─────
	#
	# A push to full deflection followed by jitter that never returns to
	# neutral: what a real stick emits, and what produced "moves the cursor too
	# fast". The control runs the identical values with no latch and must count
	# every one of them, which is what makes this a discriminator rather than a
	# number that happens to be 1.
	var held := [0.92, 0.95, 0.91, 0.99, 0.93, 0.97]
	# `nav()` is the latch under control: in `--control` the latch is REMOVED,
	# which is what makes these rows invert. Reading `steps(..., true)` in both
	# modes was the earlier defect -- the control ran the repaired code and then
	# demanded it fail.
	ok("a held stick is ONE step, not six", "latch", nav(held) == 1,
		"latched %d, unlatched %d" % [steps(held, true), steps(held, false)])

	# Release, then push again: that IS a second press, or the stick becomes
	# single-use.
	ok("release then push is a second step", "latch",
		nav([0.92, 0.95, 0.10, 0.88]) == 2,
		"%d step(s)" % nav([0.92, 0.95, 0.10, 0.88]))

	# Hysteresis: drifting back only as far as the release threshold must not
	# re-arm, or a stick resting near the boundary chatters -- the original bug
	# with a smaller number.
	ok("boundary drift does not re-arm", "latch",
		nav([0.9, 0.45, 0.9, 0.45, 0.9]) == 1,
		"%d step(s)" % nav([0.9, 0.45, 0.9, 0.45, 0.9]))

	# ✅ THE GAME'''S OWN THRESHOLD, ASSERTED AT THE DEVICE LEVEL. The game
	# digitises the stick to four direction bits at 61 % deflection, so a
	# deflection between Godot'''s 0.50 action deadzone and that 0.61 is a
	# direction the real game never sees. At the old ENTER = 0.5 this port
	# stepped there. Negative first, then the positive control on the SAME run
	# shape -- a negative alone would also pass if the latch were simply broken.
	# 🔴 THIS ROW WAS "latch" AND THE CONTROL CAUGHT IT IMMEDIATELY. Removing
	# the latch does not remove the THRESHOLD -- the unlatched path also tests
	# `>= Gamepad.ENTER`, so 0.55 counts 0 either way and the row could never
	# invert. The harness said so in one run: "a check did not invert -- it is
	# not testing what it claims to test". It is a negative, and its positive
	# control is the row below it: the same shape at 0.70 does step.
	ok("0.55 is below the game 61 % threshold, must not step", "negative",
		nav([0.55, 0.55, 0.55]) == 0,
		"%d step(s)" % nav([0.55, 0.55, 0.55]),
		"the 0.70 row on the same shape")
	ok("...and its control: 0.70 on the same shape DOES step", "latch",
		nav([0.70, 0.70, 0.70]) == 1,
		"%d step(s)" % nav([0.70, 0.70, 0.70]))

	# A button already IS an edge; latching it would swallow the second of two
	# quick taps.
	var pad := Gamepad.new()
	var passed := 0
	for i in 3:
		var b := InputEventJoypadButton.new()
		b.button_index = JOY_BUTTON_DPAD_DOWN
		b.pressed = true
		if pad.accepts(b):
			passed += 1
	ok("d-pad presses are not latched", "negative", passed == 3, "%d of 3" % passed)

	if ran == 0:
		print("🔴 no check ran -- the harness asserted nothing")
		quit(2)
	quit(fail)
GD

out=$(VERIFY_INPUT_MODE="$mode" "$GODOT" --headless --path port \
      --script "res://$(basename "$probe")" 2>&1 \
      | grep -v "^Godot Engine\|^$" || true)
rc=0
printf '%s' "$out" | grep -q "🔴" && rc=1

if [ "$mode" = "control" ]; then
  echo "control -- each check must fail when ITS OWN subject is removed:"
  printf '%s\n' "$out"
  echo
  if [ $rc -eq 0 ]; then
    echo "every controllable check fails without its subject -- the control holds"
    exit 0
  fi
  echo "🔴 a check did not invert -- it is not testing what it claims to test"
  exit 1
fi

echo "input map and stick latch:"
printf '%s\n' "$out"
echo
if [ $rc -eq 0 ]; then
  echo "Ⓐ and Ⓑ reach the game, and a held stick is one step"
  exit 0
fi
echo "🔴 the input map is not what the port needs"
exit 1
