//! Adaptive rate limiter for Binance REST API (Issue #162).
//!
//! Token bucket with server feedback via `X-MBX-USED-WEIGHT-1m` header.
//! Proactively paces requests to avoid 429 responses while maximizing throughput.
//!
//! # Architecture
//!
//! Two tracking layers provide resilience:
//! - **Server feedback** (authoritative): `X-MBX-USED-WEIGHT-1m` header syncs actual usage
//! - **Local tracking** (fallback): Counts weight locally when headers are unavailable
//!
//! The limiter uses whichever reports higher usage (conservative).
//!
//! # Budget
//!
//! Binance Spot allows 6000 weight/min per IP (official, as of 2025).
//! The budget is shared across ALL processes on the same IP.
//! Each process should set a `ceiling` to prevent starving other consumers.
//!
//! With 20% safety margin on 6000: usable = 4800 weight/min.
//! Typical per-process ceilings:
//! - Sidecar (Puck real-time): ceiling=3000
//! - Kintsugi (background repair): ceiling=2400
//! - Reserve (monitoring, ad-hoc): 600

use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};

use parking_lot::Mutex;
use tracing::{debug, warn};

/// Error returned when rate limit budget is exhausted.
#[derive(Debug, thiserror::Error)]
pub enum RateLimitError {
    #[error(
        "rate limit budget exhausted (used {used}/{max} weight, safety margin {safety_margin})"
    )]
    BudgetExhausted {
        used: u32,
        max: u32,
        safety_margin: f32,
    },
}

/// Adaptive rate limiter with Binance `X-MBX-USED-WEIGHT-1m` server feedback.
///
/// Thread-safe: all state is atomic or mutex-protected.
/// Designed to be shared via `Arc<AdaptiveRateLimiter>` across tasks.
#[derive(Debug)]
pub struct AdaptiveRateLimiter {
    /// Maximum weight allowed per minute (6000 for Binance Spot, shared across IP).
    max_weight_per_minute: u32,
    /// Per-process ceiling — never use more than this weight per window,
    /// even if the global budget has room. Prevents one process from starving
    /// others sharing the same IP. 0 = no ceiling (use full budget).
    ceiling: u32,
    /// Last known server-reported used weight (from response header).
    server_used_weight: AtomicU32,
    /// Safety margin as fraction (0.20 = use only 80% of budget).
    safety_margin: f32,
    /// Start of current 1-minute tracking window.
    window_start: Mutex<Instant>,
    /// Locally tracked weight in current window (fallback when headers unavailable).
    window_weight: AtomicU32,
}

impl AdaptiveRateLimiter {
    /// Create a rate limiter with custom parameters (no per-process ceiling).
    pub fn new(max_weight_per_minute: u32, safety_margin: f32) -> Self {
        Self {
            max_weight_per_minute,
            ceiling: 0,
            server_used_weight: AtomicU32::new(0),
            safety_margin: safety_margin.clamp(0.0, 0.99),
            window_start: Mutex::new(Instant::now()),
            window_weight: AtomicU32::new(0),
        }
    }

    /// Create a rate limiter with a per-process ceiling.
    ///
    /// `max_weight_per_minute`: Total IP-wide budget (6000 for Binance Spot).
    /// `safety_margin`: Fraction reserved (0.20 = use only 80%).
    /// `ceiling`: Max weight THIS process may use per window. The effective
    /// budget is `min(max_weight * (1 - margin), ceiling)`.
    pub fn new_with_ceiling(
        max_weight_per_minute: u32,
        safety_margin: f32,
        ceiling: u32,
    ) -> Self {
        Self {
            max_weight_per_minute,
            ceiling,
            server_used_weight: AtomicU32::new(0),
            safety_margin: safety_margin.clamp(0.0, 0.99),
            window_start: Mutex::new(Instant::now()),
            window_weight: AtomicU32::new(0),
        }
    }

    /// Create a rate limiter with Binance Spot defaults (6000 weight/min, 20% margin).
    pub fn binance_default() -> Self {
        Self::new(6000, 0.20)
    }

    /// Usable weight budget after safety margin and per-process ceiling.
    fn usable_budget(&self) -> u32 {
        let global = ((self.max_weight_per_minute as f32) * (1.0 - self.safety_margin)) as u32;
        if self.ceiling > 0 {
            global.min(self.ceiling)
        } else {
            global
        }
    }

    /// Current estimated weight usage (max of server-reported and local tracking).
    fn current_usage(&self) -> u32 {
        self.maybe_reset_window();
        let server = self.server_used_weight.load(Ordering::Relaxed);
        let local = self.window_weight.load(Ordering::Relaxed);
        server.max(local)
    }

    /// Reset tracking window if >60 seconds have elapsed.
    fn maybe_reset_window(&self) {
        let mut start = self.window_start.lock();
        if start.elapsed() >= Duration::from_secs(60) {
            *start = Instant::now();
            self.window_weight.store(0, Ordering::Relaxed);
            self.server_used_weight.store(0, Ordering::Relaxed);
        }
    }

    /// Acquire `weight` units of rate limit budget.
    ///
    /// If budget is available, reserves the weight and returns immediately.
    /// If budget is exhausted, sleeps until the current window expires,
    /// then retries once. Returns error only if still exhausted after reset.
    pub async fn acquire(&self, weight: u32) -> Result<(), RateLimitError> {
        let budget = self.usable_budget();

        // Fast path: CAS loop to atomically reserve weight
        loop {
            let current = self.window_weight.load(Ordering::Relaxed);
            let server = self.server_used_weight.load(Ordering::Relaxed);
            let usage = current.max(server);
            if usage + weight > budget {
                break; // Fall through to wait-and-retry path
            }
            match self.window_weight.compare_exchange_weak(
                current,
                current + weight,
                Ordering::Relaxed,
                Ordering::Relaxed,
            ) {
                Ok(_) => {
                    debug!(
                        weight = weight,
                        used = usage + weight,
                        budget = budget,
                        "rate limiter: acquired"
                    );
                    return Ok(());
                }
                Err(_) => continue, // Retry CAS
            }
        }

        // Budget exhausted — wait for window reset
        let wait_duration = {
            let start = self.window_start.lock();
            let elapsed = start.elapsed();
            if elapsed < Duration::from_secs(60) {
                Duration::from_secs(60) - elapsed
            } else {
                Duration::ZERO
            }
        };

        if wait_duration > Duration::ZERO {
            warn!(
                weight = weight,
                used = self.current_usage(),
                budget = budget,
                wait_ms = wait_duration.as_millis() as u64,
                "rate limiter: budget exhausted, waiting for window reset"
            );
            tokio::time::sleep(wait_duration).await;
        }

        // After reset, try again
        self.maybe_reset_window();
        let usage = self.current_usage();
        if usage + weight <= budget {
            self.window_weight.fetch_add(weight, Ordering::Relaxed);
            return Ok(());
        }

        Err(RateLimitError::BudgetExhausted {
            used: usage,
            max: self.max_weight_per_minute,
            safety_margin: self.safety_margin,
        })
    }

    /// Update rate limiter state from Binance response headers.
    ///
    /// Reads `X-MBX-USED-WEIGHT-1m` (Spot) or `X-MBX-USED-WEIGHT-1M` (Futures).
    /// Server-reported value is authoritative — overrides local tracking.
    pub fn update_from_headers(&self, headers: &reqwest::header::HeaderMap) {
        // Binance uses both casings depending on endpoint
        let weight_str = headers
            .get("x-mbx-used-weight-1m")
            .or_else(|| headers.get("X-MBX-USED-WEIGHT-1m"))
            .or_else(|| headers.get("X-MBX-USED-WEIGHT-1M"));

        #[allow(clippy::collapsible_if)]
        if let Some(val) = weight_str {
            if let Ok(s) = val.to_str() {
                if let Ok(w) = s.parse::<u32>() {
                    let prev = self.server_used_weight.fetch_max(w, Ordering::Relaxed);
                    if w > prev + 100 {
                        debug!(
                            prev_weight = prev,
                            new_weight = w,
                            budget = self.usable_budget(),
                            "rate limiter: server weight jumped"
                        );
                    }
                }
            }
        }
    }

    /// Current utilization as fraction [0.0, 1.0+].
    ///
    /// Values > 1.0 indicate over-budget (should not happen with proper acquire() usage).
    pub fn utilization(&self) -> f32 {
        let usage = self.current_usage() as f32;
        let budget = self.usable_budget() as f32;
        if budget > 0.0 { usage / budget } else { 1.0 }
    }

    /// Remaining weight budget in current window.
    pub fn remaining(&self) -> u32 {
        let budget = self.usable_budget();
        let usage = self.current_usage();
        budget.saturating_sub(usage)
    }

    /// Maximum weight per minute (before safety margin).
    pub fn max_weight_per_minute(&self) -> u32 {
        self.max_weight_per_minute
    }

    /// Safety margin fraction.
    pub fn safety_margin(&self) -> f32 {
        self.safety_margin
    }
}

/// Create a shared rate limiter for use across async tasks.
pub fn shared_binance_limiter() -> Arc<AdaptiveRateLimiter> {
    Arc::new(AdaptiveRateLimiter::binance_default())
}

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

    #[test]
    fn test_binance_defaults() {
        let limiter = AdaptiveRateLimiter::binance_default();
        assert_eq!(limiter.max_weight_per_minute(), 6000);
        assert!((limiter.safety_margin() - 0.20).abs() < f32::EPSILON);
        // Usable: 6000 * 0.80 = 4800
        assert_eq!(limiter.usable_budget(), 4800);
    }

    #[test]
    fn test_ceiling_caps_budget() {
        let limiter = AdaptiveRateLimiter::new_with_ceiling(6000, 0.20, 2400);
        // Global usable: 6000 * 0.80 = 4800, ceiling: 2400 → min(4800, 2400) = 2400
        assert_eq!(limiter.usable_budget(), 2400);

        let limiter_no_ceil = AdaptiveRateLimiter::new_with_ceiling(6000, 0.20, 0);
        // ceiling=0 means no cap → full 4800
        assert_eq!(limiter_no_ceil.usable_budget(), 4800);
    }

    #[test]
    fn test_utilization_starts_at_zero() {
        let limiter = AdaptiveRateLimiter::binance_default();
        assert!(limiter.utilization() < f32::EPSILON);
    }

    #[test]
    fn test_remaining_starts_at_budget() {
        let limiter = AdaptiveRateLimiter::binance_default();
        assert_eq!(limiter.remaining(), 4800);
    }

    #[test]
    fn test_server_header_update() {
        let limiter = AdaptiveRateLimiter::binance_default();

        let mut headers = reqwest::header::HeaderMap::new();
        headers.insert("x-mbx-used-weight-1m", "500".parse().unwrap());
        limiter.update_from_headers(&headers);

        assert_eq!(limiter.current_usage(), 500);
        assert_eq!(limiter.remaining(), 4300);
    }

    #[test]
    fn test_local_tracking_increments() {
        let limiter = AdaptiveRateLimiter::binance_default();
        limiter.window_weight.fetch_add(200, Ordering::Relaxed);
        assert_eq!(limiter.current_usage(), 200);
    }

    #[test]
    fn test_conservative_max_of_server_and_local() {
        let limiter = AdaptiveRateLimiter::binance_default();

        // Local: 300
        limiter.window_weight.fetch_add(300, Ordering::Relaxed);

        // Server: 500 (higher)
        let mut headers = reqwest::header::HeaderMap::new();
        headers.insert("x-mbx-used-weight-1m", "500".parse().unwrap());
        limiter.update_from_headers(&headers);

        // Should use max(300, 500) = 500
        assert_eq!(limiter.current_usage(), 500);
    }

    #[test]
    fn test_safety_margin_clamped() {
        let limiter = AdaptiveRateLimiter::new(2400, 1.5);
        assert!((limiter.safety_margin() - 0.99).abs() < f32::EPSILON);

        let limiter = AdaptiveRateLimiter::new(2400, -0.5);
        assert!(limiter.safety_margin().abs() < f32::EPSILON);
    }

    #[tokio::test]
    async fn test_acquire_within_budget() {
        let limiter = AdaptiveRateLimiter::binance_default();
        // 20 weight (aggTrades endpoint) — well within 1920 budget
        assert!(limiter.acquire(20).await.is_ok());
        assert_eq!(limiter.current_usage(), 20);
    }

    #[tokio::test]
    async fn test_acquire_multiple() {
        let limiter = AdaptiveRateLimiter::binance_default();
        for _ in 0..10 {
            assert!(limiter.acquire(20).await.is_ok());
        }
        assert_eq!(limiter.current_usage(), 200);
        assert!((limiter.utilization() - 200.0 / 4800.0).abs() < 0.01);
    }

    #[test]
    fn test_utilization_fraction() {
        let limiter = AdaptiveRateLimiter::binance_default();
        limiter.window_weight.fetch_add(2400, Ordering::Relaxed);
        // 2400 / 4800 = 0.50
        assert!((limiter.utilization() - 0.5).abs() < 0.01);
    }

    #[tokio::test]
    async fn test_acquire_at_budget_boundary() {
        let limiter = AdaptiveRateLimiter::new(100, 0.0); // 100 usable, no margin
        // Fill to exactly the boundary
        for _ in 0..5 {
            assert!(limiter.acquire(20).await.is_ok());
        }
        assert_eq!(limiter.current_usage(), 100);
        // Next acquire should fail (budget exhausted, window hasn't reset)
        // Since window just started, acquire will wait and retry — but still over budget
        // Use a very small weight that still tips over
        let result =
            tokio::time::timeout(std::time::Duration::from_millis(200), limiter.acquire(1)).await;
        // Should timeout because acquire waits for 60s window reset
        assert!(result.is_err());
    }

    #[test]
    fn test_window_reset_clears_usage() {
        let limiter = AdaptiveRateLimiter::binance_default();
        limiter.window_weight.fetch_add(1000, Ordering::Relaxed);
        assert_eq!(limiter.current_usage(), 1000);

        // Simulate window expiry by backdating start
        {
            let mut start = limiter.window_start.lock();
            *start = Instant::now() - std::time::Duration::from_secs(61);
        }
        limiter.maybe_reset_window();
        assert_eq!(limiter.current_usage(), 0);
    }

    #[tokio::test]
    async fn test_cas_acquire_no_over_allocation() {
        // Verify CAS prevents over-allocation under concurrent access
        let limiter = Arc::new(AdaptiveRateLimiter::new(100, 0.0)); // 100 usable, no margin
        let mut handles = Vec::new();

        // Spawn 20 tasks each trying to acquire 10 weight
        // Only 10 should succeed (100 / 10 = 10)
        for _ in 0..20 {
            let limiter = Arc::clone(&limiter);
            handles.push(tokio::spawn(async move {
                // Use timeout to avoid waiting for window reset
                tokio::time::timeout(
                    std::time::Duration::from_millis(50),
                    limiter.acquire(10),
                )
                .await
            }));
        }

        let results: Vec<_> = futures::future::join_all(handles).await;
        let successes = results
            .iter()
            .filter(|r| matches!(r, Ok(Ok(Ok(())))))
            .count();

        // At most 10 should succeed (100 / 10)
        assert!(
            successes <= 10,
            "CAS should prevent over-allocation: got {successes} successes"
        );
        // At least some should succeed
        assert!(successes >= 1, "At least one acquire should succeed");
    }

    #[test]
    fn test_fetch_max_keeps_highest_weight() {
        let limiter = AdaptiveRateLimiter::binance_default();

        // Simulate concurrent responses arriving out of order
        let mut headers1 = reqwest::header::HeaderMap::new();
        headers1.insert("x-mbx-used-weight-1m", "500".parse().unwrap());
        limiter.update_from_headers(&headers1);
        assert_eq!(limiter.server_used_weight.load(Ordering::Relaxed), 500);

        // Higher weight arrives
        let mut headers2 = reqwest::header::HeaderMap::new();
        headers2.insert("x-mbx-used-weight-1m", "800".parse().unwrap());
        limiter.update_from_headers(&headers2);
        assert_eq!(limiter.server_used_weight.load(Ordering::Relaxed), 800);

        // Lower weight arrives (out of order) — should NOT overwrite
        let mut headers3 = reqwest::header::HeaderMap::new();
        headers3.insert("x-mbx-used-weight-1m", "600".parse().unwrap());
        limiter.update_from_headers(&headers3);
        // With fetch_max, 800 should be preserved
        assert_eq!(limiter.server_used_weight.load(Ordering::Relaxed), 800);
    }
}
