"""Test InjectedState with NotRequired state fields.

This tests the fix for https://github.com/langchain-ai/langchain/issues/35585

When using InjectedState(<field>) on a tool parameter, and the referenced field is
declared as NotRequired in the custom state schema, the ToolNode should gracefully
handle missing fields by injecting None instead of raising KeyError.
"""

import sys
from typing import Annotated

import pytest
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.graph.message import add_messages
from pydantic import BaseModel, Field
from typing_extensions import NotRequired

from langgraph.prebuilt import InjectedState, ToolNode, create_react_agent
from langgraph.prebuilt.chat_agent_executor import AgentState

from .model import FakeToolCallingModel


class CustomAgentStateWithNotRequired(AgentState):
    """Custom state with a NotRequired field (TypedDict style)."""

    city: NotRequired[str]


class CustomAgentStatePydanticWithDefault(BaseModel):
    """Custom state with Optional field and default (Pydantic style)."""

    messages: Annotated[list[AnyMessage], add_messages]
    remaining_steps: int = Field(default=10)
    city: str | None = Field(default=None)


@tool
def get_weather(city: Annotated[str | None, InjectedState("city")] = None) -> str:
    """Get weather for a given city."""
    if city is None:
        return "No city provided"
    return f"It's always sunny in {city}!"


def _create_mock_runtime(
    state: dict | None = None,
    store=None,
):
    """Create a mock Runtime for testing ToolNode directly."""
    from unittest.mock import Mock

    from langgraph.runtime import Runtime

    mock_runtime = Mock(spec=Runtime)
    mock_runtime.context = {}
    return mock_runtime


def _create_config_with_runtime(store=None, state=None):
    """Create a RunnableConfig with mocked runtime for direct ToolNode testing."""
    from langgraph.prebuilt.tool_node import ToolRuntime

    tool_runtime = ToolRuntime(
        state=state or {},
        config={},
        context={},
        store=store,
        stream_writer=None,
        tools=[],
        tool_call_id="test_id",
    )
    return {
        "configurable": {
            "__pregel_runtime": _create_mock_runtime(),
            "__tool_runtime__": tool_runtime,
        }
    }


@pytest.mark.skipif(
    sys.version_info < (3, 11),
    reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_injected_state_not_required_field_missing_injects_none():
    """Test that InjectedState with NotRequired field injects None when field is missing.

    This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585
    """
    tool_node = ToolNode([get_weather])

    tool_call = {
        "name": "get_weather",
        "args": {},
        "id": "call_1",
        "type": "tool_call",
    }
    ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call])

    # State WITHOUT the "city" field - should inject None instead of raising KeyError
    state_without_city: CustomAgentStateWithNotRequired = {
        "messages": [HumanMessage("What's the weather?"), ai_msg],
    }

    result = tool_node.invoke(
        state_without_city,
        config=_create_config_with_runtime(state=state_without_city),
    )

    assert len(result["messages"]) == 1
    tool_msg = result["messages"][0]
    assert isinstance(tool_msg, ToolMessage)
    assert "No city provided" in tool_msg.content


@pytest.mark.skipif(
    sys.version_info < (3, 11),
    reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_injected_state_not_required_field_present_works():
    """Test that InjectedState with NotRequired field works when field IS present."""
    tool_node = ToolNode([get_weather])

    tool_call = {
        "name": "get_weather",
        "args": {},
        "id": "call_1",
        "type": "tool_call",
    }
    ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call])

    # State WITH the "city" field - this should work
    state_with_city: CustomAgentStateWithNotRequired = {
        "messages": [HumanMessage("What's the weather?"), ai_msg],
        "city": "San Francisco",
    }

    result = tool_node.invoke(
        state_with_city,
        config=_create_config_with_runtime(state=state_with_city),
    )

    assert len(result["messages"]) == 1
    tool_msg = result["messages"][0]
    assert isinstance(tool_msg, ToolMessage)
    assert "San Francisco" in tool_msg.content


@pytest.mark.skipif(
    sys.version_info < (3, 11),
    reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_create_react_agent_injected_state_not_required_field_missing():
    """Test create_react_agent with InjectedState using NotRequired field that is missing.

    This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585
    """
    model = FakeToolCallingModel(
        tool_calls=[
            [{"name": "get_weather", "args": {}, "id": "call_1"}],
            [],  # No more tool calls, agent should stop
        ]
    )

    agent = create_react_agent(
        model,
        tools=[get_weather],
        state_schema=CustomAgentStateWithNotRequired,
    )

    # Invoke WITHOUT the city field - should work, injecting None
    result = agent.invoke(
        {"messages": [HumanMessage("What's the weather?")]},
    )

    # Check that the tool was called successfully with None injected
    messages = result["messages"]
    tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
    assert len(tool_messages) == 1
    assert "No city provided" in tool_messages[0].content


@pytest.mark.skipif(
    sys.version_info < (3, 11),
    reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_create_react_agent_injected_state_not_required_field_present():
    """Test create_react_agent with InjectedState using NotRequired field that IS present."""
    model = FakeToolCallingModel(
        tool_calls=[
            [{"name": "get_weather", "args": {}, "id": "call_1"}],
            [],  # No more tool calls, agent should stop
        ]
    )

    agent = create_react_agent(
        model,
        tools=[get_weather],
        state_schema=CustomAgentStateWithNotRequired,
    )

    # Invoke WITH the city field
    result = agent.invoke(
        {
            "messages": [HumanMessage("What's the weather?")],
            "city": "San Francisco",
        },
    )

    # Check that the tool was called successfully
    messages = result["messages"]
    tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
    assert len(tool_messages) == 1
    assert "San Francisco" in tool_messages[0].content


@tool
def get_weather_optional(city: Annotated[str | None, InjectedState("city")]) -> str:
    """Get weather for a given city (accepts None)."""
    if city is None:
        return "Please provide a city!"
    return f"It's always sunny in {city}!"


def test_pydantic_state_with_default_field_missing_works():
    """Test that Pydantic state with Optional field and default=None works when field is missing.

    This is the workaround suggested in the issue comments - using Pydantic BaseModel
    with `city: Optional[str] = Field(default=None)` instead of TypedDict with NotRequired.
    """
    model = FakeToolCallingModel(
        tool_calls=[
            [{"name": "get_weather_optional", "args": {}, "id": "call_1"}],
            [],  # No more tool calls, agent should stop
        ]
    )

    agent = create_react_agent(
        model,
        tools=[get_weather_optional],
        state_schema=CustomAgentStatePydanticWithDefault,
    )

    # Invoke WITHOUT the city field - should work because Pydantic provides default
    result = agent.invoke(
        {"messages": [HumanMessage("What's the weather?")]},
    )

    # Check that the tool was called successfully with None
    messages = result["messages"]
    tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
    assert len(tool_messages) == 1
    assert "Please provide a city!" in tool_messages[0].content


def test_pydantic_state_with_default_field_present_works():
    """Test that Pydantic state with Optional field works when field IS present."""
    model = FakeToolCallingModel(
        tool_calls=[
            [{"name": "get_weather_optional", "args": {}, "id": "call_1"}],
            [],  # No more tool calls, agent should stop
        ]
    )

    agent = create_react_agent(
        model,
        tools=[get_weather_optional],
        state_schema=CustomAgentStatePydanticWithDefault,
    )

    # Invoke WITH the city field
    result = agent.invoke(
        {
            "messages": [HumanMessage("What's the weather?")],
            "city": "San Francisco",
        },
    )

    # Check that the tool was called successfully
    messages = result["messages"]
    tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
    assert len(tool_messages) == 1
    assert "San Francisco" in tool_messages[0].content
