from dataclasses import dataclass
from typing import Annotated, Any

import pytest
from dirty_equals import HasName
from inline_snapshot import snapshot
from mcp.types import (
    AudioContent,
    BlobResourceContents,
    EmbeddedResource,
    ImageContent,
    ResourceLink,
    TextContent,
    TextResourceContents,
)
from pydantic import AnyUrl, BaseModel, Field, TypeAdapter
from typing_extensions import TypedDict

from fastmcp.tools.tool import Tool, ToolResult, _convert_to_content
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.tests import caplog_for_fastmcp
from fastmcp.utilities.types import Audio, File, Image


class TestToolFromFunction:
    def test_basic_function(self):
        """Test registering and running a basic function."""

        def add(a: int, b: int) -> int:
            """Add two numbers."""
            return a + b

        tool = Tool.from_function(add)

        assert tool.model_dump(exclude_none=True) == snapshot(
            {
                "name": "add",
                "description": "Add two numbers.",
                "tags": set(),
                "enabled": True,
                "parameters": {
                    "properties": {
                        "a": {"type": "integer"},
                        "b": {"type": "integer"},
                    },
                    "required": ["a", "b"],
                    "type": "object",
                },
                "output_schema": {
                    "properties": {"result": {"type": "integer"}},
                    "required": ["result"],
                    "type": "object",
                    "x-fastmcp-wrap-result": True,
                },
                "fn": HasName("add"),
            }
        )

    def test_meta_parameter(self):
        """Test that meta parameter is properly handled."""

        def multiply(a: int, b: int) -> int:
            """Multiply two numbers."""
            return a * b

        meta_data = {"version": "1.0", "author": "test"}
        tool = Tool.from_function(multiply, meta=meta_data)

        assert tool.meta == meta_data
        mcp_tool = tool.to_mcp_tool()

        # MCP tool includes fastmcp meta, so check that our meta is included
        assert mcp_tool.meta is not None
        assert meta_data.items() <= mcp_tool.meta.items()

    async def test_async_function(self):
        """Test registering and running an async function."""

        async def fetch_data(url: str) -> str:
            """Fetch data from URL."""
            return f"Data from {url}"

        tool = Tool.from_function(fetch_data)

        assert tool.model_dump(exclude_none=True) == snapshot(
            {
                "name": "fetch_data",
                "description": "Fetch data from URL.",
                "tags": set(),
                "enabled": True,
                "parameters": {
                    "properties": {"url": {"type": "string"}},
                    "required": ["url"],
                    "type": "object",
                },
                "output_schema": {
                    "properties": {"result": {"type": "string"}},
                    "required": ["result"],
                    "type": "object",
                    "x-fastmcp-wrap-result": True,
                },
                "fn": HasName("fetch_data"),
            }
        )

    def test_callable_object(self):
        class Adder:
            """Adds two numbers."""

            def __call__(self, x: int, y: int) -> int:
                """ignore this"""
                return x + y

        tool = Tool.from_function(Adder())

        assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot(
            {
                "name": "Adder",
                "description": "Adds two numbers.",
                "tags": set(),
                "enabled": True,
                "parameters": {
                    "properties": {
                        "x": {"type": "integer"},
                        "y": {"type": "integer"},
                    },
                    "required": ["x", "y"],
                    "type": "object",
                },
                "output_schema": {
                    "properties": {"result": {"type": "integer"}},
                    "required": ["result"],
                    "type": "object",
                    "x-fastmcp-wrap-result": True,
                },
            }
        )

    def test_async_callable_object(self):
        class Adder:
            """Adds two numbers."""

            async def __call__(self, x: int, y: int) -> int:
                """ignore this"""
                return x + y

        tool = Tool.from_function(Adder())

        assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot(
            {
                "name": "Adder",
                "description": "Adds two numbers.",
                "tags": set(),
                "enabled": True,
                "parameters": {
                    "properties": {
                        "x": {"type": "integer"},
                        "y": {"type": "integer"},
                    },
                    "required": ["x", "y"],
                    "type": "object",
                },
                "output_schema": {
                    "properties": {"result": {"type": "integer"}},
                    "required": ["result"],
                    "type": "object",
                    "x-fastmcp-wrap-result": True,
                },
            }
        )

    def test_pydantic_model_function(self):
        """Test registering a function that takes a Pydantic model."""

        class UserInput(BaseModel):
            name: str
            age: int

        def create_user(user: UserInput, flag: bool) -> dict:
            """Create a new user."""
            return {"id": 1, **user.model_dump()}

        tool = Tool.from_function(create_user)

        assert tool.model_dump(exclude_none=True) == snapshot(
            {
                "name": "create_user",
                "description": "Create a new user.",
                "tags": set(),
                "enabled": True,
                "parameters": {
                    "$defs": {
                        "UserInput": {
                            "properties": {
                                "name": {"type": "string"},
                                "age": {"type": "integer"},
                            },
                            "required": ["name", "age"],
                            "type": "object",
                        }
                    },
                    "properties": {
                        "user": {"$ref": "#/$defs/UserInput"},
                        "flag": {"type": "boolean"},
                    },
                    "required": ["user", "flag"],
                    "type": "object",
                },
                "output_schema": {"additionalProperties": True, "type": "object"},
                "fn": HasName("create_user"),
            }
        )

    async def test_tool_with_image_return(self):
        def image_tool(data: bytes) -> Image:
            return Image(data=data)

        tool = Tool.from_function(image_tool)
        assert tool.parameters["properties"]["data"]["type"] == "string"
        assert tool.output_schema is None

        result = await tool.run({"data": "test.png"})
        assert isinstance(result.content[0], ImageContent)

    async def test_tool_with_audio_return(self):
        def audio_tool(data: bytes) -> Audio:
            return Audio(data=data)

        tool = Tool.from_function(audio_tool)
        assert tool.parameters["properties"]["data"]["type"] == "string"
        assert tool.output_schema is None

        result = await tool.run({"data": "test.wav"})
        assert isinstance(result.content[0], AudioContent)

    async def test_tool_with_file_return(self):
        def file_tool(data: bytes) -> File:
            return File(data=data, format="octet-stream")

        tool = Tool.from_function(file_tool)
        assert tool.parameters["properties"]["data"]["type"] == "string"
        assert tool.output_schema is None

        result: ToolResult = await tool.run({"data": "test.bin"})
        assert result.content[0].model_dump(exclude_none=True) == snapshot(
            {
                "type": "resource",
                "resource": {
                    "uri": AnyUrl("file:///resource.octet-stream"),
                    "mimeType": "application/octet-stream",
                    "blob": "dGVzdC5iaW4=",
                },
            }
        )

    def test_non_callable_fn(self):
        with pytest.raises(TypeError, match="not a callable object"):
            Tool.from_function(1)  # type: ignore

    def test_lambda(self):
        tool = Tool.from_function(lambda x: x, name="my_tool")
        assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot(
            {
                "name": "my_tool",
                "tags": set(),
                "enabled": True,
                "parameters": {
                    "properties": {"x": {"title": "X"}},
                    "required": ["x"],
                    "type": "object",
                },
            }
        )

    def test_lambda_with_no_name(self):
        with pytest.raises(
            ValueError, match="You must provide a name for lambda functions"
        ):
            Tool.from_function(lambda x: x)

    def test_private_arguments(self):
        def add(_a: int, _b: int) -> int:
            """Add two numbers."""
            return _a + _b

        tool = Tool.from_function(add)

        assert tool.model_dump(
            exclude_none=True, exclude={"output_schema", "fn"}
        ) == snapshot(
            {
                "name": "add",
                "description": "Add two numbers.",
                "tags": set(),
                "enabled": True,
                "parameters": {
                    "properties": {
                        "_a": {"type": "integer"},
                        "_b": {"type": "integer"},
                    },
                    "required": ["_a", "_b"],
                    "type": "object",
                },
            }
        )

    def test_tool_with_varargs_not_allowed(self):
        def func(a: int, b: int, *args: int) -> int:
            """Add two numbers."""
            return a + b

        with pytest.raises(
            ValueError, match=r"Functions with \*args are not supported as tools"
        ):
            Tool.from_function(func)

    def test_tool_with_varkwargs_not_allowed(self):
        def func(a: int, b: int, **kwargs: int) -> int:
            """Add two numbers."""
            return a + b

        with pytest.raises(
            ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
        ):
            Tool.from_function(func)

    async def test_instance_method(self):
        class MyClass:
            def add(self, x: int, y: int) -> int:
                """Add two numbers."""
                return x + y

        obj = MyClass()

        tool = Tool.from_function(obj.add)
        assert "self" not in tool.parameters["properties"]

        assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot(
            {
                "name": "add",
                "description": "Add two numbers.",
                "tags": set(),
                "enabled": True,
                "parameters": {
                    "properties": {
                        "x": {"type": "integer"},
                        "y": {"type": "integer"},
                    },
                    "required": ["x", "y"],
                    "type": "object",
                },
                "output_schema": {
                    "properties": {"result": {"type": "integer"}},
                    "required": ["result"],
                    "type": "object",
                    "x-fastmcp-wrap-result": True,
                },
            }
        )

    async def test_instance_method_with_varargs_not_allowed(self):
        class MyClass:
            def add(self, x: int, y: int, *args: int) -> int:
                """Add two numbers."""
                return x + y

        obj = MyClass()

        with pytest.raises(
            ValueError, match=r"Functions with \*args are not supported as tools"
        ):
            Tool.from_function(obj.add)

    async def test_instance_method_with_varkwargs_not_allowed(self):
        class MyClass:
            def add(self, x: int, y: int, **kwargs: int) -> int:
                """Add two numbers."""
                return x + y

        obj = MyClass()

        with pytest.raises(
            ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
        ):
            Tool.from_function(obj.add)

    async def test_classmethod(self):
        class MyClass:
            x: int = 10

            @classmethod
            def call(cls, x: int, y: int) -> int:
                """Add two numbers."""
                return x + y

        tool = Tool.from_function(MyClass.call)
        assert tool.name == "call"
        assert tool.description == "Add two numbers."
        assert "x" in tool.parameters["properties"]
        assert "y" in tool.parameters["properties"]

    async def test_tool_serializer(self):
        """Test that a tool's serializer is used to serialize the result."""

        def custom_serializer(data) -> str:
            return f"Custom serializer: {data}"

        def process_list(items: list[int]) -> int:
            return sum(items)

        tool = Tool.from_function(process_list, serializer=custom_serializer)

        result = await tool.run(arguments={"items": [1, 2, 3, 4, 5]})
        # Custom serializer affects unstructured content
        assert isinstance(result.content[0], TextContent)
        assert result.content[0].text == "Custom serializer: 15"
        # Structured output should have the raw value
        assert result.structured_content == {"result": 15}


class TestToolFromFunctionOutputSchema:
    async def test_no_return_annotation(self):
        def func():
            pass

        tool = Tool.from_function(func)
        assert tool.output_schema is None

    @pytest.mark.parametrize(
        "annotation",
        [
            int,
            float,
            bool,
            str,
            int | float,
            list,
            list[int],
            list[int | float],
            dict,
            dict[str, Any],
            dict[str, int | None],
            tuple[int, str],
            set[int],
            list[tuple[int, str]],
        ],
    )
    async def test_simple_return_annotation(self, annotation):
        def func() -> annotation:  # type: ignore
            return 1

        tool = Tool.from_function(func)

        base_schema = TypeAdapter(annotation).json_schema()

        # Non-object types get wrapped
        schema_type = base_schema.get("type")
        is_object_type = schema_type == "object"

        if not is_object_type:
            # Non-object types get wrapped
            expected_schema = {
                "type": "object",
                "properties": {"result": base_schema},
                "required": ["result"],
                "x-fastmcp-wrap-result": True,
            }
            assert tool.output_schema == expected_schema
            # # Note: Parameterized test - keeping original assertion for multiple parameter values
        else:
            # Object types remain unwrapped
            assert tool.output_schema == base_schema

    @pytest.mark.parametrize(
        "annotation",
        [
            AnyUrl,
            Annotated[int, Field(ge=1)],
            Annotated[int, Field(ge=1)],
        ],
    )
    async def test_complex_return_annotation(self, annotation):
        def func() -> annotation:  # type: ignore
            return 1

        tool = Tool.from_function(func)

        base_schema = TypeAdapter(annotation).json_schema()
        expected_schema = {
            "type": "object",
            "properties": {"result": base_schema},
            "required": ["result"],
            "x-fastmcp-wrap-result": True,
        }
        assert tool.output_schema == expected_schema

    async def test_none_return_annotation(self):
        def func() -> None:
            pass

        tool = Tool.from_function(func)
        assert tool.output_schema is None

    async def test_any_return_annotation(self):
        def func() -> Any:
            return 1

        tool = Tool.from_function(func)
        assert tool.output_schema is None

    @pytest.mark.parametrize(
        "annotation, expected",
        [
            (Image, ImageContent),
            (Audio, AudioContent),
            (File, EmbeddedResource),
            (Image | int, ImageContent | int),
            (Image | Audio, ImageContent | AudioContent),
            (list[Image | Audio], list[ImageContent | AudioContent]),
        ],
    )
    async def test_converted_return_annotation(self, annotation, expected):
        def func() -> annotation:  # type: ignore
            return 1

        tool = Tool.from_function(func)
        # Image, Audio, File types don't generate output schemas since they're converted to content directly
        assert tool.output_schema is None

    async def test_dataclass_return_annotation(self):
        @dataclass
        class Person:
            name: str
            age: int

        def func() -> Person:
            return Person(name="John", age=30)

        tool = Tool.from_function(func)
        expected_schema = compress_schema(
            TypeAdapter(Person).json_schema(), prune_titles=True
        )
        assert tool.output_schema == expected_schema

    async def test_base_model_return_annotation(self):
        class Person(BaseModel):
            name: str
            age: int

        def func() -> Person:
            return Person(name="John", age=30)

        tool = Tool.from_function(func)

        assert tool.output_schema == snapshot(
            {
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"},
                },
                "required": ["name", "age"],
                "type": "object",
            }
        )

    async def test_typeddict_return_annotation(self):
        class Person(TypedDict):
            name: str
            age: int

        def func() -> Person:
            return Person(name="John", age=30)

        tool = Tool.from_function(func)
        assert tool.output_schema == snapshot(
            {
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"},
                },
                "required": ["name", "age"],
                "type": "object",
            }
        )

    async def test_unserializable_return_annotation(self):
        class Unserializable:
            def __init__(self, data: Any):
                self.data = data

        def func() -> Unserializable:
            return Unserializable(data="test")

        tool = Tool.from_function(func)
        assert tool.output_schema is None

    async def test_mixed_unserializable_return_annotation(self):
        class Unserializable:
            def __init__(self, data: Any):
                self.data = data

        def func() -> Unserializable | int:
            return Unserializable(data="test")

        tool = Tool.from_function(func)
        assert tool.output_schema is None

    async def test_provided_output_schema_takes_precedence_over_json_compatible_annotation(
        self,
    ):
        """Test that provided output_schema takes precedence over inferred schema from JSON-compatible annotation."""

        def func() -> dict[str, int]:
            return {"a": 1, "b": 2}

        # Provide a custom output schema that differs from the inferred one
        custom_schema = {"type": "object", "description": "Custom schema"}

        tool = Tool.from_function(func, output_schema=custom_schema)
        assert tool.output_schema == custom_schema

    async def test_provided_output_schema_takes_precedence_over_complex_annotation(
        self,
    ):
        """Test that provided output_schema takes precedence over inferred schema from complex annotation."""

        def func() -> list[dict[str, int | float]]:
            return [{"a": 1, "b": 2.5}]

        # Provide a custom output schema that differs from the inferred one
        custom_schema = {"type": "object", "properties": {"custom": {"type": "string"}}}

        tool = Tool.from_function(func, output_schema=custom_schema)
        assert tool.output_schema == custom_schema

    async def test_provided_output_schema_takes_precedence_over_unserializable_annotation(
        self,
    ):
        """Test that provided output_schema takes precedence over None schema from unserializable annotation."""

        class Unserializable:
            def __init__(self, data: Any):
                self.data = data

        def func() -> Unserializable:
            return Unserializable(data="test")

        # Provide a custom output schema even though the annotation is unserializable
        custom_schema = {
            "type": "object",
            "properties": {"items": {"type": "array", "items": {"type": "string"}}},
        }

        tool = Tool.from_function(func, output_schema=custom_schema)
        assert tool.output_schema == custom_schema

    async def test_provided_output_schema_takes_precedence_over_no_annotation(self):
        """Test that provided output_schema takes precedence over None schema from no annotation."""

        def func():
            return "hello"

        # Provide a custom output schema even though there's no return annotation
        custom_schema = {
            "type": "object",
            "properties": {"value": {"type": "number", "minimum": 0}},
        }

        tool = Tool.from_function(func, output_schema=custom_schema)
        assert tool.output_schema == custom_schema

    async def test_provided_output_schema_takes_precedence_over_converted_annotation(
        self,
    ):
        """Test that provided output_schema takes precedence over converted schema from Image/Audio/File annotations."""

        def func() -> Image:
            return Image(data=b"test")

        # Provide a custom output schema that differs from the converted ImageContent schema
        custom_schema = {
            "type": "object",
            "properties": {"custom_image": {"type": "string"}},
        }

        tool = Tool.from_function(func, output_schema=custom_schema)
        assert tool.output_schema == custom_schema

    async def test_provided_output_schema_takes_precedence_over_union_annotation(self):
        """Test that provided output_schema takes precedence over inferred schema from union annotation."""

        def func() -> str | int | None:
            return "hello"

        # Provide a custom output schema that differs from the inferred union schema
        custom_schema = {"type": "object", "properties": {"flag": {"type": "boolean"}}}

        tool = Tool.from_function(func, output_schema=custom_schema)
        assert tool.output_schema == custom_schema

    async def test_provided_output_schema_takes_precedence_over_pydantic_annotation(
        self,
    ):
        """Test that provided output_schema takes precedence over inferred schema from Pydantic model annotation."""

        class Person(BaseModel):
            name: str
            age: int

        def func() -> Person:
            return Person(name="John", age=30)

        # Provide a custom output schema that differs from the inferred Person schema
        custom_schema = {
            "type": "object",
            "properties": {"numbers": {"type": "array", "items": {"type": "number"}}},
        }

        tool = Tool.from_function(func, output_schema=custom_schema)
        assert tool.output_schema == custom_schema

    async def test_output_schema_false_allows_automatic_structured_content(self):
        """Test that output_schema=False still allows automatic structured content for dict-like objects."""

        def func() -> dict[str, str]:
            return {"message": "Hello, world!"}

        tool = Tool.from_function(func, output_schema=None)
        assert tool.output_schema is None

        result = await tool.run({})
        # Dict objects automatically become structured content even without schema
        assert result.structured_content == {"message": "Hello, world!"}
        assert len(result.content) == 1
        assert result.content[0].text == '{"message":"Hello, world!"}'  # type: ignore[attr-defined]

    async def test_output_schema_none_disables_structured_content(self):
        """Test that output_schema=None explicitly disables structured content."""

        def func() -> int:
            return 42

        tool = Tool.from_function(func, output_schema=None)
        assert tool.output_schema is None

        result = await tool.run({})
        assert result.structured_content is None
        assert len(result.content) == 1
        assert result.content[0].text == "42"  # type: ignore[attr-defined]

    async def test_output_schema_inferred_when_not_specified(self):
        """Test that output schema is inferred when not explicitly specified."""

        def func() -> int:
            return 42

        # Don't specify output_schema - should infer and wrap
        tool = Tool.from_function(func)
        assert tool.output_schema == snapshot(
            {
                "properties": {"result": {"type": "integer"}},
                "required": ["result"],
                "type": "object",
                "x-fastmcp-wrap-result": True,
            }
        )

        result = await tool.run({})
        assert result.structured_content == {"result": 42}

    async def test_explicit_object_schema_with_dict_return(self):
        """Test that explicit object schemas work when function returns a dict."""

        def func() -> dict[str, int]:
            return {"value": 42}

        # Provide explicit object schema
        explicit_schema = {
            "type": "object",
            "properties": {"value": {"type": "integer", "minimum": 0}},
        }
        tool = Tool.from_function(func, output_schema=explicit_schema)
        assert tool.output_schema == explicit_schema  # Schema not wrapped
        assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema

        result = await tool.run({})
        # Dict result with object schema is used directly
        assert result.structured_content == {"value": 42}
        assert result.content[0].text == '{"value":42}'  # type: ignore[attr-defined]

    async def test_explicit_object_schema_with_non_dict_return_fails(self):
        """Test that explicit object schemas fail when function returns non-dict."""

        def func() -> int:
            return 42

        # Provide explicit object schema but return non-dict
        explicit_schema = {
            "type": "object",
            "properties": {"value": {"type": "integer"}},
        }
        tool = Tool.from_function(func, output_schema=explicit_schema)

        # Should fail because int is not dict-compatible with object schema
        with pytest.raises(ValueError, match="structured_content must be a dict"):
            await tool.run({})

    async def test_object_output_schema_not_wrapped(self):
        """Test that object-type output schemas are never wrapped."""

        def func() -> dict[str, int]:
            return {"value": 42}

        # Object schemas should never be wrapped, even when inferred
        tool = Tool.from_function(func)
        expected_schema = TypeAdapter(dict[str, int]).json_schema()
        assert tool.output_schema == expected_schema  # Not wrapped
        assert tool.output_schema and "x-fastmcp-wrap-result" not in tool.output_schema

        result = await tool.run({})
        assert result.structured_content == {"value": 42}  # Direct value

    async def test_structured_content_interaction_with_wrapping(self):
        """Test that structured content works correctly with schema wrapping."""

        def func() -> str:
            return "hello"

        # Inferred schema should wrap string type
        tool = Tool.from_function(func)
        assert tool.output_schema == snapshot(
            {
                "properties": {"result": {"type": "string"}},
                "required": ["result"],
                "type": "object",
                "x-fastmcp-wrap-result": True,
            }
        )

        result = await tool.run({})
        # Unstructured content
        assert len(result.content) == 1
        assert result.content[0].text == "hello"  # type: ignore[attr-defined]
        # Structured content should be wrapped
        assert result.structured_content == {"result": "hello"}

    async def test_structured_content_with_explicit_object_schema(self):
        """Test structured content with explicit object schema."""

        def func() -> dict[str, str]:
            return {"greeting": "hello"}

        # Provide explicit object schema
        explicit_schema = {
            "type": "object",
            "properties": {"greeting": {"type": "string"}},
            "required": ["greeting"],
        }
        tool = Tool.from_function(func, output_schema=explicit_schema)
        assert tool.output_schema == explicit_schema

        result = await tool.run({})
        # Should use direct value since explicit schema doesn't have wrap marker
        assert result.structured_content == {"greeting": "hello"}

    async def test_structured_content_with_custom_wrapper_schema(self):
        """Test structured content with custom schema that includes wrap marker."""

        def func() -> str:
            return "world"

        # Custom schema with wrap marker
        custom_schema = {
            "type": "object",
            "properties": {"message": {"type": "string"}},
            "x-fastmcp-wrap-result": True,
        }
        tool = Tool.from_function(func, output_schema=custom_schema)
        assert tool.output_schema == custom_schema

        result = await tool.run({})
        # Should wrap with "result" key due to wrap marker
        assert result.structured_content == {"result": "world"}

    async def test_none_vs_false_output_schema_behavior(self):
        """Test the difference between None and False for output_schema."""

        def func() -> int:
            return 123

        # None should disable
        tool_none = Tool.from_function(func, output_schema=None)
        assert tool_none.output_schema is None

        # Default (NotSet) should infer from return type
        tool_default = Tool.from_function(func)
        assert (
            tool_default.output_schema is not None
        )  # Should infer schema from dict return type

        # Different behavior: None vs inferred
        result_none = await tool_none.run({})
        result_default = await tool_default.run({})

        # None should still try fallback generation but fail for non-dict
        assert result_none.structured_content is None  # Fallback fails for int
        # Default should use proper schema and wrap the result
        assert result_default.structured_content == {
            "result": 123
        }  # Schema-based generation with wrapping
        assert result_none.content[0].text == result_default.content[0].text == "123"  # type: ignore[attr-defined]

    async def test_non_object_output_schema_raises_error(self):
        """Test that providing a non-object output schema raises a ValueError."""

        def func() -> int:
            return 42

        # Test various non-object schemas that should raise errors
        non_object_schemas = [
            {"type": "string"},
            {"type": "integer", "minimum": 0},
            {"type": "number"},
            {"type": "boolean"},
            {"type": "array", "items": {"type": "string"}},
        ]

        for schema in non_object_schemas:
            with pytest.raises(
                ValueError, match='Output schemas must have "type" set to "object"'
            ):
                Tool.from_function(func, output_schema=schema)


class SampleModel(BaseModel):
    x: int
    y: str


class TestConvertResultToContent:
    """Tests for the _convert_to_content helper function."""

    @pytest.mark.parametrize(
        argnames=("result", "expected"),
        argvalues=[
            (True, "true"),
            ("hello", "hello"),
            (123, "123"),
            (123.45, "123.45"),
            ({"key": "value"}, '{"key":"value"}'),
            (
                SampleModel(x=1, y="hello"),
                '{"x":1,"y":"hello"}',
            ),
        ],
        ids=[
            "boolean",
            "string",
            "integer",
            "float",
            "object",
            "basemodel",
        ],
    )
    def test_convert_singular(self, result, expected):
        """Test that a single item is converted to a TextContent."""
        converted = _convert_to_content(result)
        assert converted == [TextContent(type="text", text=expected)]

    @pytest.mark.parametrize(
        argnames=("result", "expected_text"),
        argvalues=[
            ([None], "[null]"),
            ([None, None], "[null,null]"),
            ([True], "[true]"),
            ([True, False], "[true,false]"),
            (["hello"], '["hello"]'),
            (["hello", "world"], '["hello","world"]'),
            ([123], "[123]"),
            ([123, 456], "[123,456]"),
            ([123.45], "[123.45]"),
            ([123.45, 456.78], "[123.45,456.78]"),
            ([{"key": "value"}], '[{"key":"value"}]'),
            (
                [{"key": "value"}, {"key2": "value2"}],
                '[{"key":"value"},{"key2":"value2"}]',
            ),
            ([SampleModel(x=1, y="hello")], '[{"x":1,"y":"hello"}]'),
            (
                [SampleModel(x=1, y="hello"), SampleModel(x=2, y="world")],
                '[{"x":1,"y":"hello"},{"x":2,"y":"world"}]',
            ),
            ([1, "two", None, {"c": 3}, False], '[1,"two",null,{"c":3},false]'),
        ],
        ids=[
            "none",
            "none_many",
            "boolean",
            "boolean_many",
            "string",
            "string_many",
            "integer",
            "integer_many",
            "float",
            "float_many",
            "object",
            "object_many",
            "basemodel",
            "basemodel_many",
            "mixed",
        ],
    )
    def test_convert_list(self, result, expected_text):
        """Test that a list is converted to a TextContent."""
        converted = _convert_to_content(result)
        assert converted == [TextContent(type="text", text=expected_text)]

    @pytest.mark.parametrize(
        argnames="content_block",
        argvalues=[
            (TextContent(type="text", text="hello")),
            (ImageContent(type="image", data="fakeimagedata", mimeType="image/png")),
            (AudioContent(type="audio", data="fakeaudiodata", mimeType="audio/mpeg")),
            (
                ResourceLink(
                    type="resource_link",
                    name="test resource",
                    uri=AnyUrl("resource://test"),
                )
            ),
            (
                EmbeddedResource(
                    type="resource",
                    resource=TextResourceContents(
                        uri=AnyUrl("resource://test"),
                        mimeType="text/plain",
                        text="resource content",
                    ),
                )
            ),
        ],
        ids=["text", "image", "audio", "resource link", "embedded resource"],
    )
    def test_convert_content_block(self, content_block):
        converted = _convert_to_content(content_block)
        assert converted == [content_block]

        converted = _convert_to_content([content_block, content_block])
        assert converted == [content_block, content_block]

    @pytest.mark.parametrize(
        argnames=("result", "expected"),
        argvalues=[
            (
                Image(data=b"fakeimagedata"),
                [
                    ImageContent(
                        type="image", data="ZmFrZWltYWdlZGF0YQ==", mimeType="image/png"
                    )
                ],
            ),
            (
                Audio(data=b"fakeaudiodata"),
                [
                    AudioContent(
                        type="audio", data="ZmFrZWF1ZGlvZGF0YQ==", mimeType="audio/wav"
                    )
                ],
            ),
            (
                File(data=b"filedata", format="octet-stream"),
                [
                    EmbeddedResource(
                        type="resource",
                        resource=BlobResourceContents(
                            uri=AnyUrl("file:///resource.octet-stream"),
                            blob="ZmlsZWRhdGE=",
                            mimeType="application/octet-stream",
                        ),
                    )
                ],
            ),
        ],
        ids=["image", "audio", "file"],
    )
    def test_convert_helpers(self, result, expected):
        converted = _convert_to_content(result)
        assert converted == expected

    def test_convert_mixed_content(self):
        result = [
            "hello",
            123,
            123.45,
            {"key": "value"},
            SampleModel(x=1, y="hello"),
            Image(data=b"fakeimagedata"),
            Audio(data=b"fakeaudiodata"),
            ResourceLink(
                type="resource_link",
                name="test resource",
                uri=AnyUrl("resource://test"),
            ),
            EmbeddedResource(
                type="resource",
                resource=TextResourceContents(
                    uri=AnyUrl("resource://test"),
                    mimeType="text/plain",
                    text="resource content",
                ),
            ),
        ]

        converted = _convert_to_content(result)

        assert converted == snapshot(
            [
                TextContent(type="text", text="hello"),
                TextContent(type="text", text="123"),
                TextContent(type="text", text="123.45"),
                TextContent(type="text", text='{"key":"value"}'),
                TextContent(type="text", text='{"x":1,"y":"hello"}'),
                ImageContent(
                    type="image", data="ZmFrZWltYWdlZGF0YQ==", mimeType="image/png"
                ),
                AudioContent(
                    type="audio", data="ZmFrZWF1ZGlvZGF0YQ==", mimeType="audio/wav"
                ),
                ResourceLink(
                    name="test resource",
                    uri=AnyUrl("resource://test"),
                    type="resource_link",
                ),
                EmbeddedResource(
                    type="resource",
                    resource=TextResourceContents(
                        uri=AnyUrl("resource://test"),
                        mimeType="text/plain",
                        text="resource content",
                    ),
                ),
            ]
        )

    def test_empty_list(self):
        """Test that an empty list results in an empty list."""
        result = _convert_to_content([])
        assert isinstance(result, list)
        assert len(result) == 0

    def test_empty_dict(self):
        """Test that an empty dictionary is converted to TextContent."""
        result = _convert_to_content({})
        assert isinstance(result, list)
        assert len(result) == 1
        assert isinstance(result[0], TextContent)
        assert result[0].text == "{}"

    def test_custom_serializer(self):
        """Test that a custom serializer is used for non-MCP types."""

        def custom_serializer(data):
            return f"Serialized: {data}"

        result = _convert_to_content({"a": 1}, serializer=custom_serializer)

        assert result == snapshot(
            [TextContent(type="text", text="Serialized: {'a': 1}")]
        )

    def test_custom_serializer_error_fallback(self, caplog):
        """Test that if a custom serializer fails, it falls back to the default."""

        def custom_serializer_that_fails(data):
            raise ValueError("Serialization failed")

        with caplog_for_fastmcp(caplog):
            result = _convert_to_content(
                {"a": 1}, serializer=custom_serializer_that_fails
            )

        assert isinstance(result, list)
        assert result == snapshot([TextContent(type="text", text='{"a":1}')])

        assert "Error serializing tool result" in caplog.text


class TestAutomaticStructuredContent:
    """Tests for automatic structured content generation based on return types."""

    async def test_dict_return_creates_structured_content_without_schema(self):
        """Test that dict returns automatically create structured content even without output schema."""

        def get_user_data(user_id: str) -> dict:
            return {"name": "Alice", "age": 30, "active": True}

        # No explicit output schema provided
        tool = Tool.from_function(get_user_data)

        result = await tool.run({"user_id": "123"})

        # Should have both content and structured content
        assert len(result.content) == 1
        assert isinstance(result.content[0], TextContent)
        assert result.structured_content == {"name": "Alice", "age": 30, "active": True}

    async def test_dataclass_return_creates_structured_content_without_schema(self):
        """Test that dataclass returns automatically create structured content even without output schema."""

        @dataclass
        class UserProfile:
            name: str
            age: int
            email: str

        def get_profile(user_id: str) -> UserProfile:
            return UserProfile(name="Bob", age=25, email="bob@example.com")

        # No explicit output schema, but dataclass should still create structured content
        tool = Tool.from_function(get_profile, output_schema=None)

        result = await tool.run({"user_id": "456"})

        # Should have both content and structured content
        assert len(result.content) == 1
        assert isinstance(result.content[0], TextContent)
        # Dataclass should serialize to dict
        assert result.structured_content == {
            "name": "Bob",
            "age": 25,
            "email": "bob@example.com",
        }

    async def test_pydantic_model_return_creates_structured_content_without_schema(
        self,
    ):
        """Test that Pydantic model returns automatically create structured content even without output schema."""

        class UserData(BaseModel):
            username: str
            score: int
            verified: bool

        def get_user_stats(user_id: str) -> UserData:
            return UserData(username="charlie", score=100, verified=True)

        # Explicitly set output schema to None to test automatic structured content
        tool = Tool.from_function(get_user_stats, output_schema=None)

        result = await tool.run({"user_id": "789"})

        # Should have both content and structured content
        assert len(result.content) == 1
        assert isinstance(result.content[0], TextContent)
        # Pydantic model should serialize to dict
        assert result.structured_content == {
            "username": "charlie",
            "score": 100,
            "verified": True,
        }

    async def test_int_return_no_structured_content_without_schema(self):
        """Test that int returns don't create structured content without output schema."""

        def calculate_sum(a: int, b: int):
            """No return annotation."""
            return a + b

        # No output schema
        tool = Tool.from_function(calculate_sum)

        result = await tool.run({"a": 5, "b": 3})

        # Should only have content, no structured content
        assert len(result.content) == 1
        assert isinstance(result.content[0], TextContent)
        assert result.content[0].text == "8"
        assert result.structured_content is None

    async def test_str_return_no_structured_content_without_schema(self):
        """Test that str returns don't create structured content without output schema."""

        def get_greeting(name: str):
            """No return annotation."""
            return f"Hello, {name}!"

        # No output schema
        tool = Tool.from_function(get_greeting)

        result = await tool.run({"name": "World"})

        # Should only have content, no structured content
        assert len(result.content) == 1
        assert isinstance(result.content[0], TextContent)
        assert result.content[0].text == "Hello, World!"
        assert result.structured_content is None

    async def test_list_return_no_structured_content_without_schema(self):
        """Test that list returns don't create structured content without output schema."""

        def get_numbers():
            """No return annotation."""
            return [1, 2, 3, 4, 5]

        # No output schema
        tool = Tool.from_function(get_numbers)

        result = await tool.run({})

        assert result.structured_content is None
        assert result.content == snapshot(
            [TextContent(type="text", text="[1,2,3,4,5]")]
        )

    async def test_audio_return_creates_no_structured_content(self):
        """Test that audio returns don't create structured content."""

        def get_audio() -> AudioContent:
            """No return annotation."""
            return Audio(data=b"fakeaudiodata").to_audio_content()

        # No output schema
        tool = Tool.from_function(get_audio)

        result = await tool.run({})

        assert result.content == snapshot(
            [
                AudioContent(
                    type="audio", data="ZmFrZWF1ZGlvZGF0YQ==", mimeType="audio/wav"
                )
            ]
        )
        assert result.structured_content is None

    async def test_int_return_with_schema_creates_structured_content(self):
        """Test that int returns DO create structured content when there's an output schema."""

        def calculate_sum(a: int, b: int) -> int:
            """With return annotation."""
            return a + b

        # Output schema should be auto-generated from annotation
        tool = Tool.from_function(calculate_sum)
        assert tool.output_schema is not None

        result = await tool.run({"a": 5, "b": 3})

        # Should have both content and structured content
        assert len(result.content) == 1
        assert isinstance(result.content[0], TextContent)
        assert result.content[0].text == "8"
        assert result.structured_content == {"result": 8}

    async def test_client_automatic_deserialization_with_dict_result(self):
        """Test that clients automatically deserialize dict results from structured content."""
        from fastmcp import FastMCP
        from fastmcp.client import Client

        mcp = FastMCP()

        @mcp.tool
        def get_user_info(user_id: str) -> dict:
            return {"name": "Alice", "age": 30, "active": True}

        async with Client(mcp) as client:
            result = await client.call_tool("get_user_info", {"user_id": "123"})

            # Client should provide the deserialized data
            assert result.data == {"name": "Alice", "age": 30, "active": True}
            assert result.structured_content == {
                "name": "Alice",
                "age": 30,
                "active": True,
            }
            assert len(result.content) == 1

    async def test_client_automatic_deserialization_with_dataclass_result(self):
        """Test that clients automatically deserialize dataclass results from structured content."""
        from fastmcp import FastMCP
        from fastmcp.client import Client

        mcp = FastMCP()

        @dataclass
        class UserProfile:
            name: str
            age: int
            verified: bool

        @mcp.tool
        def get_profile(user_id: str) -> UserProfile:
            return UserProfile(name="Bob", age=25, verified=True)

        async with Client(mcp) as client:
            result = await client.call_tool("get_profile", {"user_id": "456"})

            # Client should deserialize back to a dataclass (but type name is lost with title pruning)
            assert result.data.__class__.__name__ == "Root"
            assert result.data.name == "Bob"
            assert result.data.age == 25
            assert result.data.verified is True


class TestUnionReturnTypes:
    """Tests for tools with union return types."""

    async def test_dataclass_union_string_works(self):
        """Test that union of dataclass and string works correctly."""

        @dataclass
        class Data:
            value: int

        def get_data(return_error: bool) -> Data | str:
            if return_error:
                return "error occurred"
            return Data(value=42)

        tool = Tool.from_function(get_data)

        # Test returning dataclass
        result1 = await tool.run({"return_error": False})
        assert result1.structured_content == {"result": {"value": 42}}

        # Test returning string
        result2 = await tool.run({"return_error": True})
        assert result2.structured_content == {"result": "error occurred"}


class TestSerializationAlias:
    """Tests for Pydantic field serialization alias support in tool output schemas."""

    def test_output_schema_respects_serialization_alias(self):
        """Test that Tool.from_function generates output schema using serialization alias."""
        from pydantic import AliasChoices, BaseModel, Field

        class Component(BaseModel):
            """Model with multiple validation aliases but specific serialization alias."""

            component_id: str = Field(
                validation_alias=AliasChoices("id", "componentId"),
                serialization_alias="componentId",
                description="The ID of the component",
            )

        async def get_component(
            component_id: str,
        ) -> Annotated[Component, Field(description="The component.")]:
            # API returns data with 'id' field
            api_data = {"id": component_id}
            return Component.model_validate(api_data)

        tool = Tool.from_function(get_component, name="get-component")

        # The output schema should use the serialization alias 'componentId'
        # not the first validation alias 'id'
        assert tool.output_schema is not None

        # Check the wrapped result schema
        assert "properties" in tool.output_schema
        assert "result" in tool.output_schema["properties"]
        assert "$defs" in tool.output_schema

        # Find the Component definition
        component_def = list(tool.output_schema["$defs"].values())[0]

        # Should have 'componentId' not 'id' in properties
        assert "componentId" in component_def["properties"]
        assert "id" not in component_def["properties"]

        # Should require 'componentId' not 'id'
        assert "componentId" in component_def["required"]
        assert "id" not in component_def.get("required", [])

    async def test_tool_execution_with_serialization_alias(self):
        """Test that tool execution works correctly with serialization aliases."""
        from pydantic import AliasChoices, BaseModel, Field

        from fastmcp import Client, FastMCP

        class Component(BaseModel):
            """Model with multiple validation aliases but specific serialization alias."""

            component_id: str = Field(
                validation_alias=AliasChoices("id", "componentId"),
                serialization_alias="componentId",
                description="The ID of the component",
            )

        mcp = FastMCP("TestServer")

        @mcp.tool
        async def get_component(
            component_id: str,
        ) -> Annotated[Component, Field(description="The component.")]:
            # API returns data with 'id' field
            api_data = {"id": component_id}
            return Component.model_validate(api_data)

        async with Client(mcp) as client:
            # Execute the tool - this should work without validation errors
            result = await client.call_tool(
                "get_component", {"component_id": "test123"}
            )

            # The result should contain the serialized form with 'componentId'
            assert result.structured_content is not None
            assert result.structured_content["result"]["componentId"] == "test123"
            assert "id" not in result.structured_content["result"]


class TestToolTitle:
    """Tests for tool title functionality."""

    def test_tool_with_title(self):
        """Test that tools can have titles and they appear in MCP conversion."""

        def calculate(x: int, y: int) -> int:
            """Calculate the sum of two numbers."""
            return x + y

        tool = Tool.from_function(
            calculate,
            name="calc",
            title="Advanced Calculator Tool",
            description="Custom description",
        )

        assert tool.name == "calc"
        assert tool.title == "Advanced Calculator Tool"
        assert tool.description == "Custom description"

        # Test MCP conversion includes title
        mcp_tool = tool.to_mcp_tool()
        assert mcp_tool.name == "calc"
        assert (
            hasattr(mcp_tool, "title") and mcp_tool.title == "Advanced Calculator Tool"
        )

    def test_tool_without_title(self):
        """Test that tools without titles use name as display name."""

        def multiply(a: int, b: int) -> int:
            return a * b

        tool = Tool.from_function(multiply)

        assert tool.name == "multiply"
        assert tool.title is None

        # Test MCP conversion doesn't include title when None
        mcp_tool = tool.to_mcp_tool()
        assert mcp_tool.name == "multiply"
        assert not hasattr(mcp_tool, "title") or mcp_tool.title is None

    def test_tool_title_priority(self):
        """Test that explicit title takes priority over annotations.title."""
        from mcp.types import ToolAnnotations

        def divide(x: int, y: int) -> float:
            """Divide two numbers."""
            return x / y

        # Test with both explicit title and annotations.title
        annotations = ToolAnnotations(title="Annotation Title")
        tool = Tool.from_function(
            divide,
            name="div",
            title="Explicit Title",
            annotations=annotations,
        )

        assert tool.title == "Explicit Title"
        assert tool.annotations is not None
        assert tool.annotations.title == "Annotation Title"

        # Explicit title should take priority
        mcp_tool = tool.to_mcp_tool()
        assert mcp_tool.title == "Explicit Title"

    def test_tool_annotations_title_fallback(self):
        """Test that annotations.title is used when no explicit title is provided."""
        from mcp.types import ToolAnnotations

        def modulo(x: int, y: int) -> int:
            """Get modulo of two numbers."""
            return x % y

        # Test with only annotations.title (no explicit title)
        annotations = ToolAnnotations(title="Annotation Title")
        tool = Tool.from_function(
            modulo,
            name="mod",
            annotations=annotations,
        )

        assert tool.title is None
        assert tool.annotations is not None
        assert tool.annotations.title == "Annotation Title"

        # Should fall back to annotations.title
        mcp_tool = tool.to_mcp_tool()
        assert mcp_tool.title == "Annotation Title"
