"""Tests for OAuth 2.0 Token Introspection verifier (RFC 7662)."""

import base64
import time
from typing import Any

import pytest
from pytest_httpx import HTTPXMock

from fastmcp.server.auth.providers.introspection import (
    IntrospectionTokenVerifier,
)


class TestIntrospectionTokenVerifier:
    """Test core token verification logic."""

    @pytest.fixture
    def verifier(self) -> IntrospectionTokenVerifier:
        """Create a basic introspection verifier for testing."""
        return IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            timeout_seconds=5,
        )

    @pytest.fixture
    def verifier_with_required_scopes(self) -> IntrospectionTokenVerifier:
        """Create verifier with required scopes."""
        return IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            required_scopes=["read", "write"],
        )

    def test_initialization(self):
        """Test verifier initialization."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
        )

        assert verifier.introspection_url == "https://auth.example.com/oauth/introspect"
        assert verifier.client_id == "test-client"
        assert verifier.client_secret == "test-secret"
        assert verifier.timeout_seconds == 10
        assert verifier.client_auth_method == "client_secret_basic"

    def test_initialization_requires_introspection_url(self):
        """Test that introspection_url is required."""
        with pytest.raises(TypeError):
            IntrospectionTokenVerifier(  # ty: ignore[missing-argument]
                client_id="test-client",
                client_secret="test-secret",
            )

    def test_initialization_requires_client_id(self):
        """Test that client_id is required."""
        with pytest.raises(TypeError):
            IntrospectionTokenVerifier(  # ty: ignore[missing-argument]
                introspection_url="https://auth.example.com/oauth/introspect",
                client_secret="test-secret",
            )

    def test_initialization_requires_client_secret(self):
        """Test that client_secret is required."""
        with pytest.raises(TypeError):
            IntrospectionTokenVerifier(  # ty: ignore[missing-argument]
                introspection_url="https://auth.example.com/oauth/introspect",
                client_id="test-client",
            )

    def test_create_basic_auth_header(self, verifier: IntrospectionTokenVerifier):
        """Test HTTP Basic Auth header creation."""
        auth_header = verifier._create_basic_auth_header()

        # Decode and verify
        assert auth_header.startswith("Basic ")
        encoded = auth_header[6:]
        decoded = base64.b64decode(encoded).decode("utf-8")
        assert decoded == "test-client:test-secret"

    def test_extract_scopes_from_string(self, verifier: IntrospectionTokenVerifier):
        """Test scope extraction from space-separated string."""
        response = {"scope": "read write admin"}
        scopes = verifier._extract_scopes(response)

        assert scopes == ["read", "write", "admin"]

    def test_extract_scopes_from_array(self, verifier: IntrospectionTokenVerifier):
        """Test scope extraction from array."""
        response = {"scope": ["read", "write", "admin"]}
        scopes = verifier._extract_scopes(response)

        assert scopes == ["read", "write", "admin"]

    def test_extract_scopes_missing(self, verifier: IntrospectionTokenVerifier):
        """Test scope extraction when scope field is missing."""
        response: dict[str, Any] = {}
        scopes = verifier._extract_scopes(response)

        assert scopes == []

    def test_extract_scopes_with_extra_whitespace(
        self, verifier: IntrospectionTokenVerifier
    ):
        """Test scope extraction handles extra whitespace."""
        response = {"scope": "  read   write  admin  "}
        scopes = verifier._extract_scopes(response)

        assert scopes == ["read", "write", "admin"]

    async def test_valid_token_verification(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test successful token verification."""
        # Mock introspection endpoint
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read write",
                "exp": int(time.time()) + 3600,
                "iat": int(time.time()),
                "sub": "user-123",
                "username": "testuser",
            },
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is not None
        assert access_token.client_id == "user-123"
        assert access_token.scopes == ["read", "write"]
        assert access_token.expires_at is not None
        assert access_token.claims["active"] is True
        assert access_token.claims["username"] == "testuser"

    async def test_inactive_token_returns_none(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that inactive tokens return None."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": False},
        )

        access_token = await verifier.verify_token("expired-token")

        assert access_token is None

    async def test_expired_token_returns_none(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that expired tokens return None."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read",
                "exp": int(time.time()) - 3600,  # Expired 1 hour ago
            },
        )

        access_token = await verifier.verify_token("expired-token")

        assert access_token is None

    async def test_token_without_expiration(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test token without expiration field."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read",
            },
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is not None
        assert access_token.expires_at is None

    async def test_token_without_scopes(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test token without scope field."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
            },
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is not None
        assert access_token.scopes == []

    async def test_required_scopes_validation(
        self,
        verifier_with_required_scopes: IntrospectionTokenVerifier,
        httpx_mock: HTTPXMock,
    ):
        """Test that required scopes are validated."""
        # Token with insufficient scopes
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read",  # Missing 'write'
            },
        )

        access_token = await verifier_with_required_scopes.verify_token("test-token")

        assert access_token is None

    async def test_required_scopes_validation_success(
        self,
        verifier_with_required_scopes: IntrospectionTokenVerifier,
        httpx_mock: HTTPXMock,
    ):
        """Test successful validation with required scopes."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read write admin",  # Has all required scopes
            },
        )

        access_token = await verifier_with_required_scopes.verify_token("test-token")

        assert access_token is not None
        assert set(access_token.scopes) >= {"read", "write"}

    async def test_http_error_returns_none(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that HTTP errors return None."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            status_code=500,
            text="Internal Server Error",
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is None

    async def test_authentication_failure_returns_none(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that authentication failures return None."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            status_code=401,
            text="Unauthorized",
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is None

    async def test_timeout_returns_none(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that timeouts return None."""
        from httpx import TimeoutException

        httpx_mock.add_exception(
            TimeoutException("Request timed out"),
            url="https://auth.example.com/oauth/introspect",
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is None

    async def test_malformed_json_returns_none(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that malformed JSON responses return None."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            status_code=200,
            text="not json",
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is None

    async def test_request_includes_correct_headers(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that the request includes correct headers and auth."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )

        await verifier.verify_token("test-token")

        # Verify request was made with correct parameters
        request = httpx_mock.get_request()
        assert request is not None
        assert request.method == "POST"
        assert "Authorization" in request.headers
        assert request.headers["Authorization"].startswith("Basic ")
        assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
        assert request.headers["Accept"] == "application/json"

    async def test_request_includes_token_and_hint(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that the request includes token and token_type_hint."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )

        await verifier.verify_token("my-test-token")

        request = httpx_mock.get_request()
        assert request is not None

        # Parse form data
        body = request.content.decode("utf-8")
        assert "token=my-test-token" in body
        assert "token_type_hint=access_token" in body

    async def test_client_id_fallback_to_sub(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that client_id falls back to sub if not present."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "sub": "user-456",
                "scope": "read",
            },
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is not None
        assert access_token.client_id == "user-456"

    async def test_client_id_defaults_to_unknown(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that client_id defaults to 'unknown' if neither client_id nor sub present."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "scope": "read",
            },
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is not None
        assert access_token.client_id == "unknown"

    def test_initialization_with_client_secret_post(self):
        """Test verifier initialization with client_secret_post method."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            client_auth_method="client_secret_post",
        )

        assert verifier.client_auth_method == "client_secret_post"
        assert verifier.introspection_url == "https://auth.example.com/oauth/introspect"
        assert verifier.client_id == "test-client"
        assert verifier.client_secret == "test-secret"

    def test_initialization_defaults_to_client_secret_basic(self):
        """Test that client_secret_basic is the default auth method."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
        )

        assert verifier.client_auth_method == "client_secret_basic"

    def test_initialization_rejects_invalid_client_auth_method(self):
        """Test that invalid client_auth_method values are rejected."""
        # Test typo with trailing space
        with pytest.raises(ValueError) as exc_info:
            IntrospectionTokenVerifier(
                introspection_url="https://auth.example.com/oauth/introspect",
                client_id="test-client",
                client_secret="test-secret",
                client_auth_method="client_secret_basic ",  # ty: ignore[invalid-argument-type]
            )
        assert "Invalid client_auth_method" in str(exc_info.value)
        assert "client_secret_basic " in str(exc_info.value)

        # Test completely invalid value
        with pytest.raises(ValueError) as exc_info:
            IntrospectionTokenVerifier(
                introspection_url="https://auth.example.com/oauth/introspect",
                client_id="test-client",
                client_secret="test-secret",
                client_auth_method="basic",  # ty: ignore[invalid-argument-type]
            )
        assert "Invalid client_auth_method" in str(exc_info.value)
        assert "basic" in str(exc_info.value)

    async def test_client_secret_post_includes_credentials_in_body(
        self, httpx_mock: HTTPXMock
    ):
        """Test that client_secret_post includes credentials in POST body."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            client_auth_method="client_secret_post",
        )

        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )

        await verifier.verify_token("test-token")

        # Verify request was made with credentials in body, not header
        request = httpx_mock.get_request()
        assert request is not None
        assert request.method == "POST"
        assert "Authorization" not in request.headers
        assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
        assert request.headers["Accept"] == "application/json"

        # Parse form data
        body = request.content.decode("utf-8")
        assert "token=test-token" in body
        assert "token_type_hint=access_token" in body
        assert "client_id=test-client" in body
        assert "client_secret=test-secret" in body

    async def test_client_secret_post_verification_success(self, httpx_mock: HTTPXMock):
        """Test successful token verification with client_secret_post."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            client_auth_method="client_secret_post",
        )

        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read write",
                "exp": int(time.time()) + 3600,
            },
        )

        access_token = await verifier.verify_token("test-token")

        assert access_token is not None
        assert access_token.client_id == "user-123"
        assert access_token.scopes == ["read", "write"]

    async def test_client_secret_basic_still_works(
        self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that client_secret_basic continues to work unchanged."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )

        await verifier.verify_token("test-token")

        # Verify request was made with Basic Auth header
        request = httpx_mock.get_request()
        assert request is not None
        assert "Authorization" in request.headers
        assert request.headers["Authorization"].startswith("Basic ")

        # Verify credentials are NOT in body
        body = request.content.decode("utf-8")
        assert "client_id=" not in body
        assert "client_secret=" not in body


class TestIntrospectionCaching:
    """Test in-memory caching for token introspection."""

    @pytest.fixture
    def verifier_with_cache(self) -> IntrospectionTokenVerifier:
        """Create verifier with caching enabled."""
        return IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            cache_ttl_seconds=300,  # 5 minutes
            max_cache_size=100,
        )

    @pytest.fixture
    def verifier_no_cache(self) -> IntrospectionTokenVerifier:
        """Create verifier with caching disabled."""
        return IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            cache_ttl_seconds=0,  # Disabled
        )

    def test_default_cache_settings(self):
        """Test that caching is disabled by default."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
        )
        assert not verifier._cache.enabled

    def test_custom_cache_settings(self):
        """Test that cache settings can be customized."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            cache_ttl_seconds=60,
            max_cache_size=500,
        )
        assert verifier._cache._ttl == 60
        assert verifier._cache._max_size == 500

    def test_cache_disabled_with_zero_ttl(self):
        """Test that cache is disabled when TTL is 0 or None."""
        # Explicit 0
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            cache_ttl_seconds=0,
        )
        assert not verifier._cache.enabled

        # Explicit None (same as default)
        verifier2 = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            cache_ttl_seconds=None,
        )
        assert not verifier2._cache.enabled

    async def test_cache_disabled_with_zero_max_size(self, httpx_mock: HTTPXMock):
        """Test that cache is disabled when max_cache_size is 0."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read",
            },
        )

        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            cache_ttl_seconds=300,
            max_cache_size=0,
        )
        result = await verifier.verify_token("test-token")
        assert result is not None
        assert result.client_id == "user-123"

    def test_negative_max_cache_size_raises(self):
        """Negative max_cache_size is a caller bug and should raise."""
        with pytest.raises(ValueError, match="max_cache_size must be non-negative"):
            IntrospectionTokenVerifier(
                introspection_url="https://auth.example.com/oauth/introspect",
                client_id="test-client",
                client_secret="test-secret",
                cache_ttl_seconds=300,
                max_cache_size=-1,
            )

    async def test_cache_hit_returns_cached_result(
        self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that cached valid tokens are returned without introspection call."""
        # First call - introspection endpoint called
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read write",
                "exp": int(time.time()) + 3600,
            },
        )

        # First verification
        result1 = await verifier_with_cache.verify_token("test-token")
        assert result1 is not None
        assert result1.client_id == "user-123"

        # Verify one request was made
        requests = httpx_mock.get_requests()
        assert len(requests) == 1

        # Second verification - should use cache, no new request
        result2 = await verifier_with_cache.verify_token("test-token")
        assert result2 is not None
        assert result2.client_id == "user-123"

        # Still only one request
        requests = httpx_mock.get_requests()
        assert len(requests) == 1

    async def test_cache_returns_defensive_copy(
        self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that cached tokens are defensive copies (mutations don't leak)."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "scope": "read write",
                "exp": int(time.time()) + 3600,
                "custom_claim": "original",
            },
        )

        # First verification
        result1 = await verifier_with_cache.verify_token("test-token")
        assert result1 is not None
        assert result1.claims["custom_claim"] == "original"

        # Mutate the result (simulating request-path code adding derived claims)
        result1.claims["custom_claim"] = "mutated"
        result1.claims["new_claim"] = "injected"
        result1.scopes.append("admin")

        # Second verification - should get clean copy, not mutated one
        result2 = await verifier_with_cache.verify_token("test-token")
        assert result2 is not None
        assert result2.claims["custom_claim"] == "original"
        assert "new_claim" not in result2.claims
        assert "admin" not in result2.scopes

        # Verify they are different object instances
        assert result1 is not result2

    async def test_inactive_tokens_not_cached(
        self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that inactive tokens are NOT cached (may become valid later)."""
        # Add two responses - inactive tokens should trigger re-introspection
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": False},
        )
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": False},
        )

        # First verification
        result1 = await verifier_with_cache.verify_token("inactive-token")
        assert result1 is None

        # Verify one request was made
        requests = httpx_mock.get_requests()
        assert len(requests) == 1

        # Second verification - should NOT use cache, makes another request
        result2 = await verifier_with_cache.verify_token("inactive-token")
        assert result2 is None

        # Two requests made (inactive tokens not cached)
        requests = httpx_mock.get_requests()
        assert len(requests) == 2

    async def test_cache_disabled_makes_every_call(
        self, verifier_no_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that with caching disabled, every call makes a request."""
        # Add multiple responses for the same token
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )

        # First call
        await verifier_no_cache.verify_token("test-token")

        # Second call - should also make a request
        await verifier_no_cache.verify_token("test-token")

        # Two requests were made
        requests = httpx_mock.get_requests()
        assert len(requests) == 2

    async def test_different_tokens_are_cached_separately(
        self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that different tokens have separate cache entries."""
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-1"},
        )
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-2"},
        )

        # Verify two different tokens
        result1 = await verifier_with_cache.verify_token("token-1")
        result2 = await verifier_with_cache.verify_token("token-2")

        assert result1 is not None
        assert result1.client_id == "user-1"
        assert result2 is not None
        assert result2.client_id == "user-2"

        # Two requests were made
        requests = httpx_mock.get_requests()
        assert len(requests) == 2

        # Verify both again - no new requests
        await verifier_with_cache.verify_token("token-1")
        await verifier_with_cache.verify_token("token-2")

        requests = httpx_mock.get_requests()
        assert len(requests) == 2

    async def test_http_errors_are_not_cached(
        self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that HTTP errors are not cached (transient failures)."""
        # First call - HTTP error
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            status_code=500,
            text="Internal Server Error",
        )
        # Second call - success
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )

        # First verification - fails
        result1 = await verifier_with_cache.verify_token("test-token")
        assert result1 is None

        # Second verification - should retry since error wasn't cached
        result2 = await verifier_with_cache.verify_token("test-token")
        assert result2 is not None
        assert result2.client_id == "user-123"

        # Two requests were made
        requests = httpx_mock.get_requests()
        assert len(requests) == 2

    async def test_timeout_errors_are_not_cached(
        self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that timeout errors are not cached (transient failures)."""
        from httpx import TimeoutException

        # First call - timeout
        httpx_mock.add_exception(
            TimeoutException("Request timed out"),
            url="https://auth.example.com/oauth/introspect",
        )
        # Second call - success
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )

        # First verification - times out
        result1 = await verifier_with_cache.verify_token("test-token")
        assert result1 is None

        # Second verification - should retry since timeout wasn't cached
        result2 = await verifier_with_cache.verify_token("test-token")
        assert result2 is not None

        # Two requests were made
        requests = httpx_mock.get_requests()
        assert len(requests) == 2

    def test_token_hashing(self, verifier_with_cache: IntrospectionTokenVerifier):
        """Test that tokens are hashed consistently."""
        hash1 = verifier_with_cache._cache._hash_token("test-token")
        hash2 = verifier_with_cache._cache._hash_token("test-token")
        hash3 = verifier_with_cache._cache._hash_token("different-token")

        # Same token produces same hash
        assert hash1 == hash2
        # Different tokens produce different hashes
        assert hash1 != hash3
        # Hash is a hex string (SHA-256 = 64 chars)
        assert len(hash1) == 64

    async def test_cache_respects_token_expiration(
        self, verifier_with_cache: IntrospectionTokenVerifier, httpx_mock: HTTPXMock
    ):
        """Test that cache respects token's exp claim for TTL."""
        # Token expiring in 60 seconds (shorter than cache TTL of 300)
        short_exp = int(time.time()) + 60

        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={
                "active": True,
                "client_id": "user-123",
                "exp": short_exp,
            },
        )

        await verifier_with_cache.verify_token("test-token")

        # Check that cache entry uses the shorter expiration
        cache_key = verifier_with_cache._cache._hash_token("test-token")
        entry = verifier_with_cache._cache._entries[cache_key]
        # Cache expiration should be at or before token expiration
        assert entry.expires_at <= short_exp

    async def test_expired_cache_entry_triggers_new_introspection(
        self, httpx_mock: HTTPXMock
    ):
        """Test that expired cache entries are evicted and a new call is made."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            cache_ttl_seconds=1,  # 1 second TTL
        )

        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )
        httpx_mock.add_response(
            url="https://auth.example.com/oauth/introspect",
            method="POST",
            json={"active": True, "client_id": "user-123"},
        )

        # First call — caches the result
        await verifier.verify_token("test-token")
        assert len(httpx_mock.get_requests()) == 1

        # Expire the cache entry manually
        cache_key = verifier._cache._hash_token("test-token")
        verifier._cache._entries[cache_key].expires_at = time.time() - 1

        # Second call — cache miss, new introspection
        await verifier.verify_token("test-token")
        assert len(httpx_mock.get_requests()) == 2

    async def test_cache_eviction_at_max_size(self, httpx_mock: HTTPXMock):
        """Test that cache evicts entries when max size is reached."""
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
            cache_ttl_seconds=300,
            max_cache_size=2,
        )

        for i in range(3):
            httpx_mock.add_response(
                url="https://auth.example.com/oauth/introspect",
                method="POST",
                json={"active": True, "client_id": f"user-{i}"},
            )

        # Fill cache to capacity
        await verifier.verify_token("token-0")
        await verifier.verify_token("token-1")
        assert len(verifier._cache._entries) == 2

        # Third token should evict the oldest entry
        await verifier.verify_token("token-2")
        assert len(verifier._cache._entries) == 2

        # token-0 should have been evicted (FIFO)
        hash_0 = verifier._cache._hash_token("token-0")
        assert hash_0 not in verifier._cache._entries


class TestIntrospectionTokenVerifierIntegration:
    """Integration tests with FastMCP server."""

    async def test_verifier_used_by_fastmcp(self):
        """Test that IntrospectionTokenVerifier can be used as FastMCP auth."""
        from fastmcp import FastMCP

        # Create verifier
        verifier = IntrospectionTokenVerifier(
            introspection_url="https://auth.example.com/oauth/introspect",
            client_id="test-client",
            client_secret="test-secret",
        )

        # Create protected server - should work without errors
        mcp = FastMCP("Test Server", auth=verifier)

        @mcp.tool()
        def greet(name: str) -> str:
            """Greet someone."""
            return f"Hello, {name}!"

        # Verify the auth is set correctly
        assert mcp.auth is verifier
        tools = await mcp.list_tools()
        assert len(list(tools)) == 1
