"""Extended tests for app/core/rate_limiter.py — dispatch, _get_quota, memory fallback, Redis path"""

import pytest
from unittest.mock import AsyncMock, MagicMock, patch

API_PATH = "/api/v1/some/endpoint"
NON_API_PATH = "/health"


def make_request(path, method="GET"):
    from fastapi import Request
    req = MagicMock(spec=Request)
    req.url.path = path
    req.method = method
    return req


class TestGetRedis:
    @pytest.mark.asyncio
    async def test_get_redis_success(self):
        from app.core.rate_limiter import RateLimitMiddleware
        mw = RateLimitMiddleware(object())
        mock_redis = AsyncMock()
        with patch("redis.asyncio.Redis.from_url", return_value=mock_redis):
            r = await mw._get_redis()
        assert r is mock_redis

    @pytest.mark.asyncio
    async def test_get_redis_failure(self):
        from app.core.rate_limiter import RateLimitMiddleware
        mw = RateLimitMiddleware(object())
        with patch("redis.asyncio.Redis.from_url", side_effect=Exception("no redis")):
            r = await mw._get_redis()
        assert r is None

    @pytest.mark.asyncio
    async def test_get_redis_reuses(self):
        from app.core.rate_limiter import RateLimitMiddleware
        mw = RateLimitMiddleware(object())
        mock_redis = AsyncMock()
        with patch("redis.asyncio.Redis.from_url", return_value=mock_redis):
            r1 = await mw._get_redis()
            r2 = await mw._get_redis()
        assert r1 is r2


class TestGetQuota:
    @pytest.mark.asyncio
    async def test_cache_hit(self):
        from app.core.rate_limiter import RateLimitMiddleware
        mw = RateLimitMiddleware(object())
        mw._quota_cache["t1"] = (500, 9999999999)  # far future
        q = await mw._get_quota("t1")
        assert q == 500

    @pytest.mark.asyncio
    async def test_cache_expired(self):
        from app.core.rate_limiter import RateLimitMiddleware
        mw = RateLimitMiddleware(object())
        mw._quota_cache["t1"] = (500, 1)  # expired
        with patch("app.db.async_session") as mock_async_session:
            mock_session = AsyncMock()
            mock_cm = AsyncMock()
            mock_cm.__aenter__.return_value = mock_session
            mock_async_session.return_value = mock_cm

            mock_result = MagicMock()
            mock_result.scalar.return_value = "pro"
            mock_session.execute.return_value = mock_result

            q = await mw._get_quota("t1")
        assert q == 1000  # Pro plan

    @pytest.mark.asyncio
    async def test_db_exception_fallback(self):
        from app.core.rate_limiter import RateLimitMiddleware
        mw = RateLimitMiddleware(object())
        with patch("app.db.async_session", side_effect=Exception("db down")):
            q = await mw._get_quota("t1")
        assert q == 100  # default fallback


class TestDispatch:
    @pytest.mark.asyncio
    async def test_non_api_path_bypass(self):
        from app.core.rate_limiter import RateLimitMiddleware
        mw = RateLimitMiddleware(object())
        mock_call_next = AsyncMock(return_value="response")
        resp = await mw.dispatch(make_request(NON_API_PATH), mock_call_next)
        assert resp == "response"

    @pytest.mark.asyncio
    async def test_no_tenant_bypass(self):
        from app.core.rate_limiter import RateLimitMiddleware
        from app.core.tenant_context import tenant_ctx

        mw = RateLimitMiddleware(object())
        mock_call_next = AsyncMock(return_value="response")

        token = tenant_ctx.set(None)
        try:
            resp = await mw.dispatch(make_request(API_PATH), mock_call_next)
            assert resp == "response"
        finally:
            tenant_ctx.reset(token)

    @pytest.mark.asyncio
    async def test_burst_block_returns_429(self):
        from app.core.rate_limiter import RateLimitMiddleware, BURST_MAX_PER_SECOND
        from app.core.tenant_context import tenant_ctx

        mw = RateLimitMiddleware(object())
        mock_call_next = AsyncMock()

        token = tenant_ctx.set("tenant-burst")
        try:
            # Fill the burst window
            for _ in range(BURST_MAX_PER_SECOND):
                mw._check_burst("tenant-burst")
            # Next one should be blocked
            resp = await mw.dispatch(make_request(API_PATH), mock_call_next)
            assert resp.status_code == 429
            mock_call_next.assert_not_called()
        finally:
            tenant_ctx.reset(token)

    @pytest.mark.asyncio
    async def test_memory_fallback_path(self):
        """When Redis is unavailable, skip daily quota and pass through (burst only)"""
        from app.core.rate_limiter import RateLimitMiddleware
        from app.core.tenant_context import tenant_ctx

        mw = RateLimitMiddleware(object())
        mw._quota_cache["tenant-mem"] = (5, 9999999999)

        mock_resp = MagicMock()
        mock_resp.headers = {}
        mock_call_next = AsyncMock(return_value=mock_resp)

        token = tenant_ctx.set("tenant-mem")
        try:
            with patch.object(mw, "_get_redis", return_value=None):
                resp = await mw.dispatch(make_request(API_PATH), mock_call_next)
            assert resp is mock_resp  # passes through without quota check
        finally:
            tenant_ctx.reset(token)

    @pytest.mark.asyncio
    async def test_redis_unavailable_skips_quota(self):
        """Without Redis, daily quota is skipped — only burst protection applies"""
        from app.core.rate_limiter import RateLimitMiddleware
        from app.core.tenant_context import tenant_ctx

        mw = RateLimitMiddleware(object())
        mw._quota_cache["tenant-ex"] = (1, 9999999999)

        mock_resp = MagicMock()
        mock_resp.headers = {}
        mock_call_next = AsyncMock(return_value=mock_resp)

        token = tenant_ctx.set("tenant-ex")
        try:
            with patch.object(mw, "_get_redis", return_value=None):
                # Even though quota=1, multiple requests pass (no memory fallback)
                resp1 = await mw.dispatch(make_request(API_PATH), mock_call_next)
                resp2 = await mw.dispatch(make_request(API_PATH), mock_call_next)
            assert resp1 is mock_resp
            assert resp2 is mock_resp  # quota not enforced without Redis
        finally:
            tenant_ctx.reset(token)

    @pytest.mark.asyncio
    async def test_redis_path_success(self):
        from app.core.rate_limiter import RateLimitMiddleware
        from app.core.tenant_context import tenant_ctx

        mw = RateLimitMiddleware(object())
        mw._quota_cache["tenant-redis"] = (100, 9999999999)

        mock_redis = AsyncMock()
        mock_redis.incr.return_value = 1  # first request

        mock_call_next = AsyncMock()
        mock_call_next.return_value.status_code = 200
        mock_call_next.return_value.headers = {}

        token = tenant_ctx.set("tenant-redis")
        try:
            with patch.object(mw, "_get_redis", return_value=mock_redis):
                resp = await mw.dispatch(make_request(API_PATH), mock_call_next)
            assert resp.status_code == 200
            mock_redis.expire.assert_called_once()
            # verify the key starts with ratelimit prefix
            assert mock_redis.expire.call_args[0][0].startswith("ratelimit:tenant-redis:")
        finally:
            tenant_ctx.reset(token)

    @pytest.mark.asyncio
    async def test_redis_path_exceeded(self):
        from app.core.rate_limiter import RateLimitMiddleware
        from app.core.tenant_context import tenant_ctx

        mw = RateLimitMiddleware(object())
        mw._quota_cache["tenant-ex2"] = (5, 9999999999)

        mock_redis = AsyncMock()
        mock_redis.incr.return_value = 10  # over quota

        mock_call_next = AsyncMock()

        token = tenant_ctx.set("tenant-ex2")
        try:
            with patch.object(mw, "_get_redis", return_value=mock_redis):
                resp = await mw.dispatch(make_request(API_PATH), mock_call_next)
            assert resp.status_code == 429
            mock_redis.decr.assert_called_once()  # decrement the over-count
        finally:
            tenant_ctx.reset(token)
