# #region Test.RateLimiter [C:3] [TYPE Module] [SEMANTICS test,rate_limiter,security,throttle]
# @BRIEF Verify RateLimiterModule contracts — ban/window logic, pruning, thread safety.
# @RELATION BINDS_TO -> [RateLimiterModule]
# @TEST_EDGE: unknown_ip_not_banned -> is_banned returns False for unseen IP
# @TEST_EDGE: ban_triggers_at_limit -> MAX_ATTEMPTS+1 attempts triggers ban
# @TEST_EDGE: exactly_at_limit_no_ban -> exactly MAX_ATTEMPTS does NOT ban (code uses >)
# @TEST_EDGE: ban_expires -> ban auto-clears after BAN_DURATION seconds
# @TEST_EDGE: old_attempts_pruned -> attempts outside window are discarded
# @TEST_EDGE: success_clears_attempts -> record_success resets attempt counter
# @TEST_EDGE: success_does_not_clear_ban -> record_success does NOT lift active ban
# @TEST_EDGE: ban_clears_attempts -> after ban, attempt history is deleted

from pathlib import Path
import sys
import threading

sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from unittest.mock import patch
import pytest

from src.core.rate_limiter import RateLimiter


# ── Constant overrides for fast, deterministic tests ──
# MAX_ATTEMPTS=2  → ban triggers on 3rd attempt (production code uses `>` not `>=`)
# ATTEMPT_WINDOW=3600 → 1-hour window (attempts stay valid within a single test)
# BAN_DURATION=3600   → 1-hour ban (testable via mocked monotonic clock)

@pytest.fixture(autouse=True)
def _fast_constants(monkeypatch):
    """Monkeypatch module constants to small values for fast testing."""
    monkeypatch.setattr("src.core.rate_limiter.MAX_ATTEMPTS", 2)
    monkeypatch.setattr("src.core.rate_limiter.ATTEMPT_WINDOW", 3600)
    monkeypatch.setattr("src.core.rate_limiter.BAN_DURATION", 3600)


@pytest.fixture
def limiter():
    """Fresh RateLimiter instance per test — no shared state."""
    return RateLimiter()


class TestRateLimiter:
    """RateLimiter — in-memory sliding-window rate limiter keyed by IP."""

    # #region test_is_banned_unknown_ip [C:2] [TYPE Function]
    # @BRIEF Unknown IP has no ban entry — is_banned returns False.
    def test_is_banned_unknown_ip(self, limiter):
        assert limiter.is_banned("192.168.1.1") is False
    # #endregion test_is_banned_unknown_ip

    # #region test_is_banned_banned_ip [C:2] [TYPE Function]
    # @BRIEF IP with 3 attempts (MAX_ATTEMPTS+1) is banned — is_banned returns True.
    def test_is_banned_banned_ip(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            mock_time.monotonic.return_value = 1000.0
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")

            # Must check inside patch — real monotonic would exceed ban expiry
            assert limiter.is_banned("10.0.0.1") is True
    # #endregion test_is_banned_banned_ip

    # #region test_ban_expires_after_duration [C:2] [TYPE Function]
    # @BRIEF Ban auto-clears once monotonic clock exceeds ban expiry.
    def test_ban_expires_after_duration(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            mock_time.monotonic.return_value = 1000.0
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            assert limiter.is_banned("10.0.0.1") is True

            # Advance clock past BAN_DURATION (3600s) + 1s margin
            mock_time.monotonic.return_value = 4601.0
            assert limiter.is_banned("10.0.0.1") is False
    # #endregion test_ban_expires_after_duration

    # #region test_record_attempt_under_limit [C:2] [TYPE Function]
    # @BRIEF 2 attempts (exactly MAX_ATTEMPTS) does NOT trigger ban.
    def test_record_attempt_under_limit(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            mock_time.monotonic.return_value = 1000.0
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")

        assert limiter.is_banned("10.0.0.1") is False
    # #endregion test_record_attempt_under_limit

    # #region test_record_attempt_triggers_ban [C:2] [TYPE Function]
    # @BRIEF 3rd attempt (MAX_ATTEMPTS+1) triggers ban — code uses `>` not `>=`.
    def test_record_attempt_triggers_ban(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            mock_time.monotonic.return_value = 1000.0
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            assert limiter.is_banned("10.0.0.1") is False

            limiter.record_attempt("10.0.0.1")
            assert limiter.is_banned("10.0.0.1") is True
    # #endregion test_record_attempt_triggers_ban

    # #region test_old_attempts_pruned [C:2] [TYPE Function]
    # @BRIEF Attempts older than ATTEMPT_WINDOW are discarded; fresh start after gap.
    def test_old_attempts_pruned(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            # 2 attempts at t=1000
            mock_time.monotonic.return_value = 1000.0
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")

            # Advance past ATTEMPT_WINDOW (3600s) — old attempts expire
            mock_time.monotonic.return_value = 5000.0
            limiter.record_attempt("10.0.0.1")

            # Only 1 attempt in window — not banned
            assert limiter.is_banned("10.0.0.1") is False
    # #endregion test_old_attempts_pruned

    # #region test_record_success_clears_attempts [C:2] [TYPE Function]
    # @BRIEF record_success resets attempt counter; subsequent attempts start fresh.
    def test_record_success_clears_attempts(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            mock_time.monotonic.return_value = 1000.0
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            limiter.record_success("10.0.0.1")

            # 2 more attempts after success — counter was reset, still under limit
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            assert limiter.is_banned("10.0.0.1") is False
    # #endregion test_record_success_clears_attempts

    # #region test_record_success_does_not_clear_ban [C:2] [TYPE Function]
    # @BRIEF record_success clears attempts but does NOT lift an active ban.
    def test_record_success_does_not_clear_ban(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            mock_time.monotonic.return_value = 1000.0
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            assert limiter.is_banned("10.0.0.1") is True

            # Success clears attempts but ban remains
            limiter.record_success("10.0.0.1")
            assert limiter.is_banned("10.0.0.1") is True
    # #endregion test_record_success_does_not_clear_ban

    # #region test_different_ips_independent [C:2] [TYPE Function]
    # @BRIEF Ban on one IP does not affect other IPs.
    def test_different_ips_independent(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            mock_time.monotonic.return_value = 1000.0
            for _ in range(3):
                limiter.record_attempt("10.0.0.1")

            assert limiter.is_banned("10.0.0.1") is True
            assert limiter.is_banned("192.168.1.1") is False
    # #endregion test_different_ips_independent

    # #region test_ban_clears_attempts [C:2] [TYPE Function]
    # @BRIEF After ban, attempt history is deleted; post-expiry attempts start fresh.
    def test_ban_clears_attempts(self, limiter):
        with patch("src.core.rate_limiter.time") as mock_time:
            # Trigger ban at t=1000
            mock_time.monotonic.return_value = 1000.0
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            assert limiter.is_banned("10.0.0.1") is True

            # Advance past ban expiry
            mock_time.monotonic.return_value = 4601.0
            assert limiter.is_banned("10.0.0.1") is False

            # 2 fresh attempts — if old attempts weren't cleared, these would
            # stack on top of 3 old ones (5 > 2) and re-trigger ban immediately.
            limiter.record_attempt("10.0.0.1")
            limiter.record_attempt("10.0.0.1")
            assert limiter.is_banned("10.0.0.1") is False
    # #endregion test_ban_clears_attempts

    # #region test_thread_safety_basic [C:2] [TYPE Function]
    # @BRIEF Concurrent record_attempt calls from multiple threads do not raise.
    def test_thread_safety_basic(self, limiter):
        errors = []

        def worker(ip, count):
            try:
                for _ in range(count):
                    limiter.record_attempt(ip)
                    limiter.is_banned(ip)
            except Exception as exc:
                errors.append(exc)

        threads = [
            threading.Thread(target=worker, args=("10.0.0.1", 50)),
            threading.Thread(target=worker, args=("10.0.0.2", 50)),
            threading.Thread(target=worker, args=("10.0.0.3", 50)),
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=5)

        assert errors == []
    # #endregion test_thread_safety_basic


class TestRateLimiterSingleton:
    """rate_limiter singleton is pre-created at module level."""

    # #region test_singleton_exists [C:2] [TYPE Function]
    # @BRIEF Module-level singleton exists and exposes the expected interface.
    def test_singleton_exists(self):
        from src.core.rate_limiter import rate_limiter

        assert rate_limiter is not None
        assert hasattr(rate_limiter, "is_banned")
        assert hasattr(rate_limiter, "record_attempt")
        assert hasattr(rate_limiter, "record_success")
    # #endregion test_singleton_exists

# #endregion Test.RateLimiter
