"""
Tests for OpenAPI to MCP generator, focusing on security and edge cases.

This test suite ensures that:
1. Parameter names with invalid Python identifiers are handled safely
2. No exec() is used (security)
3. All edge cases (hyphens, dots, keywords, special chars) work correctly
4. Path traversal attacks are prevented
5. Path parameters are properly URL encoded
"""

from types import SimpleNamespace
from unittest.mock import AsyncMock, patch

import pytest

from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
    _request_auth_header,
    _request_extra_headers,
    _resolve_param_list,
    _resolve_ref,
    build_input_schema,
    create_tool_function,
    extract_parameters,
    get_base_url,
    resolve_operation_params,
)

GET_ASYNC_CLIENT_TARGET = "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.get_async_httpx_client"


def _create_mock_client(method: str, response_text: str) -> AsyncMock:
    """Utility to create a mocked async httpx client for the given method."""
    response = SimpleNamespace(text=response_text)
    client = AsyncMock()
    setattr(client, method, AsyncMock(return_value=response))
    return client


class TestCreateToolFunction:
    """Test create_tool_function with various parameter name edge cases."""

    @pytest.mark.asyncio
    async def test_hyphenated_path_parameter(self):
        """Test function with hyphenated path parameter (e.g., repository-id)."""
        operation = {
            "parameters": [
                {
                    "name": "repository-id",
                    "in": "path",
                    "required": True,
                    "schema": {"type": "string"},
                }
            ]
        }

        func = create_tool_function(
            path="/repos/{repository-id}",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        # Should not raise SyntaxError
        assert callable(func)
        assert func.__name__ == "tool_function"

        # Test calling with original parameter name
        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", '{"id": "123"}')
            mock_client.return_value = async_client

            result = await func(**{"repository-id": "test-repo"})
            assert result == '{"id": "123"}'

            # Verify URL was constructed correctly
            call_args = async_client.get.call_args
            assert "repository-id" in str(call_args[0][0]) or "test-repo" in str(
                call_args[0][0]
            )

    @pytest.mark.asyncio
    async def test_leading_digit_parameter(self):
        """Test function with parameter starting with digit (e.g., 2fa-code)."""
        operation = {
            "parameters": [
                {
                    "name": "2fa-code",
                    "in": "query",
                    "required": False,
                    "schema": {"type": "string"},
                }
            ]
        }

        func = create_tool_function(
            path="/verify",
            method="post",
            operation=operation,
            base_url="https://api.example.com",
        )

        assert callable(func)

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("post", "verified")
            mock_client.return_value = async_client

            result = await func(**{"2fa-code": "123456"})
            assert result == "verified"

            # Verify query parameter was included
            call_args = async_client.post.call_args
            assert call_args[1]["params"]["2fa-code"] == "123456"

    @pytest.mark.asyncio
    async def test_dot_in_parameter_name(self):
        """Test function with dot in parameter name (e.g., user.name)."""
        operation = {
            "parameters": [
                {
                    "name": "user.name",
                    "in": "query",
                    "required": False,
                    "schema": {"type": "string"},
                }
            ]
        }

        func = create_tool_function(
            path="/search",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        assert callable(func)

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "found")
            mock_client.return_value = async_client

            result = await func(**{"user.name": "john.doe"})
            assert result == "found"

            call_args = async_client.get.call_args
            assert call_args[1]["params"]["user.name"] == "john.doe"

    @pytest.mark.asyncio
    async def test_dollar_sign_parameter(self):
        """Test function with dollar sign parameter (OData style, e.g., $filter)."""
        operation = {
            "parameters": [
                {
                    "name": "$filter",
                    "in": "query",
                    "required": False,
                    "schema": {"type": "string"},
                }
            ]
        }

        func = create_tool_function(
            path="/entities",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        assert callable(func)

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "[]")
            mock_client.return_value = async_client

            result = await func(**{"$filter": "name eq 'test'"})
            assert result == "[]"

            call_args = async_client.get.call_args
            assert call_args[1]["params"]["$filter"] == "name eq 'test'"

    @pytest.mark.asyncio
    async def test_python_keyword_parameter(self):
        """Test function with Python keyword as parameter name (e.g., class)."""
        operation = {
            "parameters": [
                {
                    "name": "class",
                    "in": "query",
                    "required": False,
                    "schema": {"type": "string"},
                }
            ]
        }

        func = create_tool_function(
            path="/items",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        assert callable(func)

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "items")
            mock_client.return_value = async_client

            result = await func(**{"class": "premium"})
            assert result == "items"

            call_args = async_client.get.call_args
            assert call_args[1]["params"]["class"] == "premium"

    @pytest.mark.asyncio
    async def test_multiple_problematic_parameters(self):
        """Test function with multiple problematic parameter names."""
        operation = {
            "parameters": [
                {
                    "name": "repository-id",
                    "in": "path",
                    "required": True,
                    "schema": {"type": "string"},
                },
                {
                    "name": "2fa-code",
                    "in": "query",
                    "required": False,
                    "schema": {"type": "string"},
                },
                {
                    "name": "$filter",
                    "in": "query",
                    "required": False,
                    "schema": {"type": "string"},
                },
            ]
        }

        func = create_tool_function(
            path="/repos/{repository-id}",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        assert callable(func)

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "success")
            mock_client.return_value = async_client

            result = await func(
                **{
                    "repository-id": "test-repo",
                    "2fa-code": "123",
                    "$filter": "active",
                }
            )
            assert result == "success"

    @pytest.mark.asyncio
    async def test_request_body_parameter(self):
        """Test function with request body parameter."""
        operation = {
            "requestBody": {
                "required": True,
                "content": {
                    "application/json": {
                        "schema": {
                            "type": "object",
                            "properties": {"name": {"type": "string"}},
                        }
                    }
                },
            }
        }

        func = create_tool_function(
            path="/create",
            method="post",
            operation=operation,
            base_url="https://api.example.com",
        )

        assert callable(func)

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("post", "created")
            mock_client.return_value = async_client

            result = await func(**{"body": {"name": "test"}})
            assert result == "created"

            call_args = async_client.post.call_args
            assert call_args[1]["json"] == {"name": "test"}

    @pytest.mark.asyncio
    async def test_no_parameters(self):
        """Test function with no parameters."""
        operation = {}

        func = create_tool_function(
            path="/health",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        assert callable(func)

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "ok")
            mock_client.return_value = async_client

            result = await func()
            assert result == "ok"

    @pytest.mark.asyncio
    async def test_all_http_methods(self):
        """Test all supported HTTP methods."""
        methods = ["get", "post", "put", "delete", "patch"]

        for method in methods:
            operation = {
                "parameters": [
                    {
                        "name": "repository-id",
                        "in": "path",
                        "required": True,
                        "schema": {"type": "string"},
                    }
                ]
            }

            func = create_tool_function(
                path="/repos/{repository-id}",
                method=method,
                operation=operation,
                base_url="https://api.example.com",
            )

            assert callable(func)

            with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
                async_client = _create_mock_client(method, "success")
                mock_client.return_value = async_client

                result = await func(**{"repository-id": "test"})
                assert result == "success"

    def test_no_exec_usage(self):
        """Verify that create_tool_function does not use exec()."""
        import ast
        import inspect

        # Get the source code of create_tool_function
        source = inspect.getsource(create_tool_function)

        # Parse the AST
        tree = ast.parse(source)

        # Check for exec() calls
        exec_calls = []
        for node in ast.walk(tree):
            if isinstance(node, ast.Call):
                if isinstance(node.func, ast.Name) and node.func.id == "exec":
                    exec_calls.append(node)

        # Should have no exec() calls
        assert len(exec_calls) == 0, "create_tool_function should not use exec()"


class TestBuildInputSchema:
    """Test that build_input_schema preserves original parameter names."""

    def test_original_parameter_names_preserved(self):
        """Test that original parameter names are preserved in input schema."""
        operation = {
            "parameters": [
                {
                    "name": "repository-id",
                    "in": "path",
                    "required": True,
                    "schema": {"type": "string"},
                },
                {
                    "name": "2fa-code",
                    "in": "query",
                    "required": False,
                    "schema": {"type": "string"},
                },
                {
                    "name": "$filter",
                    "in": "query",
                    "required": False,
                    "schema": {"type": "string"},
                },
            ]
        }

        schema = build_input_schema(operation)

        # Original names should be in the schema
        assert "repository-id" in schema["properties"]
        assert "2fa-code" in schema["properties"]
        assert "$filter" in schema["properties"]

        # Required should include original names
        assert "repository-id" in schema["required"]


class TestExtractParameters:
    """Test parameter extraction from OpenAPI operations."""

    def test_extract_path_query_body_params(self):
        """Test extraction of different parameter types."""
        operation = {
            "parameters": [
                {"name": "repo-id", "in": "path"},
                {"name": "filter", "in": "query"},
                {"name": "data", "in": "body"},
            ],
            "requestBody": {
                "content": {"application/json": {"schema": {"type": "object"}}}
            },
        }

        path_params, query_params, body_params = extract_parameters(operation)

        assert "repo-id" in path_params
        assert "filter" in query_params
        assert "data" in body_params
        assert "body" in body_params  # From requestBody


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


class TestPathSecurity:
    """Test path traversal security and URL encoding."""

    @pytest.mark.asyncio
    async def test_should_reject_path_traversal_inputs(self):
        """Test that path traversal attacks (../admin) are rejected."""
        operation = {
            "parameters": [
                {
                    "name": "filename",
                    "in": "path",
                    "required": True,
                    "schema": {"type": "string"},
                }
            ]
        }

        tool_function = create_tool_function(
            path="/files/{filename}",
            method="GET",
            operation=operation,
            base_url="https://example.com",
        )

        response = await tool_function(**{"filename": "../admin"})

        assert "Invalid path parameter" in response

    @pytest.mark.asyncio
    async def test_should_encode_and_request_safe_path_parameters(self):
        """Test that path parameters are properly URL encoded."""
        operation = {
            "parameters": [
                {
                    "name": "filename",
                    "in": "path",
                    "required": True,
                    "schema": {"type": "string"},
                }
            ]
        }

        tool_function = create_tool_function(
            path="/files/{filename}",
            method="GET",
            operation=operation,
            base_url="https://example.com",
        )

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "dummy-response")
            mock_client.return_value = async_client

            response = await tool_function(**{"filename": "report 2024.json"})

            assert response == "dummy-response"

            # Verify URL was properly encoded
            call_args = async_client.get.call_args
            url = call_args[0][0]
            assert url == "https://example.com/files/report%202024.json"


class TestGetBaseUrl:
    """Test base URL extraction and fallback logic."""

    def test_openapi_3x_with_servers(self):
        """Test extraction from OpenAPI 3.x servers field."""
        spec = {
            "openapi": "3.0.0",
            "servers": [
                {"url": "https://api.example.com/v1"},
                {"url": "https://api-staging.example.com/v1"},
            ],
            "paths": {},
        }

        base_url = get_base_url(spec)
        assert base_url == "https://api.example.com/v1"

    def test_openapi_2x_with_host(self):
        """Test extraction from OpenAPI 2.x (Swagger) host field."""
        spec = {
            "swagger": "2.0",
            "host": "api.example.com",
            "basePath": "/v1",
            "schemes": ["https"],
            "paths": {},
        }

        base_url = get_base_url(spec)
        assert base_url == "https://api.example.com/v1"

    def test_openapi_2x_without_basepath(self):
        """Test extraction from OpenAPI 2.x without basePath."""
        spec = {
            "swagger": "2.0",
            "host": "api.example.com",
            "schemes": ["https"],
            "paths": {},
        }

        base_url = get_base_url(spec)
        assert base_url == "https://api.example.com"

    def test_openapi_2x_default_scheme(self):
        """Test that https is used as default scheme when not specified."""
        spec = {"swagger": "2.0", "host": "api.example.com", "paths": {}}

        base_url = get_base_url(spec)
        assert base_url == "https://api.example.com"

    def test_fallback_with_openapi_json_suffix(self):
        """Test fallback: derive base URL from spec_path with /openapi.json suffix."""
        spec = {
            "openapi": "3.0.0",
            "paths": {},
            # No servers field
        }
        spec_path = "http://localhost:8001/openapi.json"

        base_url = get_base_url(spec, spec_path)
        assert base_url == "http://localhost:8001"

    def test_fallback_with_swagger_json_suffix(self):
        """Test fallback: derive base URL from spec_path with /swagger.json suffix."""
        spec = {
            "swagger": "2.0",
            "paths": {},
            # No host field
        }
        spec_path = "https://api.example.com/api/swagger.json"

        base_url = get_base_url(spec, spec_path)
        assert base_url == "https://api.example.com/api"

    def test_fallback_with_openapi_yaml_suffix(self):
        """Test fallback: derive base URL from spec_path with .yaml suffix."""
        spec = {"openapi": "3.0.0", "paths": {}}
        spec_path = "http://localhost:3000/docs/openapi.yaml"

        base_url = get_base_url(spec, spec_path)
        assert base_url == "http://localhost:3000/docs"

    def test_fallback_with_generic_json_file(self):
        """Test fallback: remove last segment if it's a JSON file."""
        spec = {"openapi": "3.0.0", "paths": {}}
        spec_path = "https://example.com/v1/api-spec.json"

        base_url = get_base_url(spec, spec_path)
        assert base_url == "https://example.com/v1"

    def test_fallback_with_generic_yaml_file(self):
        """Test fallback: remove last segment if it's a YAML file."""
        spec = {"openapi": "3.0.0", "paths": {}}
        spec_path = "https://example.com/docs/api.yml"

        base_url = get_base_url(spec, spec_path)
        assert base_url == "https://example.com/docs"

    def test_no_fallback_without_spec_path(self):
        """Test that empty string is returned when no server info and no spec_path."""
        spec = {"openapi": "3.0.0", "paths": {}}

        base_url = get_base_url(spec)
        assert base_url == ""

    def test_no_fallback_with_local_file_path(self):
        """Test that fallback doesn't apply to local file paths."""
        spec = {"openapi": "3.0.0", "paths": {}}
        spec_path = "/Users/test/openapi.json"

        base_url = get_base_url(spec, spec_path)
        assert base_url == ""

    def test_priority_servers_over_fallback(self):
        """Test that servers field takes priority over spec_path fallback."""
        spec = {
            "openapi": "3.0.0",
            "servers": [{"url": "https://production.example.com"}],
            "paths": {},
        }
        spec_path = "http://localhost:8001/openapi.json"

        base_url = get_base_url(spec, spec_path)
        assert base_url == "https://production.example.com"

    def test_fallback_with_port_number(self):
        """Test fallback handles URLs with port numbers correctly."""
        spec = {"openapi": "3.0.0", "paths": {}}
        spec_path = "http://localhost:8001/openapi.json"

        base_url = get_base_url(spec, spec_path)
        assert base_url == "http://localhost:8001"

    def test_fallback_with_nested_path(self):
        """Test fallback with deeply nested spec path."""
        spec = {"openapi": "3.0.0", "paths": {}}
        spec_path = "https://api.example.com/v2/docs/api/openapi.json"

        base_url = get_base_url(spec, spec_path)
        assert base_url == "https://api.example.com/v2/docs/api"


class TestResolveRef:
    """Test $ref resolution for individual parameters."""

    def test_inline_param_returned_unchanged(self):
        """Inline params (no $ref) are returned as-is."""
        param = {"name": "owner", "in": "path", "required": True}
        component_params: dict = {}
        result = _resolve_ref(param, component_params)
        assert result is param

    def test_ref_resolved_from_components(self):
        """A $ref pointing at components/parameters is resolved correctly."""
        param = {"$ref": "#/components/parameters/per-page"}
        component_params = {
            "per-page": {
                "name": "per_page",
                "in": "query",
                "schema": {"type": "integer"},
            }
        }
        result = _resolve_ref(param, component_params)
        assert result == {
            "name": "per_page",
            "in": "query",
            "schema": {"type": "integer"},
        }

    def test_unresolvable_ref_returns_none(self):
        """A $ref whose target is absent from components returns None (not the stub)."""
        param = {"$ref": "#/components/parameters/missing-param"}
        component_params: dict = {}
        result = _resolve_ref(param, component_params)
        assert result is None

    def test_non_component_ref_returned_unchanged(self):
        """A $ref that doesn't start with #/components/parameters/ is returned as-is."""
        param = {"$ref": "#/definitions/SomeModel"}
        result = _resolve_ref(param, {})
        assert result is param


class TestResolveParamList:
    """Test batch $ref resolution with filtering."""

    def test_all_inline_params_preserved(self):
        """All inline params with names are preserved."""
        raw = [
            {"name": "owner", "in": "path"},
            {"name": "repo", "in": "path"},
        ]
        result = _resolve_param_list(raw, {})
        assert len(result) == 2
        assert result[0]["name"] == "owner"
        assert result[1]["name"] == "repo"

    def test_refs_resolved(self):
        """$ref entries are resolved against component_params."""
        raw = [
            {"$ref": "#/components/parameters/per-page"},
            {"name": "q", "in": "query"},
        ]
        component_params = {
            "per-page": {
                "name": "per_page",
                "in": "query",
                "schema": {"type": "integer"},
            }
        }
        result = _resolve_param_list(raw, component_params)
        assert len(result) == 2
        assert result[0]["name"] == "per_page"
        assert result[1]["name"] == "q"

    def test_unresolvable_refs_dropped(self):
        """Unresolvable $refs are silently dropped — no None or nameless entries."""
        raw = [
            {"$ref": "#/components/parameters/does-not-exist"},
            {"name": "q", "in": "query"},
        ]
        result = _resolve_param_list(raw, {})
        assert len(result) == 1
        assert result[0]["name"] == "q"

    def test_nameless_entries_dropped(self):
        """Entries that resolve to dicts without a 'name' key are dropped."""
        raw = [{"schema": {"type": "string"}}]  # no "name" field
        result = _resolve_param_list(raw, {})
        assert result == []


class TestResolveOperationParams:
    """Test the shared resolve_operation_params helper."""

    def test_inline_params_unchanged(self):
        """Simple inline operation with no $refs or path-level params is returned unchanged."""
        operation = {
            "operationId": "listRepos",
            "parameters": [
                {"name": "owner", "in": "path", "required": True},
                {"name": "sort", "in": "query"},
            ],
        }
        path_item = {"get": operation}
        result = resolve_operation_params(operation, path_item, {})
        names = [p["name"] for p in result["parameters"]]
        assert names == ["owner", "sort"]

    def test_ref_params_resolved(self):
        """$ref parameters in the operation are resolved from components."""
        operation = {
            "parameters": [
                {"$ref": "#/components/parameters/per-page"},
                {"name": "q", "in": "query"},
            ]
        }
        path_item = {"get": operation}
        components = {
            "parameters": {
                "per-page": {
                    "name": "per_page",
                    "in": "query",
                    "schema": {"type": "integer"},
                }
            }
        }
        result = resolve_operation_params(operation, path_item, components)
        names = [p["name"] for p in result["parameters"]]
        assert "per_page" in names
        assert "q" in names

    def test_path_level_params_merged(self):
        """Path-level parameters are merged into the operation parameters."""
        path_level_params = [
            {"name": "owner", "in": "path", "required": True},
            {"name": "repo", "in": "path", "required": True},
        ]
        operation = {"parameters": [{"name": "sort", "in": "query"}]}
        path_item = {"parameters": path_level_params, "get": operation}
        result = resolve_operation_params(operation, path_item, {})
        names = [p["name"] for p in result["parameters"]]
        assert "owner" in names
        assert "repo" in names
        assert "sort" in names

    def test_operation_level_wins_on_collision(self):
        """When path-level and operation-level define the same name+in, operation wins."""
        path_level_params = [
            {
                "name": "per_page",
                "in": "query",
                "schema": {"type": "integer"},
                "default": 30,
            }
        ]
        operation = {
            "parameters": [
                {
                    "name": "per_page",
                    "in": "query",
                    "schema": {"type": "integer"},
                    "default": 100,
                }
            ]
        }
        path_item = {"parameters": path_level_params, "get": operation}
        result = resolve_operation_params(operation, path_item, {})
        per_page_params = [p for p in result["parameters"] if p["name"] == "per_page"]
        assert len(per_page_params) == 1
        assert per_page_params[0].get("default") == 100  # operation-level value

    def test_unresolvable_refs_silently_dropped(self):
        """Unresolvable $refs are dropped — they don't poison the result with (None, None) keys."""
        operation = {
            "parameters": [
                {"$ref": "#/components/parameters/nonexistent"},
                {"name": "q", "in": "query"},
            ]
        }
        path_item = {"get": operation}
        result = resolve_operation_params(operation, path_item, {})
        names = [p["name"] for p in result["parameters"]]
        assert names == ["q"]
        # Verify no None entries slipped through
        assert all(p.get("name") is not None for p in result["parameters"])

    def test_github_style_spec_structure(self):
        """Simulate a GitHub-style spec: path-level owner+repo refs, operation-level query params."""
        component_params = {
            "owner": {
                "name": "owner",
                "in": "path",
                "required": True,
                "schema": {"type": "string"},
            },
            "repo": {
                "name": "repo",
                "in": "path",
                "required": True,
                "schema": {"type": "string"},
            },
            "per-page": {
                "name": "per_page",
                "in": "query",
                "schema": {"type": "integer"},
            },
        }
        path_level_params = [
            {"$ref": "#/components/parameters/owner"},
            {"$ref": "#/components/parameters/repo"},
        ]
        operation = {
            "operationId": "repos/list-commits",
            "parameters": [
                {"$ref": "#/components/parameters/per-page"},
                {"name": "sha", "in": "query"},
            ],
        }
        path_item = {"parameters": path_level_params, "get": operation}
        result = resolve_operation_params(
            operation, path_item, {"parameters": component_params}
        )
        names = [p["name"] for p in result["parameters"]]
        assert "owner" in names
        assert "repo" in names
        assert "per_page" in names
        assert "sha" in names
        assert len(names) == 4  # no duplicates


# ---------------------------------------------------------------------------
# Tool name sanitization for OpenAPI -> MCP
# Repro: GitHub's REST OpenAPI uses tag-namespaced operationIds like
# "actions/download-job-logs-for-workflow-run". Without sanitization the
# generated MCP tool name contains '/', which Anthropic/OpenAI/Bedrock all
# reject (^[a-zA-Z0-9_-]+$). This block guards the registration + preview
# paths against that.
# ---------------------------------------------------------------------------


class TestSanitizeOpenAPIToolName:
    def test_replaces_slashes(self):
        from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
            sanitize_openapi_tool_name,
        )

        assert (
            sanitize_openapi_tool_name("actions/download-job-logs-for-workflow-run")
            == "actions_download-job-logs-for-workflow-run"
        )

    def test_replaces_other_punctuation(self):
        from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
            sanitize_openapi_tool_name,
        )

        assert sanitize_openapi_tool_name("foo.bar:baz qux") == "foo_bar_baz_qux"

    def test_lowercases(self):
        from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
            sanitize_openapi_tool_name,
        )

        assert sanitize_openapi_tool_name("Pulls/List-Files") == "pulls_list-files"

    def test_already_valid_passes_through(self):
        from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
            sanitize_openapi_tool_name,
        )

        assert sanitize_openapi_tool_name("plain-tool_name") == "plain-tool_name"

    def test_empty_string(self):
        from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
            sanitize_openapi_tool_name,
        )

        assert sanitize_openapi_tool_name("") == ""

    def test_caps_at_128_chars(self):
        from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
            sanitize_openapi_tool_name,
        )

        out = sanitize_openapi_tool_name("a/" * 200)
        assert len(out) <= 128


class TestRegisterToolsFromOpenAPI:
    """Verify register_tools_from_openapi emits provider-safe tool names."""

    def test_github_style_operation_ids_are_sanitized(self, monkeypatch):
        import re

        from litellm.proxy._experimental.mcp_server import openapi_to_mcp_generator

        registered: list = []

        def _capture(name, description, input_schema, handler):  # noqa: ANN001
            registered.append(name)

        monkeypatch.setattr(
            openapi_to_mcp_generator.global_mcp_tool_registry,
            "register_tool",
            _capture,
        )

        spec = {
            "paths": {
                "/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": {
                    "get": {
                        "operationId": "actions/download-job-logs-for-workflow-run",
                        "summary": "Download job logs",
                    }
                },
                "/repos/{owner}/{repo}/pulls/{pull_number}/files": {
                    "get": {
                        "operationId": "pulls/list-files",
                        "summary": "List files",
                    }
                },
            }
        }

        openapi_to_mcp_generator.register_tools_from_openapi(
            spec, base_url="https://api.example.com"
        )

        assert registered, "expected at least one registered tool"
        anthropic_re = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
        for name in registered:
            assert anthropic_re.match(
                name
            ), f"tool name {name!r} violates ^[a-zA-Z0-9_-]+$"
        assert "actions_download-job-logs-for-workflow-run" in registered
        assert "pulls_list-files" in registered

    def test_missing_operation_id_uses_sanitized_method_path_fallback(
        self, monkeypatch
    ):
        import re

        from litellm.proxy._experimental.mcp_server import openapi_to_mcp_generator

        registered: list = []

        def _capture(name, description, input_schema, handler):  # noqa: ANN001
            registered.append(name)

        monkeypatch.setattr(
            openapi_to_mcp_generator.global_mcp_tool_registry,
            "register_tool",
            _capture,
        )

        spec = {
            "paths": {
                "/foo/{bar}/baz": {
                    "get": {"summary": "no operationId here"},
                }
            }
        }
        openapi_to_mcp_generator.register_tools_from_openapi(
            spec, base_url="https://api.example.com"
        )

        assert registered
        for name in registered:
            assert re.match(
                r"^[a-zA-Z0-9_-]+$", name
            ), f"fallback tool name {name!r} not sanitized"


class TestRequestExtraHeaders:
    """Tests for _request_extra_headers ContextVar forwarding in tool_function."""

    @pytest.mark.asyncio
    async def test_extra_headers_forwarded_to_upstream(self):
        """Extra headers set via ContextVar are included in the upstream request."""
        operation = {}
        func = create_tool_function(
            path="/data",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "ok")
            mock_client.return_value = async_client

            token = _request_extra_headers.set({"X-TOKEN": "secret-value"})
            try:
                result = await func()
            finally:
                _request_extra_headers.reset(token)

            assert result == "ok"
            call_args = async_client.get.call_args
            headers_sent = call_args[1]["headers"]
            assert headers_sent.get("X-TOKEN") == "secret-value"

    @pytest.mark.asyncio
    async def test_no_extra_headers_by_default(self):
        """Without setting _request_extra_headers, no extra headers are injected."""
        operation = {}
        func = create_tool_function(
            path="/data",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
            headers={"X-Static": "static-value"},
        )

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "ok")
            mock_client.return_value = async_client

            result = await func()

            assert result == "ok"
            call_args = async_client.get.call_args
            headers_sent = call_args[1]["headers"]
            assert headers_sent == {"X-Static": "static-value"}
            assert "X-TOKEN" not in headers_sent

    @pytest.mark.asyncio
    async def test_extra_headers_merged_with_static_headers(self):
        """Forwarded headers are passed through alongside non-conflicting static headers."""
        operation = {}
        func = create_tool_function(
            path="/data",
            method="post",
            operation=operation,
            base_url="https://api.example.com",
            headers={"X-Static": "static-value"},
        )

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("post", "created")
            mock_client.return_value = async_client

            token = _request_extra_headers.set({"X-TOKEN": "dynamic-value"})
            try:
                result = await func()
            finally:
                _request_extra_headers.reset(token)

            assert result == "created"
            call_args = async_client.post.call_args
            headers_sent = call_args[1]["headers"]
            assert headers_sent.get("X-Static") == "static-value"
            assert headers_sent.get("X-TOKEN") == "dynamic-value"

    @pytest.mark.asyncio
    async def test_static_headers_win_over_forwarded_on_conflict(self):
        """Static (operator) headers must override forwarded (caller) headers on name conflict."""
        operation = {}
        func = create_tool_function(
            path="/data",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
            headers={"X-Tenant": "operator-tenant"},
        )

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "ok")
            mock_client.return_value = async_client

            token = _request_extra_headers.set({"X-Tenant": "caller-spoofed"})
            try:
                result = await func()
            finally:
                _request_extra_headers.reset(token)

            assert result == "ok"
            call_args = async_client.get.call_args
            headers_sent = call_args[1]["headers"]
            assert headers_sent.get("X-Tenant") == "operator-tenant"
            assert "caller-spoofed" not in headers_sent.values()

    @pytest.mark.asyncio
    async def test_static_headers_win_case_insensitively(self):
        """Forwarded header with different casing must not bypass the static-wins rule."""
        operation = {}
        func = create_tool_function(
            path="/data",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
            headers={"X-Tenant": "operator-tenant"},
        )

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "ok")
            mock_client.return_value = async_client

            token = _request_extra_headers.set({"x-tenant": "caller-spoofed"})
            try:
                result = await func()
            finally:
                _request_extra_headers.reset(token)

            assert result == "ok"
            call_args = async_client.get.call_args
            headers_sent = call_args[1]["headers"]
            assert headers_sent.get("X-Tenant") == "operator-tenant"
            assert "x-tenant" not in headers_sent
            assert "caller-spoofed" not in headers_sent.values()

    @pytest.mark.asyncio
    async def test_auth_header_still_overrides_extra_headers(self):
        """_request_auth_header takes precedence for Authorization over extra headers."""
        operation = {}
        func = create_tool_function(
            path="/secure",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "secure-data")
            mock_client.return_value = async_client

            extra_token = _request_extra_headers.set(
                {"Authorization": "Bearer extra", "X-TOKEN": "token-value"}
            )
            auth_token = _request_auth_header.set("Bearer byok-credential")
            try:
                result = await func()
            finally:
                _request_auth_header.reset(auth_token)
                _request_extra_headers.reset(extra_token)

            assert result == "secure-data"
            call_args = async_client.get.call_args
            headers_sent = call_args[1]["headers"]
            assert headers_sent.get("Authorization") == "Bearer byok-credential"
            assert headers_sent.get("X-TOKEN") == "token-value"

    @pytest.mark.asyncio
    async def test_extra_headers_not_leaked_between_calls(self):
        """After resetting the ContextVar, subsequent calls do not see the headers."""
        operation = {}
        func = create_tool_function(
            path="/data",
            method="get",
            operation=operation,
            base_url="https://api.example.com",
        )

        with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
            async_client = _create_mock_client("get", "ok")
            mock_client.return_value = async_client

            token = _request_extra_headers.set({"X-TOKEN": "first-call"})
            _request_extra_headers.reset(token)

            await func()

            call_args = async_client.get.call_args
            headers_sent = call_args[1]["headers"]
            assert "X-TOKEN" not in headers_sent
