# Copyright 2026 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 Progressive SSE Streaming Stage 1 implementation."""

import asyncio
from typing import Any
from typing import AsyncGenerator

from google.adk.agents.llm_agent import Agent
from google.adk.agents.run_config import RunConfig
from google.adk.agents.run_config import StreamingMode
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.utils.streaming_utils import StreamingResponseAggregator
from google.genai import types
import pytest


def get_weather(location: str) -> dict[str, Any]:
  """Mock weather function for testing.

  Args:
    location: The location to get the weather for.

  Returns:
    A dictionary containing the weather information.
  """
  return {
      "temperature": 22,
      "condition": "sunny",
      "location": location,
  }


class StreamingMockModel(BaseLlm):
  """A mock model that properly streams multiple chunks in a single call."""

  model: str = "streaming-mock"
  stream_chunks: list[LlmResponse] = []
  call_count: int = 0

  @classmethod
  def supported_models(cls) -> list[str]:
    return ["streaming-mock"]

  async def generate_content_async(
      self, llm_request: LlmRequest, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    """Yield all chunks in a single streaming call."""
    self.call_count += 1

    # Only stream on the first call
    if self.call_count > 1:
      # On subsequent calls, return a simple final response
      yield LlmResponse(
          content=types.Content(
              role="model",
              parts=[types.Part.from_text(text="Task completed.")],
          ),
          partial=False,
      )
      return

    aggregator = StreamingResponseAggregator()

    # Process each chunk through the aggregator
    for chunk in self.stream_chunks:
      # Convert LlmResponse to types.GenerateContentResponse
      # Since we don't have the full response object, we'll simulate it
      async for processed_chunk in aggregator.process_response(
          self._llm_response_to_generate_content_response(chunk)
      ):
        yield processed_chunk

    # Call close() to get the final aggregated response
    if final_response := aggregator.close():
      yield final_response

  def _llm_response_to_generate_content_response(
      self, llm_response: LlmResponse
  ) -> types.GenerateContentResponse:
    """Convert LlmResponse to GenerateContentResponse for aggregator."""
    # Create a minimal GenerateContentResponse that the aggregator can process
    candidates = []
    if llm_response.content:
      candidates.append(
          types.Candidate(
              content=llm_response.content,
              finish_reason=llm_response.finish_reason,
              finish_message=llm_response.error_message,
          )
      )

    return types.GenerateContentResponse(
        candidates=candidates,
        usage_metadata=llm_response.usage_metadata,
    )


def test_progressive_sse_streaming_function_calls():
  """Test that function calls are buffered and executed in parallel."""

  # Setup: Create mock responses simulating streaming chunks
  response1 = LlmResponse(
      content=types.Content(
          role="model", parts=[types.Part.from_text(text="Checking weather...")]
      ),
  )

  response2 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[
              types.Part.from_function_call(
                  name="get_weather", args={"location": "Tokyo"}
              )
          ],
      ),
  )

  response3 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[
              types.Part.from_function_call(
                  name="get_weather", args={"location": "New York"}
              )
          ],
      ),
      finish_reason=types.FinishReason.STOP,
  )

  # Create a streaming mock that yields all chunks in one call
  mock_model = StreamingMockModel(
      stream_chunks=[response1, response2, response3]
  )

  agent = Agent(
      name="weather_agent",
      model=mock_model,
      tools=[get_weather],
  )

  run_config = RunConfig(streaming_mode=StreamingMode.SSE)

  # Use the real InMemoryRunner to get access to run_config parameter
  runner = InMemoryRunner(agent=agent)

  # Create session manually
  session = runner.session_service.create_session_sync(
      app_name=runner.app_name, user_id="test_user"
  )

  events = []
  for event in runner.run(
      user_id="test_user",
      session_id=session.id,
      new_message=types.Content(
          role="user",
          parts=[types.Part.from_text(text="What is the weather?")],
      ),
      run_config=run_config,
  ):
    events.append(event)

  # Verify event structure (Stage 1 expectations)
  # Expected events:
  # 0-2: Partial events (text + 2 FCs) - not executed
  # 3: Final aggregated model event (text + 2 FCs) - partial=False
  # 4: Aggregated function response (both get_weather results executed in
  # parallel)
  # 5: Final model response after FCs
  assert len(events) == 6

  assert events[0].partial
  assert events[0].content.parts[0].text == "Checking weather..."

  assert events[1].partial
  assert events[1].content.parts[0].function_call.name == "get_weather"
  assert events[1].content.parts[0].function_call.args["location"] == "Tokyo"

  assert events[2].partial
  assert events[2].content.parts[0].function_call.name == "get_weather"
  assert events[2].content.parts[0].function_call.args["location"] == "New York"

  assert not events[3].partial
  assert events[3].content.parts[0].text == "Checking weather..."
  assert events[3].content.parts[1].function_call.name == "get_weather"
  assert events[3].content.parts[1].function_call.args["location"] == "Tokyo"
  assert events[3].content.parts[2].function_call.name == "get_weather"
  assert events[3].content.parts[2].function_call.args["location"] == "New York"

  assert not events[4].partial
  assert events[4].content.parts[0].function_response.name == "get_weather"
  assert (
      events[4].content.parts[0].function_response.response["location"]
      == "Tokyo"
  )
  assert events[4].content.parts[1].function_response.name == "get_weather"
  assert (
      events[4].content.parts[1].function_response.response["location"]
      == "New York"
  )

  assert not events[5].partial
  assert events[5].content.parts[0].text == "Task completed."


def test_progressive_sse_preserves_part_ordering():
  """Test that part ordering is preserved, especially for thought parts.

  This test verifies that when the model outputs:
  - chunk1(thought1_1)
  - chunk2(thought1_2)
  - chunk3(text1_1)
  - chunk4(text1_2)
  - chunk5(FC1)
  - chunk6(thought2_1)
  - chunk7(thought2_2)
  - chunk8(FC2)

  The final aggregated output should be:
  - Part(thought1)  # thought1_1 + thought1_2 merged
  - Part(text1)     # text1_1 + text1_2 merged
  - Part(FC1)
  - Part(thought2)  # thought2_1 + thought2_2 merged
  - Part(FC2)
  """

  # Create streaming chunks that test the ordering requirement
  chunk1 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[types.Part(text="Initial thought part 1. ", thought=True)],
      )
  )

  chunk2 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[types.Part(text="Initial thought part 2.", thought=True)],
      )
  )

  chunk3 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[types.Part.from_text(text="Let me check Tokyo. ")],
      )
  )

  chunk4 = LlmResponse(
      content=types.Content(
          role="model", parts=[types.Part.from_text(text="And New York too.")]
      )
  )

  chunk5 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[
              types.Part.from_function_call(
                  name="get_weather", args={"location": "Tokyo"}
              )
          ],
      )
  )

  chunk6 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[
              types.Part(
                  text="Now processing second thought part 1. ", thought=True
              )
          ],
      )
  )

  chunk7 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[types.Part(text="Second thought part 2.", thought=True)],
      )
  )

  chunk8 = LlmResponse(
      content=types.Content(
          role="model",
          parts=[
              types.Part.from_function_call(
                  name="get_weather", args={"location": "New York"}
              )
          ],
      ),
      finish_reason=types.FinishReason.STOP,
  )

  mock_model = StreamingMockModel(
      stream_chunks=[
          chunk1,
          chunk2,
          chunk3,
          chunk4,
          chunk5,
          chunk6,
          chunk7,
          chunk8,
      ]
  )

  agent = Agent(
      name="ordering_test_agent",
      model=mock_model,
      tools=[get_weather],
  )

  run_config = RunConfig(streaming_mode=StreamingMode.SSE)

  # Use the real InMemoryRunner to get access to run_config parameter
  runner = InMemoryRunner(agent=agent)

  # Create session manually
  session = runner.session_service.create_session_sync(
      app_name=runner.app_name, user_id="test_user"
  )

  events = []
  for event in runner.run(
      user_id="test_user",
      session_id=session.id,
      new_message=types.Content(
          role="user",
          parts=[types.Part.from_text(text="What is the weather?")],
      ),
      run_config=run_config,
  ):
    events.append(event)

  # Find the final aggregated model event (partial=False, from model)
  aggregated_event = None
  for event in events:
    if (
        not event.partial
        and event.author == "ordering_test_agent"
        and event.content
        and len(event.content.parts) > 2
    ):
      aggregated_event = event
      break

  assert aggregated_event is not None, "Should find an aggregated model event"

  # Verify the part ordering
  parts = aggregated_event.content.parts
  assert len(parts) == 5, f"Expected 5 parts, got {len(parts)}"

  # Part 0: First thought (merged from chunk1 + chunk2)
  assert parts[0].thought
  assert parts[0].text == "Initial thought part 1. Initial thought part 2."

  # Part 1: Regular text (merged from chunk3 + chunk4)
  assert not parts[1].thought
  assert parts[1].text == "Let me check Tokyo. And New York too."

  # Part 2: First function call (from chunk5)
  assert parts[2].function_call.name == "get_weather"
  assert parts[2].function_call.args["location"] == "Tokyo"

  # Part 3: Second thought (merged from chunk6 + chunk7)
  assert parts[3].thought
  assert (
      parts[3].text
      == "Now processing second thought part 1. Second thought part 2."
  )

  # Part 4: Second function call (from chunk8)
  assert parts[4].function_call.name == "get_weather"
  assert parts[4].function_call.args["location"] == "New York"


def test_progressive_sse_streaming_function_call_arguments():
  """Test streaming function call arguments feature.

  This test simulates the streamFunctionCallArguments feature where a function
  call's arguments are streamed incrementally across multiple chunks:

  Chunk 1: FC name + partial location argument ("New ")
  Chunk 2: Continue location argument ("York") -> concatenated to "New York"
  Chunk 3: Add unit argument ("celsius"), willContinue=False -> FC complete

  Expected result: FunctionCall(name="get_weather",
                                 args={"location": "New York", "unit":
                                 "celsius"},
                                 id="fc_001")
  """

  aggregator = StreamingResponseAggregator()

  # Chunk 1: FC name + partial location argument
  chunk1_fc = types.FunctionCall(
      name="get_weather",
      id="fc_001",
      partial_args=[
          types.PartialArg(json_path="$.location", string_value="New ")
      ],
      will_continue=True,
  )
  chunk1 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(
                  role="model", parts=[types.Part(function_call=chunk1_fc)]
              )
          )
      ]
  )

  # Chunk 2: Continue streaming location argument
  chunk2_fc = types.FunctionCall(
      partial_args=[
          types.PartialArg(json_path="$.location", string_value="York")
      ],
      will_continue=True,
  )
  chunk2 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(
                  role="model", parts=[types.Part(function_call=chunk2_fc)]
              )
          )
      ]
  )

  # Chunk 3: Add unit argument, FC complete
  chunk3_fc = types.FunctionCall(
      partial_args=[
          types.PartialArg(json_path="$.unit", string_value="celsius")
      ],
      will_continue=False,  # FC complete
  )
  chunk3 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(
                  role="model", parts=[types.Part(function_call=chunk3_fc)]
              ),
              finish_reason=types.FinishReason.STOP,
          )
      ]
  )

  # Process all chunks through aggregator
  processed_chunks = []
  for chunk in [chunk1, chunk2, chunk3]:

    async def process():
      results = []
      async for response in aggregator.process_response(chunk):
        results.append(response)
      return results

    import asyncio

    chunk_results = asyncio.run(process())
    processed_chunks.extend(chunk_results)

  # Get final aggregated response
  final_response = aggregator.close()

  # Verify final aggregated response has complete FC
  assert final_response is not None
  assert len(final_response.content.parts) == 1

  fc_part = final_response.content.parts[0]
  assert fc_part.function_call is not None
  assert fc_part.function_call.name == "get_weather"
  assert fc_part.function_call.id == "fc_001"

  # Verify arguments were correctly assembled from streaming chunks
  args = fc_part.function_call.args
  assert args["location"] == "New York"  # "New " + "York" concatenated
  assert args["unit"] == "celsius"


def test_progressive_sse_preserves_thought_signature():
  """Test that thought_signature is preserved when streaming FC arguments.

  This test verifies that when a streaming function call has a thought_signature
  in the Part, it is correctly preserved in the final aggregated FunctionCall.
  """

  aggregator = StreamingResponseAggregator()

  # Create a thought signature (simulating what Gemini returns)
  # thought_signature is bytes (base64 encoded)
  test_thought_signature = b"test_signature_abc123"

  # Chunk with streaming FC args and thought_signature
  chunk_fc = types.FunctionCall(
      name="add_5_numbers",
      id="fc_003",
      partial_args=[
          types.PartialArg(json_path="$.num1", number_value=10),
          types.PartialArg(json_path="$.num2", number_value=20),
      ],
      will_continue=False,
  )

  # Create Part with both function_call AND thought_signature
  chunk_part = types.Part(
      function_call=chunk_fc, thought_signature=test_thought_signature
  )

  chunk = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(role="model", parts=[chunk_part]),
              finish_reason=types.FinishReason.STOP,
          )
      ]
  )

  # Process chunk through aggregator
  async def process():
    results = []
    async for response in aggregator.process_response(chunk):
      results.append(response)
    return results

  import asyncio

  asyncio.run(process())

  # Get final aggregated response
  final_response = aggregator.close()

  # Verify thought_signature was preserved in the Part
  assert final_response is not None
  assert len(final_response.content.parts) == 1

  fc_part = final_response.content.parts[0]
  assert fc_part.function_call is not None
  assert fc_part.function_call.name == "add_5_numbers"

  assert fc_part.thought_signature == test_thought_signature


def test_progressive_sse_handles_empty_function_call():
  """Test that empty function calls are skipped.

  When using streamFunctionCallArguments, Gemini may send an empty
  functionCall: {} as the final chunk to signal streaming completion.
  This test verifies that such empty function calls are properly skipped
  and don't cause errors.
  """

  aggregator = StreamingResponseAggregator()

  # Chunk 1: Streaming FC with partial args
  chunk1_fc = types.FunctionCall(
      name="concat_number_and_string",
      id="fc_001",
      partial_args=[
          types.PartialArg(json_path="$.num", number_value=100),
          types.PartialArg(json_path="$.s", string_value="ADK"),
      ],
      will_continue=False,
  )
  chunk1 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(
                  role="model", parts=[types.Part(function_call=chunk1_fc)]
              )
          )
      ]
  )

  # Chunk 2: Empty function call (streaming end marker)
  chunk2_fc = types.FunctionCall()  # Empty function call
  chunk2 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(
                  role="model", parts=[types.Part(function_call=chunk2_fc)]
              ),
              finish_reason=types.FinishReason.STOP,
          )
      ]
  )

  # Process all chunks through aggregator
  async def process():
    results = []
    for chunk in [chunk1, chunk2]:
      async for response in aggregator.process_response(chunk):
        results.append(response)
    return results

  import asyncio

  asyncio.run(process())

  # Get final aggregated response
  final_response = aggregator.close()

  # Verify final response only has the real FC, not the empty one
  assert final_response is not None
  assert len(final_response.content.parts) == 1

  fc_part = final_response.content.parts[0]
  assert fc_part.function_call is not None
  assert fc_part.function_call.name == "concat_number_and_string"
  assert fc_part.function_call.id == "fc_001"

  # Verify arguments
  args = fc_part.function_call.args
  assert args["num"] == 100
  assert args["s"] == "ADK"


@pytest.mark.parametrize(
    "first_chunk_partial_args",
    [
        pytest.param(None, id="partial_args_none"),
        pytest.param([], id="partial_args_empty_list"),
    ],
)
def test_streaming_fc_chunk_with_will_continue_but_no_partial_args(
    first_chunk_partial_args,
):
  """Test streaming function call with will_continue=True but no partial_args."""

  aggregator = StreamingResponseAggregator()

  # Chunk 1: FC name + will_continue=True, but NO partial_args (or empty list)
  # This is the first chunk that Gemini 3 sends for streaming FC
  chunk1_fc = types.FunctionCall(
      name="my_tool",
      id="fc_gemini3",
      will_continue=True,
      partial_args=first_chunk_partial_args,
  )
  chunk1_part = types.Part(
      function_call=chunk1_fc,
      thought_signature=b"test_sig_123",
  )
  chunk1 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(role="model", parts=[chunk1_part])
          )
      ]
  )

  # Chunk 2: Middle chunk with partial_args, name is None
  chunk2_fc = types.FunctionCall(
      partial_args=[
          types.PartialArg(json_path="$.document", string_value="Once upon ")
      ],
      will_continue=True,
  )
  chunk2 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(
                  role="model", parts=[types.Part(function_call=chunk2_fc)]
              )
          )
      ]
  )

  # Chunk 3: Another middle chunk continuing the string argument
  chunk3_fc = types.FunctionCall(
      partial_args=[
          types.PartialArg(json_path="$.document", string_value="a time...")
      ],
      will_continue=True,
  )
  chunk3 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(
                  role="model", parts=[types.Part(function_call=chunk3_fc)]
              )
          )
      ]
  )

  # Chunk 4: Final chunk - no name, no partial_args, will_continue=False
  # This signals the end of the streaming function call
  chunk4_fc = types.FunctionCall(
      will_continue=False,
  )
  chunk4 = types.GenerateContentResponse(
      candidates=[
          types.Candidate(
              content=types.Content(
                  role="model", parts=[types.Part(function_call=chunk4_fc)]
              ),
              finish_reason=types.FinishReason.STOP,
          )
      ]
  )

  # Process all chunks through aggregator
  async def process():
    results = []
    for chunk in [chunk1, chunk2, chunk3, chunk4]:
      async for response in aggregator.process_response(chunk):
        results.append(response)
    return results

  processed_chunks = asyncio.run(process())

  # All intermediate chunks should be marked as partial
  assert all(chunk.partial for chunk in processed_chunks)

  # Get final aggregated response
  final_response = aggregator.close()

  # Verify final aggregated response has the complete FC with accumulated args
  assert final_response is not None
  assert len(final_response.content.parts) == 1

  fc_part = final_response.content.parts[0]
  assert fc_part.function_call is not None
  assert fc_part.function_call.name == "my_tool"
  assert fc_part.function_call.id == "fc_gemini3"

  # Verify the document argument was correctly accumulated
  args = fc_part.function_call.args
  assert "document" in args
  assert (
      args["document"] == "Once upon a time..."
  )  # Concatenated from chunks 2 + 3

  # Verify thought_signature was preserved from the first chunk
  assert fc_part.thought_signature == b"test_sig_123"


class PartialFunctionCallMockModel(BaseLlm):
  """A mock model that yields partial function call events followed by final."""

  model: str = "partial-fc-mock"
  tool_call_count: int = 0

  @classmethod
  def supported_models(cls) -> list[str]:
    return ["partial-fc-mock"]

  async def generate_content_async(
      self, llm_request: LlmRequest, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    """Yield partial FC events then final, simulating streaming behavior."""

    # Check if this is a follow-up call (after function response)
    has_function_response = False
    for content in llm_request.contents:
      for part in content.parts or []:
        if part.function_response:
          has_function_response = True
          break

    if has_function_response:
      # Final response after function execution
      yield LlmResponse(
          content=types.Content(
              role="model",
              parts=[types.Part.from_text(text="Function executed once.")],
          ),
          partial=False,
      )
      return

    # First call: yield partial FC events then final
    # Partial event 1
    yield LlmResponse(
        content=types.Content(
            role="model",
            parts=[
                types.Part.from_function_call(
                    name="track_execution", args={"call_id": "partial_1"}
                )
            ],
        ),
        partial=True,
    )

    # Partial event 2
    yield LlmResponse(
        content=types.Content(
            role="model",
            parts=[
                types.Part.from_function_call(
                    name="track_execution", args={"call_id": "partial_2"}
                )
            ],
        ),
        partial=True,
    )

    # Final aggregated event (only this should trigger execution)
    yield LlmResponse(
        content=types.Content(
            role="model",
            parts=[
                types.Part.from_function_call(
                    name="track_execution", args={"call_id": "final"}
                )
            ],
        ),
        partial=False,
        finish_reason=types.FinishReason.STOP,
    )


def test_partial_function_calls_not_executed_in_none_streaming_mode():
  """Test that partial function call events are skipped regardless of mode."""
  execution_log = []

  def track_execution(call_id: str) -> str:
    """A tool that logs each execution to verify call count."""
    execution_log.append(call_id)
    return f"Executed: {call_id}"

  mock_model = PartialFunctionCallMockModel()

  agent = Agent(
      name="partial_fc_test_agent",
      model=mock_model,
      tools=[track_execution],
  )

  # Use StreamingMode.NONE to verify partial FCs are still skipped
  run_config = RunConfig(streaming_mode=StreamingMode.NONE)

  runner = InMemoryRunner(agent=agent)

  session = runner.session_service.create_session_sync(
      app_name=runner.app_name, user_id="test_user"
  )

  events = []
  for event in runner.run(
      user_id="test_user",
      session_id=session.id,
      new_message=types.Content(
          role="user",
          parts=[types.Part.from_text(text="Test partial FC handling")],
      ),
      run_config=run_config,
  ):
    events.append(event)

  # Verify the tool was only executed once (from the final event)
  assert (
      len(execution_log) == 1
  ), f"Expected 1 execution, got {len(execution_log)}: {execution_log}"
  assert (
      execution_log[0] == "final"
  ), f"Expected 'final' execution, got: {execution_log[0]}"

  # Verify partial events were yielded but not executed
  partial_events = [e for e in events if e.partial]
  assert (
      len(partial_events) == 2
  ), f"Expected 2 partial events, got {len(partial_events)}"

  # Verify there's a function response event (from the final FC execution)
  function_response_events = [
      e
      for e in events
      if e.content
      and e.content.parts
      and any(p.function_response for p in e.content.parts)
  ]
  assert (
      len(function_response_events) == 1
  ), f"Expected 1 function response event, got {len(function_response_events)}"
