using System.Collections.Concurrent;
namespace Nexus.Api.RateLimiting;
///
/// Simple in-memory tracking of login attempts per IP,
/// aligned with the fixed-window rate limiter (5 attempts / 1 minute).
///
/// Provides remaining-attempt count that can be passed back to the frontend.
///
public sealed class LoginAttemptTracker
{
private const int MaxAttempts = 5;
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
// IP → (count, windowStartTicks)
private static readonly ConcurrentDictionary _store = new();
///
/// Registers a failed attempt for the given IP.
/// Returns remaining attempts (0 = locked out until reset).
///
public int RecordFailedAttempt(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
var (count, windowStart) = _store.AddOrUpdate(ip,
_ => (1, now),
(_, entry) =>
{
if (now - entry.WindowStartTicks >= windowTicks)
return (1, now);
return (entry.Count + 1, entry.WindowStartTicks);
});
return Math.Max(0, MaxAttempts - count);
}
///
/// Returns the remaining attempts for the given IP without recording.
///
public int GetRemaining(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
if (_store.TryGetValue(ip, out var entry))
{
if (now - entry.WindowStartTicks >= windowTicks)
return MaxAttempts;
return Math.Max(0, MaxAttempts - entry.Count);
}
return MaxAttempts;
}
///
/// Returns the number of seconds until the rate-limit window resets,
/// or 0 if the window has already expired / no attempts recorded.
///
public int GetRetryAfterSeconds(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
if (!_store.TryGetValue(ip, out var entry))
return 0;
var elapsed = now - entry.WindowStartTicks;
if (elapsed >= windowTicks)
return 0;
return (int)Math.Ceiling((windowTicks - elapsed) / 1000.0);
}
///
/// Resets attempt count for the given IP (e.g. on success).
///
public void Reset(string ip)
{
_store.TryRemove(ip, out _);
}
}