#!/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")

## Hold a direction through the REAL latch, then tick `repeat_due()` and report
## the time of every repeat it produces.
##
## The press edge is fed through `accepts()` rather than poked into the latch,
## so this exercises the same path the port does -- `held_direction()` reads
## that latch first. Under `--control` the hold is simply not made: the rate is
## a `const` and cannot be removed at runtime, so what the control removes is
## the PREMISE (a direction being held), and every count must go to zero.
func hold_and_tick(seconds: float, delta: float) -> Array[float]:
	var pad := Gamepad.new()
	if mode != "control":
		pad.accepts(deflect(0.92))
	var t := 0.0
	var out: Array[float] = []
	while t < seconds:
		t += delta
		if pad.repeat_due(delta) != 0:
			out.append(t)
	return out

## Hold one way past the delay, reverse, and report how long until the first
## repeat in the NEW direction. Inheriting the old cadence would show up here as
## a time far below `REPEAT_DELAY`.
func reversal_delay(delta: float) -> float:
	var pad := Gamepad.new()
	if mode != "control":
		pad.accepts(deflect(0.92))
	var t := 0.0
	while t < 1.0:
		t += delta
		pad.repeat_due(delta)
	pad.accepts(deflect(0.0))
	if mode != "control":
		pad.accepts(deflect(-0.92))
	t = 0.0
	while t < 2.0:
		t += delta
		if pad.repeat_due(delta) != 0:
			return t
	return -1.0

func deflect(v: float) -> InputEventJoypadMotion:
	var e := InputEventJoypadMotion.new()
	e.axis = JOY_AXIS_LEFT_Y
	e.axis_value = v
	return e

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)

	# ── 4. subject `repeat` -- a held direction repeats at the MEASURED rate ──
	#
	# 🔴 THIS SECTION EXISTS BECAUSE A PREDICTION IN `gamepad.gd` WAS WRONG.
	# That file said adopting the rate would turn "a held stick is ONE step, not
	# six" red, and warned that the row would read as a regression. Measured on
	# adoption day: it stays green, because `steps()` never advances a clock and
	# so has never called `repeat_due()` at all. The warning was reasoned, not
	# run -- and the real consequence is worse than the one predicted. The rate
	# shipped into a harness with **no coverage of the feature whatsoever**.
	#
	# The rows below are that coverage. They assert the two numbers from
	# `docs/re/f1-repeat-measured-via-driver-patch.md`, not the shape alone: a
	# test that only checked "it repeats eventually" would pass on any constant
	# and would have passed on the 0.40 / 0.20 guess this port deliberately
	# refused to ship.
	var FRAME := 1.0 / 60.0

	# Hold the stick by pushing it through the same latch the port uses, then
	# tick. `held_direction()` reads the latch, so this is the real path.
	var timeline := hold_and_tick(2.0, FRAME)
	# ⚠️ MEASURED FROM THE ARMING TICK, NOT FROM t=0, and the difference is a
	# whole frame. `repeat_due()`'s first call only latches the direction and
	# returns 0; the clock accumulates from the call after it. In the port that
	# first call happens on the frame the press is handled -- the frame that
	# produced the press-triggered step -- and the finding measures its 12
	# frames "from the press-triggered step to the first repeat". So the arming
	# tick is the press step, and subtracting it is what puts the harness and
	# the finding on the same origin. Without this the row read 0.433 vs 0.402
	# and the tolerance would have had to be widened to hide a units mismatch.
	var first: float = (timeline[0] - FRAME) if not timeline.is_empty() else -1.0

	ok("nothing repeats before the measured delay", "negative",
		first >= Gamepad.REPEAT_DELAY,
		"first repeat %.3fs after the press step, delay is %.3f"
			% [first, Gamepad.REPEAT_DELAY],
		"the steady-interval row below")
	ok("first repeat lands on the measured delay", "repeat",
		first >= 0.0 and absf(first - Gamepad.REPEAT_DELAY) <= FRAME,
		"%.3fs vs %.3f (±one frame)" % [first, Gamepad.REPEAT_DELAY])

	var gaps: Array[float] = []
	for i in range(1, timeline.size()):
		gaps.append(timeline[i] - timeline[i - 1])
	var mean := 0.0
	for g in gaps:
		mean += g
	mean = mean / gaps.size() if not gaps.is_empty() else -1.0
	ok("steady interval is the measured 0.134s", "repeat",
		not gaps.is_empty() and absf(mean - Gamepad.REPEAT_INTERVAL) <= FRAME,
		"mean %.3fs over %d gap(s) vs %.3f" % [mean, gaps.size(), Gamepad.REPEAT_INTERVAL])

	# `repeat_due()` subtracts the interval rather than resetting the clock,
	# with the stated reason "at 140 fps and at 30 fps the same number of steps
	# happen per second". That is a claim about the code, so it is asserted
	# rather than believed.
	var at30 := hold_and_tick(2.0, 1.0 / 30.0).size()
	var at240 := hold_and_tick(2.0, 1.0 / 240.0).size()
	# `at30 > 0` matters: with nothing held both counts are 0 and "they agree"
	# would be a green line for a mechanism that never ran -- the control caught
	# exactly that, so the count is asserted as well as the agreement.
	ok("the cadence does not drift with frame rate", "repeat",
		at30 > 0 and absf(at30 - at240) <= 1,
		"%d steps at 30fps, %d at 240fps" % [at30, at240])

	# A direction change must restart the delay, not inherit the old cadence --
	# otherwise flicking the other way mid-repeat steps instantly.
	ok("a direction change restarts the delay", "repeat",
		reversal_delay(FRAME) >= Gamepad.REPEAT_DELAY,
		"%.3fs after the reversal" % reversal_delay(FRAME))

	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
