# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tests for the Pydantic-based function declaration builder.

These tests verify that the simplified Pydantic approach generates correct
JSON schemas for various function signatures, including edge cases.
"""

from __future__ import annotations

from collections.abc import Sequence
from enum import Enum
from typing import Any
from typing import Literal
from typing import Optional

from absl.testing import parameterized
from google.adk.tools._function_tool_declarations import build_function_declaration_with_json_schema
from google.adk.tools.tool_context import ToolContext
from pydantic import BaseModel
from pydantic import Field
from pydantic.dataclasses import dataclass as pyd_dataclass


class Color(Enum):
  """A simple enum for testing."""

  RED = "red"
  GREEN = "green"
  BLUE = "blue"


class Priority(Enum):
  """An integer enum for testing."""

  LOW = 1
  MEDIUM = 2
  HIGH = 3


class Address(BaseModel):
  """A Pydantic model for nested object testing."""

  street: str = Field(..., description="Street address")
  city: str = Field(..., description="City name")
  zip_code: str = Field(..., pattern=r"^\d{5}$", description="US ZIP code")


class Person(BaseModel):
  """A Pydantic model with nested model."""

  name: str
  age: int
  address: Optional[Address] = None


@pyd_dataclass
class Window:
  """A Pydantic dataclass for testing."""

  width: int
  height: int


class TestBasicTypes(parameterized.TestCase):
  """Tests for basic Python types."""

  @parameterized.named_parameters(
      (
          "string",
          lambda name: f"Hello, {name}!",
          {"name": {"title": "Name", "type": "string"}},
          {"type": "string"},
      ),
      (
          "integer",
          lambda n: n * 2,
          {"n": {"title": "N", "type": "integer"}},
          {"type": "integer"},
      ),
      (
          "float",
          lambda x: x * x,
          {"x": {"title": "X", "type": "number"}},
          {"type": "number"},
      ),
      (
          "boolean",
          lambda enabled: not enabled,
          {"enabled": {"title": "Enabled", "type": "boolean"}},
          {"type": "boolean"},
      ),
  )
  def test_basic_parameter_types(
      self, func, expected_param_props, expected_response_schema
  ):
    """Test functions with single basic type parameters."""
    # We need to define the functions within the test or use types from typing
    # to properly capture annotations. For simplicity, we'll define them here.
    if func.__code__.co_varnames[0] == "name":

      def test_func(name: str) -> str:
        return func(name)

    elif func.__code__.co_varnames[0] == "n":

      def test_func(n: int) -> int:
        return func(n)

    elif func.__code__.co_varnames[0] == "x":

      def test_func(x: float) -> float:
        return func(x)

    elif func.__code__.co_varnames[0] == "enabled":

      def test_func(enabled: bool) -> bool:
        return func(enabled)

    else:
      raise ValueError("Unexpected function signature")

    decl = build_function_declaration_with_json_schema(test_func)

    self.assertIsNotNone(decl.parameters_json_schema)
    schema = decl.parameters_json_schema

    self.assertEqual(schema["properties"], expected_param_props)
    self.assertEqual(decl.response_json_schema, expected_response_schema)
    self.assertEqual(set(schema["required"]), set(expected_param_props.keys()))

  def test_string_parameter_details(self):
    """Test function with string parameter details."""

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

    decl = build_function_declaration_with_json_schema(greet)

    self.assertEqual(decl.name, "greet")
    self.assertEqual(decl.description, "Greet someone by name.")
    self.assertEqual(
        decl.parameters_json_schema,
        {
            "type": "object",
            "properties": {
                "name": {
                    "title": "Name",
                    "type": "string",
                }
            },
            "required": ["name"],
            "title": "greetParams",
        },
    )

    self.assertEqual(decl.response_json_schema, {"type": "string"})

  def test_multiple_parameters(self):
    """Test function with multiple parameters of different types."""

    def create_user(name: str, age: int, active: bool) -> str:
      """Create a new user."""
      return f"Created {name}"

    decl = build_function_declaration_with_json_schema(create_user)
    schema = decl.parameters_json_schema

    self.assertLen(schema["properties"], 3)
    self.assertEqual(schema["properties"]["name"]["type"], "string")
    self.assertEqual(schema["properties"]["age"]["type"], "integer")
    self.assertEqual(schema["properties"]["active"]["type"], "boolean")
    self.assertEqual(set(schema["required"]), {"name", "age", "active"})
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )


class TestDefaultValues(parameterized.TestCase):
  """Tests for parameters with default values."""

  def test_string_with_default(self):
    """Test string parameter with default value."""

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

    decl = build_function_declaration_with_json_schema(greet)
    schema = decl.parameters_json_schema

    assert schema["properties"]["name"]["default"] == "World"
    self.assertNotIn("name", schema.get("required", []))
    assert decl.response_json_schema == {
        "type": "string",
    }

  def test_int_with_default(self):
    """Test integer parameter with default value."""

    def repeat(text: str, times: int = 3) -> str:
      """Repeat text."""
      return text * times

    decl = build_function_declaration_with_json_schema(repeat)
    schema = decl.parameters_json_schema

    # times should have default, text should be required
    assert "text" in schema["required"]
    assert schema["properties"]["times"]["default"] == 3
    self.assertNotIn("times", schema.get("required", []))
    assert decl.response_json_schema == {
        "type": "string",
    }

  def test_none_default(self):
    """Test parameter with None as default."""

    def search(query: str, limit: Optional[int] = None) -> str:
      """Search for something."""
      return query

    decl = build_function_declaration_with_json_schema(search)
    schema = decl.parameters_json_schema

    assert "query" in schema["required"]
    # limit should not be required since it has default None
    self.assertNotIn("limit", schema.get("required", []))
    assert schema["properties"]["limit"]["default"] is None
    assert decl.response_json_schema == {
        "type": "string",
    }


class TestCollectionTypes(parameterized.TestCase):
  """Tests for list, dict, and other collection types."""

  @parameterized.named_parameters(
      (
          "strings",
          ", ".join,
          "items",
          str,
          "string",
          "string",
      ),
      (
          "integers",
          sum,
          "numbers",
          int,
          "integer",
          "integer",
      ),
  )
  def test_list_parameters(
      self,
      func_impl,
      param_name,
      item_type,
      expected_item_schema_type,
      expected_response_schema_type,
  ):
    """Test list parameters with different item types."""

    if item_type == str:

      def test_func(items: list[str]) -> str:
        return func_impl(items)

      test_func.__name__ = "join_strings"
    elif item_type == int:

      def test_func(numbers: list[int]) -> int:
        return func_impl(numbers)

      test_func.__name__ = "sum_numbers"
    else:
      raise ValueError("Unsupported item type")

    decl = build_function_declaration_with_json_schema(test_func)
    schema = decl.parameters_json_schema

    self.assertEqual(schema["properties"][param_name]["type"], "array")
    self.assertEqual(
        schema["properties"][param_name]["items"]["type"],
        expected_item_schema_type,
    )
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": expected_response_schema_type,
        },
    )

  def test_dict_parameter(self):
    """Test dict[str, Any] parameter."""

    def process_data(data: dict[str, Any]) -> str:
      """Process a dictionary."""
      return str(data)

    decl = build_function_declaration_with_json_schema(process_data)
    schema = decl.parameters_json_schema

    self.assertEqual(schema["properties"]["data"]["type"], "object")
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_dict_with_typed_values(self):
    """Test dict[str, int] parameter."""

    def sum_scores(scores: dict[str, int]) -> int:
      """Sum all scores."""
      return sum(scores.values())

    decl = build_function_declaration_with_json_schema(sum_scores)
    schema = decl.parameters_json_schema

    self.assertEqual(schema["properties"]["scores"]["type"], "object")
    # additionalProperties should specify int type
    self.assertEqual(
        schema["properties"]["scores"]
        .get("additionalProperties", {})
        .get("type"),
        "integer",
    )
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "integer",
        },
    )

  def test_sequence_type(self):
    """Test Sequence[str] parameter (from collections.abc)."""

    def process_items(items: Sequence[str]) -> int:
      """Process items and return count."""
      return len(list(items))

    decl = build_function_declaration_with_json_schema(process_items)
    schema = decl.parameters_json_schema

    self.assertEqual(schema["properties"]["items"]["type"], "array")
    self.assertEqual(schema["properties"]["items"]["items"]["type"], "string")
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "integer",
        },
    )

  def test_tuple_fixed_length(self):
    """Test tuple[int, int] parameter (fixed length)."""

    def add_point(coords: tuple[int, int]) -> int:
      """Add coordinates."""
      x, y = coords
      return x + y

    decl = build_function_declaration_with_json_schema(add_point)
    schema = decl.parameters_json_schema

    # Fixed-length tuples use prefixItems
    coords_schema = schema["properties"]["coords"]
    self.assertEqual(coords_schema["type"], "array")
    self.assertIn("prefixItems", coords_schema)
    self.assertLen(coords_schema["prefixItems"], 2)
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "integer",
        },
    )


class TestEnumAndLiteral(parameterized.TestCase):
  """Tests for Enum and Literal types."""

  def test_string_enum(self):
    """Test Enum parameter with string values."""

    def set_color(color: Color) -> str:
      """Set the color."""
      return color.value

    decl = build_function_declaration_with_json_schema(set_color)
    schema = decl.parameters_json_schema

    self.assertIn("$defs", schema)
    self.assertIn("color", schema["properties"])
    color_schema = schema["properties"]["color"]
    self.assertIn("$ref", color_schema)
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_literal_type(self):
    """Test Literal type parameter."""

    def set_mode(mode: Literal["fast", "slow", "auto"]) -> str:
      """Set the mode."""
      return mode

    decl = build_function_declaration_with_json_schema(set_mode)
    schema = decl.parameters_json_schema

    mode_schema = schema["properties"]["mode"]
    self.assertEqual(mode_schema.get("enum"), ["fast", "slow", "auto"])
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_literal_with_default(self):
    """Test Literal type with default value."""

    def configure(mode: Literal["on", "off"] = "on") -> str:
      """Configure something."""
      return mode

    decl = build_function_declaration_with_json_schema(configure)
    schema = decl.parameters_json_schema

    self.assertEqual(schema["properties"]["mode"]["default"], "on")
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )


class TestOptionalAndUnion(parameterized.TestCase):
  """Tests for Optional and Union types."""

  def test_optional_string(self):
    """Test Optional[str] parameter."""

    def greet(name: Optional[str] = None) -> str:
      """Greet someone."""
      return f"Hello, {name or 'World'}!"

    decl = build_function_declaration_with_json_schema(greet)
    schema = decl.parameters_json_schema

    # Optional should be represented with anyOf including null
    name_schema = schema["properties"]["name"]
    self.assertIn("anyOf", name_schema)
    self.assertLen(name_schema["anyOf"], 2)
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_union_of_primitives(self):
    """Test Union[int, str] parameter."""

    def process(value: int | str) -> str:
      """Process a value."""
      return str(value)

    decl = build_function_declaration_with_json_schema(process)
    schema = decl.parameters_json_schema

    value_schema = schema["properties"]["value"]
    self.assertIn("anyOf", value_schema)
    self.assertLen(value_schema["anyOf"], 2)
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_complex_union(self):
    """Test Union[int, str, dict[str, float]] parameter."""

    def flexible_input(
        payload: int | str | dict[str, float] = 0,
    ) -> str:
      """Accept flexible input."""
      return str(payload)

    decl = build_function_declaration_with_json_schema(flexible_input)
    schema = decl.parameters_json_schema

    payload_schema = schema["properties"]["payload"]
    self.assertIn("anyOf", payload_schema)
    self.assertLen(payload_schema["anyOf"], 3)
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )


class TestNestedObjects(parameterized.TestCase):
  """Tests for nested Pydantic models and dataclasses."""

  def test_pydantic_model_parameter(self):
    """Test parameter that is a Pydantic model."""

    def save_address(address: Address) -> str:
      """Save an address."""
      return f"Saved address in {address.city}"

    decl = build_function_declaration_with_json_schema(save_address)
    schema = decl.parameters_json_schema

    # Should have $defs for the nested model
    self.assertIn("address", schema["properties"])
    self.assertIn("$ref", schema["properties"]["address"])

    address_def = schema["$defs"]["Address"]
    self.assertEqual(address_def["type"], "object")
    self.assertIn("street", address_def["properties"])
    self.assertEqual(
        address_def["properties"]["zip_code"]["pattern"], r"^\d{5}$"
    )
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_nested_pydantic_model(self):
    """Test Pydantic model with nested model."""

    def save_person(person: Person) -> str:
      """Save a person."""
      return f"Saved {person.name}"

    decl = build_function_declaration_with_json_schema(save_person)
    schema = decl.parameters_json_schema

    # Should handle nested Address model
    self.assertIn("$defs", schema)
    person_defs = schema["$defs"]["Person"]
    self.assertEqual(person_defs["type"], "object")
    self.assertIn("address", person_defs["properties"])
    self.assertIn("person", schema["properties"])
    self.assertIn("$ref", schema["properties"]["person"])
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_pydantic_dataclass_parameter(self):
    """Test parameter that is a Pydantic dataclass."""

    def resize_window(window: Window) -> str:
      """Resize a window."""
      return f"Resized to {window.width}x{window.height}"

    decl = build_function_declaration_with_json_schema(resize_window)
    schema = decl.parameters_json_schema

    self.assertIn("window", schema["properties"])
    self.assertIn("$ref", schema["properties"]["window"])
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_list_of_pydantic_models(self):
    """Test list of Pydantic models."""

    def save_addresses(addresses: list[Address]) -> int:
      """Save multiple addresses."""
      return len(addresses)

    decl = build_function_declaration_with_json_schema(save_addresses)
    schema = decl.parameters_json_schema

    addr_schema = schema["properties"]["addresses"]
    self.assertEqual(addr_schema["type"], "array")
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "integer",
        },
    )


class TestSpecialCases(parameterized.TestCase):
  """Tests for special cases and edge cases."""

  def test_no_parameters(self):
    """Test function with no parameters."""

    def get_time() -> str:
      """Get current time."""
      return "12:00"

    decl = build_function_declaration_with_json_schema(get_time)

    self.assertEqual(decl.name, "get_time")
    self.assertIsNone(decl.parameters_json_schema)
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_no_type_annotations(self):
    """Test function with no type annotations."""

    def legacy_function(x, y):
      """A legacy function without types."""
      return x + y

    decl = build_function_declaration_with_json_schema(legacy_function)
    schema = decl.parameters_json_schema

    # Should still generate schema, with Any type
    self.assertIn("x", schema["properties"])
    self.assertIsNone(schema["properties"]["x"].get("type"))
    self.assertIn("y", schema["properties"])
    self.assertIsNone(schema["properties"]["y"].get("type"))
    # No return type annotation, so response schema should be None
    self.assertIsNone(decl.response_json_schema)

  def test_any_type_parameter(self):
    """Test parameter with Any type."""

    def process_any(data: Any) -> str:
      """Process any data."""
      return str(data)

    decl = build_function_declaration_with_json_schema(process_any)
    schema = decl.parameters_json_schema

    # Any type should be represented somehow
    self.assertIn("data", schema["properties"])
    self.assertIsNone(schema["properties"]["data"].get("type"))
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_tool_context_ignored_via_ignore_params(self):
    """Test that tool_context parameter is ignored when passed in ignore_params."""

    def my_tool(query: str, tool_context: ToolContext) -> str:
      """A tool that uses context."""
      return query

    decl = build_function_declaration_with_json_schema(
        my_tool, ignore_params=["tool_context"]
    )
    schema = decl.parameters_json_schema

    self.assertIn("query", schema["properties"])
    self.assertNotIn("tool_context", schema["properties"])
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_ignore_params(self):
    """Test ignoring specific parameters."""

    def complex_func(a: str, b: int, c: float, internal: str) -> str:
      """A function with internal parameter."""
      return a

    decl = build_function_declaration_with_json_schema(
        complex_func, ignore_params=["internal"]
    )
    schema = decl.parameters_json_schema

    self.assertIn("a", schema["properties"])
    self.assertIn("b", schema["properties"])
    self.assertIn("c", schema["properties"])
    self.assertNotIn("internal", schema["properties"])
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "string",
        },
    )

  def test_docstring_preserved(self):
    """Test that docstring is preserved as description."""

    def well_documented(x: int) -> int:
      """This is a well-documented function.

      It does something useful.

      Args:
        x: The number to square.

      Returns:
        The squared number.
      """
      return x

    decl = build_function_declaration_with_json_schema(well_documented)

    self.assertIn("well-documented function", decl.description)
    self.assertIn("something useful", decl.description)
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "integer",
        },
    )

  def test_no_docstring(self):
    """Test function without docstring."""

    def undocumented(x: int) -> int:
      return x

    decl = build_function_declaration_with_json_schema(undocumented)

    self.assertIsNone(decl.description)
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "integer",
        },
    )


class TestComplexFunction(parameterized.TestCase):
  """Test the complex function from the user's prototype."""

  def test_complex_function_schema(self):
    """Test the complex function with many type variations."""

    def complex_fn(
        color: Color,
        tags: list[str],
        mode: Literal["fast", "slow"] = "fast",
        count: Optional[int] = None,
        address: Optional[Address] = None,
        window: Optional[Window] = None,
        payload: int | str | dict[str, float] = 0,
        colors: Optional[list[Color]] = None,
    ) -> None:
      """A complex function with many parameter types."""
      del color, tags, mode, count, address, window, payload, colors

    decl = build_function_declaration_with_json_schema(complex_fn)

    self.assertEqual(decl.name, "complex_fn")
    self.assertIsNotNone(decl.parameters_json_schema)

    schema = decl.parameters_json_schema
    props = schema["properties"]

    # Verify all parameters are present
    self.assertIn("color", props)
    self.assertIn("tags", props)
    self.assertIn("mode", props)
    self.assertIn("count", props)
    self.assertIn("address", props)
    self.assertIn("window", props)
    self.assertIn("payload", props)
    self.assertIn("colors", props)

    # tags should be array of strings
    self.assertEqual(props["tags"]["type"], "array")

    # mode should have enum
    self.assertEqual(props["mode"].get("enum"), ["fast", "slow"])
    # Return type is None, which maps to JSON schema null type
    self.assertEqual(
        decl.response_json_schema,
        {
            "type": "null",
        },
    )


class TestPydanticModelAsFunction(parameterized.TestCase):
  """Tests for using Pydantic BaseModel directly."""

  def test_base_model_class(self):
    """Test passing a Pydantic BaseModel class directly."""

    class CreateUserRequest(BaseModel):
      """Request to create a user."""

      name: str
      email: str
      age: Optional[int] = None

    decl = build_function_declaration_with_json_schema(CreateUserRequest)

    self.assertEqual(decl.name, "CreateUserRequest")
    self.assertIsNotNone(decl.parameters_json_schema)

    schema = decl.parameters_json_schema
    self.assertIn("name", schema["properties"])
    self.assertIn("email", schema["properties"])
    self.assertIn("age", schema["properties"])
    # When passing a BaseModel, there is no function return, so response schema
    # is None
    self.assertIsNone(decl.response_json_schema)
