//! Login rate limiter (audit 2026-06-11 H-B1).
//!
//! Two sliding-window token buckets gating `POST /api/v1/admin/auth/login`:
//!
//! 1. **Per `(remote_ip, username)`** — burst 5 in 60 s. Defeats a single
//!    attacker hammering one account.
//! 2. **Per username** — burst 10 in 15 min. Defeats credential-stuffing
//!    that distributes one username across many IPs.
//!
//! Buckets reset lazily when a request arrives in a stale window. Both
//! caches sweep when they cross a fixed size threshold so an attacker
//! enumerating usernames can't grow the table unbounded.
//!
//! A separate `tokio::sync::Semaphore` caps concurrent Argon2 verifies
//! (the actual CPU cost) so even a single legitimate user can't park
//! every blocking-thread on password hashing during a credential-
//! stuffing flurry. The login handler acquires it AFTER the bucket
//! check so attackers can't even queue.

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

/// Outcome of [`LoginRateLimiter::try_consume`].
#[derive(Debug, Clone, Copy)]
pub enum LoginRateOutcome {
    /// Both buckets had a slot; this attempt is allowed.
    Allowed,
    /// One of the buckets is full. The advisory `retry_after` is the
    /// soonest a slot will next free up.
    Denied { retry_after: Duration },
}

#[derive(Debug, Clone, Copy)]
struct Bucket {
    window_started_at: Instant,
    count: u32,
}

impl Bucket {
    fn fresh(now: Instant) -> Self {
        Self {
            window_started_at: now,
            count: 0,
        }
    }

    /// Reset the window if `window` has elapsed.
    fn roll(&mut self, now: Instant, window: Duration) {
        if now.duration_since(self.window_started_at) >= window {
            *self = Self::fresh(now);
        }
    }

    fn retry_after(&self, now: Instant, window: Duration) -> Duration {
        window.saturating_sub(now.duration_since(self.window_started_at))
    }
}

#[derive(Debug, Default)]
struct State {
    per_ip_user: HashMap<(String, String), Bucket>,
    per_user: HashMap<String, Bucket>,
}

#[derive(Debug)]
pub struct LoginRateLimiter {
    ip_user_burst: u32,
    ip_user_window: Duration,
    user_burst: u32,
    user_window: Duration,
    state: Mutex<State>,
}

impl Default for LoginRateLimiter {
    fn default() -> Self {
        Self::new()
    }
}

impl LoginRateLimiter {
    #[must_use]
    pub fn new() -> Self {
        Self {
            ip_user_burst: 5,
            ip_user_window: Duration::from_secs(60),
            user_burst: 10,
            user_window: Duration::from_secs(15 * 60),
            state: Mutex::new(State::default()),
        }
    }

    /// Test-only constructor with custom budgets.
    #[doc(hidden)]
    #[must_use]
    pub fn with_budgets(
        ip_user_burst: u32,
        ip_user_window: Duration,
        user_burst: u32,
        user_window: Duration,
    ) -> Self {
        Self {
            ip_user_burst,
            ip_user_window,
            user_burst,
            user_window,
            state: Mutex::new(State::default()),
        }
    }

    /// Attempt to consume one slot. The username is lowercased before
    /// keying so case-folded variants share a bucket (admin usernames
    /// are case-insensitive elsewhere). `remote_ip` is whatever the
    /// handler resolved (X-Forwarded-For first hop, or `"unknown"` if
    /// it was absent).
    pub fn try_consume(&self, remote_ip: &str, username: &str) -> LoginRateOutcome {
        let now = Instant::now();
        let user_key = username.trim().to_ascii_lowercase();
        let mut state = self.state.lock().expect("login rate limiter poisoned");

        // Per-username check.
        {
            let bucket = state
                .per_user
                .entry(user_key.clone())
                .or_insert_with(|| Bucket::fresh(now));
            bucket.roll(now, self.user_window);
            if bucket.count >= self.user_burst {
                return LoginRateOutcome::Denied {
                    retry_after: bucket.retry_after(now, self.user_window),
                };
            }
        }

        // Per-(ip, user) check.
        let ip_key = (remote_ip.to_string(), user_key.clone());
        {
            let bucket = state
                .per_ip_user
                .entry(ip_key.clone())
                .or_insert_with(|| Bucket::fresh(now));
            bucket.roll(now, self.ip_user_window);
            if bucket.count >= self.ip_user_burst {
                return LoginRateOutcome::Denied {
                    retry_after: bucket.retry_after(now, self.ip_user_window),
                };
            }
        }

        // Both buckets have room — bump both.
        if let Some(b) = state.per_user.get_mut(&user_key) {
            b.count += 1;
        }
        if let Some(b) = state.per_ip_user.get_mut(&ip_key) {
            b.count += 1;
        }

        // Bounded growth: sweep expired entries when the maps cross a
        // soft cap.
        if state.per_ip_user.len() > 4096 {
            let win = self.ip_user_window;
            state
                .per_ip_user
                .retain(|_, b| now.duration_since(b.window_started_at) < win);
        }
        if state.per_user.len() > 1024 {
            let win = self.user_window;
            state
                .per_user
                .retain(|_, b| now.duration_since(b.window_started_at) < win);
        }

        LoginRateOutcome::Allowed
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn allows_up_to_burst_then_denies() {
        let l = LoginRateLimiter::with_budgets(
            3,
            Duration::from_secs(60),
            100,
            Duration::from_secs(60),
        );
        for _ in 0..3 {
            assert!(matches!(
                l.try_consume("1.2.3.4", "alice"),
                LoginRateOutcome::Allowed
            ));
        }
        assert!(matches!(
            l.try_consume("1.2.3.4", "alice"),
            LoginRateOutcome::Denied { .. }
        ));
    }

    #[test]
    fn per_user_bucket_crosses_ips() {
        let l = LoginRateLimiter::with_budgets(
            100,
            Duration::from_secs(60),
            3,
            Duration::from_secs(60),
        );
        // Three different IPs each try once → the per-user bucket
        // exhausts after 3, denying the 4th regardless of source IP.
        assert!(matches!(
            l.try_consume("1.1.1.1", "alice"),
            LoginRateOutcome::Allowed
        ));
        assert!(matches!(
            l.try_consume("2.2.2.2", "alice"),
            LoginRateOutcome::Allowed
        ));
        assert!(matches!(
            l.try_consume("3.3.3.3", "alice"),
            LoginRateOutcome::Allowed
        ));
        assert!(matches!(
            l.try_consume("4.4.4.4", "alice"),
            LoginRateOutcome::Denied { .. }
        ));
    }

    #[test]
    fn username_is_case_folded() {
        let l = LoginRateLimiter::with_budgets(
            1,
            Duration::from_secs(60),
            100,
            Duration::from_secs(60),
        );
        assert!(matches!(
            l.try_consume("1.2.3.4", "Alice"),
            LoginRateOutcome::Allowed
        ));
        assert!(matches!(
            l.try_consume("1.2.3.4", "alice"),
            LoginRateOutcome::Denied { .. }
        ));
    }

    #[test]
    fn distinct_users_have_independent_buckets() {
        let l = LoginRateLimiter::with_budgets(
            1,
            Duration::from_secs(60),
            100,
            Duration::from_secs(60),
        );
        assert!(matches!(
            l.try_consume("1.2.3.4", "alice"),
            LoginRateOutcome::Allowed
        ));
        assert!(matches!(
            l.try_consume("1.2.3.4", "bob"),
            LoginRateOutcome::Allowed
        ));
    }
}
