Entities/Abilities/DirectionalDoubleTapGesture.cs
using System;

namespace BlockParty;

/// <summary>Recognizes press-release-press cardinal and diagonal chords, including held perpendicular modifiers.</summary>
internal sealed class DirectionalDoubleTapGesture
{
	private const float DEFAULT_TAP_RELEASE_TIME = 0.1f;
	private const float DEFAULT_SECOND_TAP_TIME = 0.07f;
	private const float DEFAULT_DIAGONAL_GRACE_TIME = 0.1f;

	private float _tapReleaseTime;
	private float _secondTapTime;
	private float _diagonalGraceTime;

	private Vector2 _tapDirection;
	private Vector2 _pendingSecondDirection;
	// First-tap direction abandoned when the second press was perpendicular-only (tap Right, press Up).
	// Kept for one grace window so the repeat key arriving a frame late (Right) still completes the
	// up-right diagonal instead of the gesture being lost.
	private Vector2 _lateFirstDirection;
	private float _lateTimer;
	private int _tapTriggerMask;
	private int _tapPhase;
	private float _tapTimer;
	private float _gapTimer;

	public DirectionalDoubleTapGesture(
		float tapReleaseTime = DEFAULT_TAP_RELEASE_TIME,
		float secondTapTime = DEFAULT_SECOND_TAP_TIME,
		float diagonalGraceTime = DEFAULT_DIAGONAL_GRACE_TIME )
	{
		Configure( tapReleaseTime, secondTapTime, diagonalGraceTime );
	}

	/// <summary>A <paramref name="diagonalGraceTime"/> of 0 means "never wait": a second tap fires on the
	/// press, using whatever keys are down at that instant. Anything above 0 holds a CARDINAL second tap
	/// for that long first, so a perpendicular key arriving a frame or two late still folds into the same
	/// chord — the difference between an 8-way gesture you can actually hit and one that needs both keys
	/// on the same frame. Costs that much input latency on every cardinal trigger, so only abilities that
	/// really want diagonals should pay it.</summary>
	public void Configure( float tapReleaseTime, float secondTapTime, float diagonalGraceTime = DEFAULT_DIAGONAL_GRACE_TIME )
	{
		_tapReleaseTime = tapReleaseTime;
		_secondTapTime = secondTapTime;
		_diagonalGraceTime = diagonalGraceTime;
	}

	public bool TryTrigger( Player player, float dt, out Vector2 direction,
		bool allowHorizontal = true, bool allowUp = true, bool allowDown = true )
	{
		IPlayerInput input = player.InputSource;
		direction = Vector2.Zero;
		int heldMask = ReadHeldMask( input, allowHorizontal, allowUp, allowDown );
		int justMask = player.ShouldIgnoreWallJumpDoubleTapPress
			? 0
			: ReadJustMask( input, allowHorizontal, allowUp, allowDown );
		Vector2 chordDirection = DirectionFromMasks( heldMask, justMask );

		if ( _tapPhase == 3 )
		{
			_gapTimer += dt;
			if ( chordDirection.LengthSquared > 0f )
			{
				Vector2 rolled = AddRolledDirection( _pendingSecondDirection, chordDirection );
				if ( IsCompatibleSecondTap( _tapDirection, rolled ) )
					_pendingSecondDirection = rolled;
			}

			if ( IsDiagonal( _pendingSecondDirection ) || _gapTimer >= _diagonalGraceTime )
			{
				direction = _pendingSecondDirection;
				Reset();
				return true;
			}
			return false;
		}

		if ( _tapPhase == 1 )
		{
			_tapTimer += dt;
			if ( _tapTimer > _tapReleaseTime )
				Reset();
		}
		else if ( _tapPhase == 2 )
		{
			_gapTimer += dt;
			if ( _gapTimer > _secondTapTime )
				Reset();
		}

		if ( _lateFirstDirection.LengthSquared > 0f )
		{
			_lateTimer += dt;
			if ( _lateTimer >= _diagonalGraceTime )
				_lateFirstDirection = Vector2.Zero;
		}

		if ( justMask != 0 && chordDirection.LengthSquared > 0f )
		{
			Vector2 abandonedFirst = Vector2.Zero;
			if ( _tapPhase == 2 && IsCompatibleSecondTap( _tapDirection, chordDirection ) )
			{
				// KNOWN: _tapDirection includes held modifiers, so hold Up, tap Right, release both, re-press Up
				// (all within ~170ms) reads as a repeat and fires Up. Deliberately unfixed: a proper fix needs a
				// parallel tapped-only direction (the trigger mask alone cancels on opposing presses).
				if ( IsRepeatOfFirstTap( _tapDirection, chordDirection ) )
				{
					_pendingSecondDirection = chordDirection;
					if ( IsDiagonal( chordDirection ) || _diagonalGraceTime <= 0f )
					{
						direction = chordDirection;
						Reset();
						return true;
					}

					_tapPhase = 3;
					_gapTimer = 0f;
					return false;
				}

				// Perpendicular-only press (tap Right, press Up) is a direction change, not a double tap:
				// restart as a fresh first tap in the new direction, remembering the old one for a late repeat key.
				abandonedFirst = _tapDirection;
			}

			if ( _tapPhase == 1 )
			{
				_tapTriggerMask |= justMask;
				_tapDirection = AddRolledDirection( _tapDirection, chordDirection );
				if ( _lateFirstDirection.LengthSquared > 0f && IsDiagonal( _tapDirection )
					&& IsCompatibleSecondTap( _lateFirstDirection, _tapDirection )
					&& IsRepeatOfFirstTap( _lateFirstDirection, _tapDirection ) )
				{
					direction = _tapDirection;
					Reset();
					return true;
				}
			}
			else
			{
				_tapDirection = chordDirection;
				_tapTriggerMask = justMask;
				_tapPhase = 1;
				_tapTimer = 0f;
				_gapTimer = 0f;
				_lateFirstDirection = abandonedFirst;
				_lateTimer = 0f;
			}
		}

		if ( _tapPhase == 1 && (_tapTriggerMask & heldMask) == 0 )
		{
			_tapPhase = 2;
			_gapTimer = 0f;
		}

		return false;
	}

	public void Reset()
	{
		_tapDirection = Vector2.Zero;
		_pendingSecondDirection = Vector2.Zero;
		_lateFirstDirection = Vector2.Zero;
		_lateTimer = 0f;
		_tapTriggerMask = 0;
		_tapPhase = 0;
		_tapTimer = 0f;
		_gapTimer = 0f;
	}

	private static int ReadHeldMask( IPlayerInput input, bool allowHorizontal, bool allowUp, bool allowDown )
	{
		return (allowHorizontal && input.Left ? 1 << 0 : 0)
			| (allowHorizontal && input.Right ? 1 << 1 : 0)
			| (allowUp && input.Up ? 1 << 2 : 0)
			| (allowDown && input.Down ? 1 << 3 : 0);
	}

	private static int ReadJustMask( IPlayerInput input, bool allowHorizontal, bool allowUp, bool allowDown )
	{
		return (allowHorizontal && input.LeftJust ? 1 << 0 : 0)
			| (allowHorizontal && input.RightJust ? 1 << 1 : 0)
			| (allowUp && input.UpJust ? 1 << 2 : 0)
			| (allowDown && input.DownJust ? 1 << 3 : 0);
	}

	private static Vector2 DirectionFromMasks( int heldMask, int justMask )
	{
		float x = ResolveAxis( heldMask, justMask, 1 << 1, 1 << 0 );
		float y = ResolveAxis( heldMask, justMask, 1 << 2, 1 << 3 );
		Vector2 direction = new( x, y );
		return direction.LengthSquared > 0f ? Utils.Normalized( direction ) : Vector2.Zero;
	}

	/// <summary>Resolves one axis to -1/0/+1. A FRESH PRESS wins its axis outright, even against the
	/// opposite key still being held — otherwise double-tapping away from the direction you're currently
	/// running (tap left while right is down) cancels to zero and the gesture never starts at all. Only
	/// when neither side was just pressed do the held bits decide, and opposing held bits cancel.</summary>
	private static float ResolveAxis( int heldMask, int justMask, int positiveBit, int negativeBit )
	{
		bool justPositive = (justMask & positiveBit) != 0;
		bool justNegative = (justMask & negativeBit) != 0;
		if ( justPositive != justNegative ) return justPositive ? 1f : -1f;

		bool heldPositive = (heldMask & positiveBit) != 0;
		bool heldNegative = (heldMask & negativeBit) != 0;
		if ( heldPositive != heldNegative ) return heldPositive ? 1f : -1f;
		return 0f;
	}

	/// <summary>No axis opposes the first tap. Perpendicular components are allowed (they're the diagonal modifiers).</summary>
	private static bool IsCompatibleSecondTap( Vector2 first, Vector2 second )
	{
		if ( first.x != 0f && second.x != 0f && MathF.Sign( first.x ) != MathF.Sign( second.x ) ) return false;
		if ( first.y != 0f && second.y != 0f && MathF.Sign( first.y ) != MathF.Sign( second.y ) ) return false;
		return true;
	}

	/// <summary>The second press repeats at least one direction of the first tap. Without this, tap Right then
	/// tap Up counts as a double tap of Up (nothing opposes), so quick direction changes fire abilities.</summary>
	private static bool IsRepeatOfFirstTap( Vector2 first, Vector2 second )
	{
		if ( first.x != 0f && second.x != 0f && MathF.Sign( first.x ) == MathF.Sign( second.x ) ) return true;
		if ( first.y != 0f && second.y != 0f && MathF.Sign( first.y ) == MathF.Sign( second.y ) ) return true;
		return false;
	}

	private static Vector2 AddRolledDirection( Vector2 pending, Vector2 held )
	{
		float x = pending.x;
		float y = pending.y;
		if ( held.x != 0f && (x == 0f || MathF.Sign( x ) == MathF.Sign( held.x )) ) x = held.x;
		if ( held.y != 0f && (y == 0f || MathF.Sign( y ) == MathF.Sign( held.y )) ) y = held.y;
		return Utils.Normalized( new Vector2( x, y ) );
	}

	private static bool IsDiagonal( Vector2 direction ) => direction.x != 0f && direction.y != 0f;
}