"""
tests/test_rate_limiter.py — Tests for rate limiting (#71) and config
validation (#73).

Coverage:
  TokenBucketStore:
    1.  Allows requests under limit
    2.  Blocks requests over burst limit
    3.  Refills tokens over time
    4.  Per-identity isolation (per-owner tracking)
    5.  Burst allowance (uses burst tokens before throttling)
    6.  Returns correct retry_after > 0 on 429
    7.  reset() clears a single bucket
    8.  clear() clears all buckets
    9.  Rejects invalid rpm / burst on construction
    10. update_limits() hot-reloads config

  RateLimiterMiddleware (FastAPI integration):
    11. Returns 429 with Retry-After header when limit exceeded
    12. Allows requests within limit (200 OK)
    13. Skips /api/v1/health endpoint (no rate limiting)
    14. Per-owner_id query param tracking
    15. Per X-Owner-Id header tracking
    16. X-RateLimit-Limit header present on OK responses
    17. Disabled middleware passes all requests through

  validate_config (#73):
    18. Empty extraction_model → warning
    19. Empty reconciliation_model → warning
    20. embedding_dim <= 0 → warning
    21. db_path directory doesn't exist → warning
    22. rate_limit_rpm < 0 → warning
    23. rate_limit_burst < 0 → warning
    24. Token budget inconsistency → warning
    25. Clean config → no warnings
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from typing import Any, List
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# TokenBucketStore tests (no FastAPI needed)
# ---------------------------------------------------------------------------

from cortex.api.rate_limiter import TokenBucketStore


class TestTokenBucketStore:
    # -----------------------------------------------------------------------
    # Test 1: Allows requests under limit
    # -----------------------------------------------------------------------
    def test_allows_under_limit(self):
        store = TokenBucketStore(requests_per_minute=60, burst=5)
        allowed, retry = store.consume("user:alice")
        assert allowed is True
        assert retry == 0.0

    # -----------------------------------------------------------------------
    # Test 2: Blocks requests over burst limit
    # -----------------------------------------------------------------------
    def test_blocks_over_burst(self):
        store = TokenBucketStore(requests_per_minute=60, burst=3)
        identity = "user:bob"
        # Drain the burst
        for _ in range(3):
            allowed, _ = store.consume(identity)
            assert allowed is True
        # Next request must be blocked
        allowed, retry = store.consume(identity)
        assert allowed is False
        assert retry > 0.0

    # -----------------------------------------------------------------------
    # Test 3: Tokens refill over time
    # -----------------------------------------------------------------------
    def test_refills_over_time(self, monkeypatch):
        store = TokenBucketStore(requests_per_minute=60, burst=1)
        identity = "user:carol"

        # Use a fake monotonic clock
        fake_time = [0.0]
        monkeypatch.setattr(time, "monotonic", lambda: fake_time[0])

        # Drain the single token
        allowed, _ = store.consume(identity)
        assert allowed is True
        allowed, _ = store.consume(identity)
        assert allowed is False

        # Advance time by 1 second (1 token/sec refill at 60rpm)
        fake_time[0] += 1.0
        allowed, _ = store.consume(identity)
        assert allowed is True

    # -----------------------------------------------------------------------
    # Test 4: Per-identity isolation (per-owner tracking)
    # -----------------------------------------------------------------------
    def test_per_identity_isolation(self):
        store = TokenBucketStore(requests_per_minute=60, burst=1)
        # Drain alice
        store.consume("owner:alice")
        store.consume("owner:alice")  # this will block alice

        # bob should still be allowed
        allowed, _ = store.consume("owner:bob")
        assert allowed is True

    # -----------------------------------------------------------------------
    # Test 5: Burst allowance works (burst >> 1 allows a burst)
    # -----------------------------------------------------------------------
    def test_burst_allowance(self):
        burst_size = 10
        store = TokenBucketStore(requests_per_minute=1, burst=burst_size)
        identity = "user:dave"

        allowed_count = 0
        for _ in range(burst_size):
            allowed, _ = store.consume(identity)
            if allowed:
                allowed_count += 1

        assert allowed_count == burst_size

        # The very next request (burst_size + 1) must be blocked
        allowed, retry = store.consume(identity)
        assert allowed is False
        assert retry > 0.0

    # -----------------------------------------------------------------------
    # Test 6: retry_after is positive on 429
    # -----------------------------------------------------------------------
    def test_retry_after_positive_on_block(self):
        store = TokenBucketStore(requests_per_minute=60, burst=1)
        identity = "user:eve"
        store.consume(identity)  # drain
        store.consume(identity)  # drain again just in case of floating point

        allowed, retry = store.consume(identity)
        assert allowed is False
        assert retry > 0.0
        # retry_after should be well under 60s for 60 rpm
        assert retry < 60.0

    # -----------------------------------------------------------------------
    # Test 7: reset() clears a single bucket
    # -----------------------------------------------------------------------
    def test_reset_clears_bucket(self):
        store = TokenBucketStore(requests_per_minute=60, burst=1)
        identity = "user:frank"
        store.consume(identity)  # drain
        allowed, _ = store.consume(identity)
        assert allowed is False

        store.reset(identity)
        allowed, _ = store.consume(identity)
        assert allowed is True

    # -----------------------------------------------------------------------
    # Test 8: clear() clears all buckets
    # -----------------------------------------------------------------------
    def test_clear_clears_all_buckets(self):
        store = TokenBucketStore(requests_per_minute=60, burst=1)
        for i in range(5):
            store.consume(f"user:{i}")
            store.consume(f"user:{i}")  # drain each

        store.clear()
        assert store.bucket_count() == 0

        # After clear, all identities should be allowed again
        allowed, _ = store.consume("user:0")
        assert allowed is True

    # -----------------------------------------------------------------------
    # Test 9: Rejects invalid construction params
    # -----------------------------------------------------------------------
    def test_invalid_rpm_raises(self):
        with pytest.raises(ValueError, match="requests_per_minute"):
            TokenBucketStore(requests_per_minute=0, burst=5)
        with pytest.raises(ValueError, match="requests_per_minute"):
            TokenBucketStore(requests_per_minute=-1, burst=5)

    def test_invalid_burst_raises(self):
        with pytest.raises(ValueError, match="burst"):
            TokenBucketStore(requests_per_minute=60, burst=0)
        with pytest.raises(ValueError, match="burst"):
            TokenBucketStore(requests_per_minute=60, burst=-3)

    # -----------------------------------------------------------------------
    # Test 10: update_limits() hot-reloads config
    # -----------------------------------------------------------------------
    def test_update_limits(self):
        store = TokenBucketStore(requests_per_minute=60, burst=2)
        store.consume("user:grace")
        store.consume("user:grace")  # drain burst=2

        # Increase limits
        store.update_limits(requests_per_minute=120, burst=5)
        assert store.rpm == 120
        assert store.burst == 5
        # Buckets should be cleared — grace is now unthrottled
        allowed, _ = store.consume("user:grace")
        assert allowed is True


# ---------------------------------------------------------------------------
# Middleware integration tests (FastAPI)
# ---------------------------------------------------------------------------

pytest.importorskip("fastapi", reason="fastapi not installed")
pytest.importorskip("httpx", reason="httpx not installed")

from fastapi import FastAPI  # noqa: E402
from fastapi.testclient import TestClient  # noqa: E402
from cortex.api.rate_limiter import RateLimiterMiddleware  # noqa: E402


def _make_app(rpm: int = 60, burst: int = 5, enabled: bool = True) -> FastAPI:
    """Create a minimal FastAPI app with RateLimiterMiddleware."""
    app = FastAPI()
    app.add_middleware(
        RateLimiterMiddleware,
        requests_per_minute=rpm,
        burst=burst,
        enabled=enabled,
    )

    @app.get("/api/v1/ping")
    async def ping():
        return {"ok": True}

    @app.get("/api/v1/health")
    async def health():
        return {"ok": True}

    return app


class TestRateLimiterMiddleware:
    # -----------------------------------------------------------------------
    # Test 11: Returns 429 with Retry-After header when limit exceeded
    # -----------------------------------------------------------------------
    def test_429_with_retry_after_header(self):
        app = _make_app(rpm=60, burst=2)
        client = TestClient(app, raise_server_exceptions=False)

        # Drain the burst
        client.get("/api/v1/ping")
        client.get("/api/v1/ping")

        # Next request should be rate-limited
        r = client.get("/api/v1/ping")
        assert r.status_code == 429
        assert "Retry-After" in r.headers
        assert int(r.headers["Retry-After"]) >= 1
        body = r.json()
        assert body["detail"] == "Rate limit exceeded"
        assert "retry_after" in body

    # -----------------------------------------------------------------------
    # Test 12: Allows requests within limit (200 OK)
    # -----------------------------------------------------------------------
    def test_allows_within_limit(self):
        app = _make_app(rpm=60, burst=5)
        client = TestClient(app, raise_server_exceptions=False)
        for _ in range(5):
            r = client.get("/api/v1/ping")
            assert r.status_code == 200

    # -----------------------------------------------------------------------
    # Test 13: Skips /api/v1/health (no rate limiting)
    # -----------------------------------------------------------------------
    def test_skips_health_endpoint(self):
        app = _make_app(rpm=60, burst=1)
        client = TestClient(app, raise_server_exceptions=False)

        # Drain the burst so any real endpoint would 429
        client.get("/api/v1/ping")
        client.get("/api/v1/ping")
        assert client.get("/api/v1/ping").status_code == 429

        # Health should still 200
        for _ in range(5):
            r = client.get("/api/v1/health")
            assert r.status_code == 200

    # -----------------------------------------------------------------------
    # Test 14: Per-owner_id query param tracking
    # -----------------------------------------------------------------------
    def test_per_owner_id_query_param(self):
        app = _make_app(rpm=60, burst=1)
        client = TestClient(app, raise_server_exceptions=False)

        # Drain alice
        client.get("/api/v1/ping?owner_id=alice")
        client.get("/api/v1/ping?owner_id=alice")
        r_alice = client.get("/api/v1/ping?owner_id=alice")
        assert r_alice.status_code == 429

        # bob should still be fine
        r_bob = client.get("/api/v1/ping?owner_id=bob")
        assert r_bob.status_code == 200

    # -----------------------------------------------------------------------
    # Test 15: Per X-Owner-Id header tracking
    # -----------------------------------------------------------------------
    def test_per_owner_id_header(self):
        app = _make_app(rpm=60, burst=1)
        client = TestClient(app, raise_server_exceptions=False)

        # Drain alice via header
        client.get("/api/v1/ping", headers={"X-Owner-Id": "alice"})
        client.get("/api/v1/ping", headers={"X-Owner-Id": "alice"})
        r_alice = client.get("/api/v1/ping", headers={"X-Owner-Id": "alice"})
        assert r_alice.status_code == 429

        # bob via header should still be fine
        r_bob = client.get("/api/v1/ping", headers={"X-Owner-Id": "bob"})
        assert r_bob.status_code == 200

    # -----------------------------------------------------------------------
    # Test 16: X-RateLimit-Limit header present on OK responses
    # -----------------------------------------------------------------------
    def test_ratelimit_limit_header_on_ok(self):
        app = _make_app(rpm=30, burst=5)
        client = TestClient(app, raise_server_exceptions=False)
        r = client.get("/api/v1/ping")
        assert r.status_code == 200
        assert r.headers.get("X-RateLimit-Limit") == "30"

    # -----------------------------------------------------------------------
    # Test 17: Disabled middleware passes all requests through
    # -----------------------------------------------------------------------
    def test_disabled_middleware(self):
        app = _make_app(rpm=60, burst=1, enabled=False)
        client = TestClient(app, raise_server_exceptions=False)

        # Even after "draining" the bucket would be, everything passes
        for _ in range(20):
            r = client.get("/api/v1/ping")
            assert r.status_code == 200


# ---------------------------------------------------------------------------
# validate_config tests (#73)
# ---------------------------------------------------------------------------

from cortex.config import CortexConfig, validate_config  # noqa: E402


class TestValidateConfig:
    def _cfg(self, **kwargs) -> CortexConfig:
        """Create a config with sane defaults + overrides."""
        base = CortexConfig()
        for k, v in kwargs.items():
            object.__setattr__(base, k, v)
        return base

    # -----------------------------------------------------------------------
    # Test 18: Empty extraction_model → warning
    # -----------------------------------------------------------------------
    def test_empty_extraction_model_warns(self):
        cfg = self._cfg(extraction_model="")
        issues = validate_config(cfg)
        assert any("extraction_model" in i for i in issues), issues

    def test_blank_extraction_model_warns(self):
        cfg = self._cfg(extraction_model="   ")
        issues = validate_config(cfg)
        assert any("extraction_model" in i for i in issues), issues

    # -----------------------------------------------------------------------
    # Test 19: Empty reconciliation_model → warning
    # -----------------------------------------------------------------------
    def test_empty_reconciliation_model_warns(self):
        cfg = self._cfg(reconciliation_model="")
        issues = validate_config(cfg)
        assert any("reconciliation_model" in i for i in issues), issues

    # -----------------------------------------------------------------------
    # Test 20: embedding_dim <= 0 → warning
    # -----------------------------------------------------------------------
    def test_zero_embedding_dim_warns(self):
        cfg = self._cfg(embedding_dim=0)
        issues = validate_config(cfg)
        assert any("embedding_dim" in i for i in issues), issues

    def test_negative_embedding_dim_warns(self):
        cfg = self._cfg(embedding_dim=-512)
        issues = validate_config(cfg)
        assert any("embedding_dim" in i for i in issues), issues

    # -----------------------------------------------------------------------
    # Test 21: db_path directory doesn't exist → warning
    # -----------------------------------------------------------------------
    def test_nonexistent_db_dir_warns(self):
        cfg = self._cfg(storage_db_path="/nonexistent/path/that/does/not/exist/cortex.db")
        issues = validate_config(cfg)
        assert any("db_path" in i or "directory" in i for i in issues), issues

    # -----------------------------------------------------------------------
    # Test 22: rate_limit_rpm < 0 → warning
    # -----------------------------------------------------------------------
    def test_negative_rpm_warns(self):
        cfg = self._cfg(rate_limit_rpm=-1)
        issues = validate_config(cfg)
        assert any("rate_limit_rpm" in i for i in issues), issues

    # -----------------------------------------------------------------------
    # Test 23: rate_limit_burst < 0 → warning
    # -----------------------------------------------------------------------
    def test_negative_burst_warns(self):
        cfg = self._cfg(rate_limit_burst=-5)
        issues = validate_config(cfg)
        assert any("rate_limit_burst" in i for i in issues), issues

    # -----------------------------------------------------------------------
    # Test 24: Token budget inconsistency → warning
    # -----------------------------------------------------------------------
    def test_token_budget_inconsistency_warns(self):
        cfg = self._cfg(
            total_memory_token_budget=100,
            cornerstone_token_budget=80,
            retrieval_token_budget=80,  # 80+80 > 100
        )
        issues = validate_config(cfg)
        assert any("budget" in i.lower() for i in issues), issues

    # -----------------------------------------------------------------------
    # Test 25: Clean config → no warnings
    # -----------------------------------------------------------------------
    def test_clean_config_no_warnings(self, tmp_path):
        db_file = tmp_path / "cortex.db"
        cfg = self._cfg(
            storage_db_path=str(db_file),
            extraction_model="gpt-4.1-nano",
            reconciliation_model="gpt-4.1-nano",
            embedding_dim=1536,
            rate_limit_rpm=60,
            rate_limit_burst=10,
            total_memory_token_budget=4000,
            cornerstone_token_budget=800,
            retrieval_token_budget=3000,
        )
        issues = validate_config(cfg)
        # tmp_path exists, so db_path parent exists → no warnings
        assert issues == [], f"Unexpected issues: {issues}"
