# 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.

import asyncio
from typing import Any
from typing import AsyncGenerator

from google.adk.agents.context import Context
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.adk.agents.llm_agent import LlmAgent
from google.adk.events.event import Event
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._base_node import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
from pydantic import Field
import pytest

from . import testing_utils

# --- Mock Nodes and Agents for Testing Live Mode Design ---


class _MockNonLiveNode(BaseNode):
  """A standard non-live node whose signature does NOT accept live_request_queue."""

  called: bool = False
  actual_input: Any = None
  shared_state: dict[str, Any] = Field(default_factory=dict)

  def __init__(self, *, name: str):
    super().__init__(name=name)

  async def _run_impl(
      self,
      *,
      ctx: Context,
      node_input: Any,
  ) -> AsyncGenerator[Any, None]:
    self.called = True
    self.actual_input = node_input
    self.shared_state["called"] = True
    self.shared_state["actual_input"] = node_input
    yield Event(output=f"{self.name}_output")


class _ConstantNode(BaseNode):
  """A node that outputs a constant value."""

  output_value: Any = None

  def __init__(self, *, name: str, output_value: Any):
    super().__init__(name=name)
    self.output_value = output_value

  async def _run_impl(
      self,
      *,
      ctx: Context,
      node_input: Any,
  ) -> AsyncGenerator[Any, None]:
    yield Event(output=self.output_value)


# --- Live Workflow Unit Tests (TDD) ---


@pytest.mark.asyncio
async def test_hybrid_live_non_live_nodes():
  """CUJ 1: A workflow has hybrid live & non-live nodes."""
  mock_model1 = testing_utils.MockModel.create(
      responses=[
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: node1_start")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: node1_end")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent([
                  types.Part.from_function_call(
                      name="finish_task",
                      args={"result": "LiveNode1_output"},
                  )
              ])
          ),
      ]
  )
  live_node1 = LlmAgent(
      name="LiveNode1",
      model=mock_model1,
      mode="task",
      instruction="Handle live interaction 1.",
  )
  non_live_node = _MockNonLiveNode(name="NonLiveNode")
  mock_model2 = testing_utils.MockModel.create(
      responses=[
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: node2_start")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: node2_end")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent([
                  types.Part.from_function_call(
                      name="finish_task",
                      args={"result": "LiveNode2_output"},
                  )
              ])
          ),
      ]
  )
  live_node2 = LlmAgent(
      name="LiveNode2",
      model=mock_model2,
      mode="task",
      instruction="Handle live interaction 2.",
  )

  wf = Workflow(
      name="hybrid_workflow",
      edges=[
          (START, live_node1),
          (live_node1, non_live_node),
          (non_live_node, live_node2),
      ],
  )

  live_queue = LiveRequestQueue()

  # Pre-seed first live node's requests
  live_queue.send_realtime(
      types.Blob(data=b"node1_start", mime_type="audio/pcm")
  )
  live_queue.send_realtime(types.Blob(data=b"node1_end", mime_type="audio/pcm"))

  ss = InMemorySessionService()
  runner = Runner(app_name=wf.name, node=wf, session_service=ss)
  session = await ss.create_session(app_name=wf.name, user_id="u")

  events = []
  async for event in runner.run_live(
      user_id="u",
      session_id=session.id,
      live_request_queue=live_queue,
      run_config=testing_utils.RunConfig(
          get_session_config={"state_delta": {"__START__": "start_input"}}
      ),
  ):
    events.append(event)
    if event.output == "NonLiveNode_output":
      # First live node and non-live node completed! Now feed the second live node's requests:
      live_queue.send_realtime(
          types.Blob(data=b"node2_start", mime_type="audio/pcm")
      )
      live_queue.send_realtime(
          types.Blob(data=b"node2_end", mime_type="audio/pcm")
      )

  # 1. Assert exact outputs sequence
  outputs = [e.output for e in events if e.output is not None]
  assert outputs == [
      {"result": "LiveNode1_output"},
      "NonLiveNode_output",
      {"result": "LiveNode2_output"},
  ]
  assert non_live_node.shared_state.get("actual_input") == {
      "result": "LiveNode1_output"
  }

  # 2. Assert intermediate content events (conversational turns)
  content_texts = [
      p.text
      for e in events
      if e.content and e.content.parts and e.output is None
      for p in e.content.parts
      if p.text
  ]
  assert content_texts == [
      "Acknowledged: node1_start",
      "Acknowledged: node1_end",
      "Acknowledged: node2_start",
      "Acknowledged: node2_end",
  ]

  # 3. Assert live requests fed to the models
  assert [b.data for b in mock_model1.live_blobs] == [
      b"node1_start",
      b"node1_end",
  ]
  assert [b.data for b in mock_model2.live_blobs] == [
      b"node2_start",
      b"node2_end",
  ]


@pytest.mark.asyncio
async def test_nested_workflow_has_live_node():
  """CUJ 2: A nested workflow has a live node."""
  mock_model = testing_utils.MockModel.create(
      responses=[
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: inner_start")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: inner_end")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent([
                  types.Part.from_function_call(
                      name="finish_task",
                      args={"result": "InnerLiveNode_output"},
                  )
              ])
          ),
      ]
  )
  live_node = LlmAgent(
      name="InnerLiveNode",
      model=mock_model,
      mode="task",
      instruction="Handle inner live interaction.",
  )
  inner_wf = Workflow(name="inner_wf", edges=[(START, live_node)])
  outer_wf = Workflow(name="outer_wf", edges=[(START, inner_wf)])

  live_queue = LiveRequestQueue()
  live_queue.send_realtime(
      types.Blob(data=b"inner_start", mime_type="audio/pcm")
  )
  live_queue.send_realtime(types.Blob(data=b"inner_end", mime_type="audio/pcm"))

  ss = InMemorySessionService()
  runner = Runner(app_name=outer_wf.name, node=outer_wf, session_service=ss)
  session = await ss.create_session(app_name=outer_wf.name, user_id="u")

  events = []
  async for event in runner.run_live(
      user_id="u",
      session_id=session.id,
      live_request_queue=live_queue,
      run_config=testing_utils.RunConfig(
          get_session_config={"state_delta": {"__START__": "start_input"}}
      ),
  ):
    events.append(event)

  # Assert exact outputs sequence
  outputs = [e.output for e in events if e.output is not None]
  assert outputs == [{"result": "InnerLiveNode_output"}]

  # Assert content events
  content_texts = [
      p.text
      for e in events
      if e.content and e.content.parts and e.output is None
      for p in e.content.parts
      if p.text
  ]
  assert content_texts == [
      "Acknowledged: inner_start",
      "Acknowledged: inner_end",
  ]
  assert [b.data for b in mock_model.live_blobs] == [
      b"inner_start",
      b"inner_end",
  ]


@pytest.mark.asyncio
async def test_nested_live_node_and_outer_live_node():
  """CUJ 3: A nested workflow has live node & outer workflow then has a live node."""
  mock_model_inner = testing_utils.MockModel.create(
      responses=[
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: inner_start")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: inner_end")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent([
                  types.Part.from_function_call(
                      name="finish_task",
                      args={"result": "InnerLiveNode_output"},
                  )
              ])
          ),
      ]
  )
  inner_live = LlmAgent(
      name="InnerLiveNode",
      model=mock_model_inner,
      mode="task",
      instruction="Handle inner live interaction.",
  )
  inner_wf = Workflow(name="inner_wf", edges=[(START, inner_live)])

  mock_model_outer = testing_utils.MockModel.create(
      responses=[
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: outer_start")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: outer_end")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent([
                  types.Part.from_function_call(
                      name="finish_task",
                      args={"result": "OuterLiveNode_output"},
                  )
              ])
          ),
      ]
  )
  outer_live = LlmAgent(
      name="OuterLiveNode",
      model=mock_model_outer,
      mode="task",
      instruction="Handle outer live interaction.",
  )
  prep_node = _ConstantNode(name="PrepNode", output_value="prep_output")

  wf = Workflow(
      name="nested_sequential_live",
      edges=[
          (START, inner_wf),
          (inner_wf, prep_node),
          (prep_node, outer_live),
      ],
  )

  live_queue = LiveRequestQueue()
  live_queue.send_realtime(
      types.Blob(data=b"inner_start", mime_type="audio/pcm")
  )
  live_queue.send_realtime(types.Blob(data=b"inner_end", mime_type="audio/pcm"))

  ss = InMemorySessionService()
  runner = Runner(app_name=wf.name, node=wf, session_service=ss)
  session = await ss.create_session(app_name=wf.name, user_id="u")

  events = []
  async for event in runner.run_live(
      user_id="u",
      session_id=session.id,
      live_request_queue=live_queue,
      run_config=testing_utils.RunConfig(
          get_session_config={"state_delta": {"__START__": "start_input"}}
      ),
  ):
    events.append(event)
    if event.output == "prep_output":
      # Inner live node and prep node completed! Feed outer node's requests:
      live_queue.send_realtime(
          types.Blob(data=b"outer_start", mime_type="audio/pcm")
      )
      live_queue.send_realtime(
          types.Blob(data=b"outer_end", mime_type="audio/pcm")
      )

  # Assert exact outputs sequence
  outputs = [e.output for e in events if e.output is not None]
  assert outputs == [
      {"result": "InnerLiveNode_output"},
      "prep_output",
      {"result": "OuterLiveNode_output"},
  ]

  # Assert content events
  content_texts = [
      p.text
      for e in events
      if e.content and e.content.parts and e.output is None
      for p in e.content.parts
      if p.text
  ]
  assert content_texts == [
      "Acknowledged: inner_start",
      "Acknowledged: inner_end",
      "Acknowledged: outer_start",
      "Acknowledged: outer_end",
  ]
  assert [b.data for b in mock_model_inner.live_blobs] == [
      b"inner_start",
      b"inner_end",
  ]
  assert [b.data for b in mock_model_outer.live_blobs] == [
      b"outer_start",
      b"outer_end",
  ]


@pytest.mark.asyncio
async def test_dynamic_node_scheduling_of_live_node():
  """CUJ 4: A node in workflow dynamically schedules a live node using ctx.run_node()."""

  class _DynamicLiveSchedulerNode(BaseNode):
    """A node that dynamically schedules a child live node using ctx.run_node()."""

    child_node: BaseNode | None = None
    child_output: Any = None
    shared_state: dict[str, Any] = Field(default_factory=dict)

    def __init__(self, *, name: str, child_node: BaseNode):
      super().__init__(name=name, rerun_on_resume=True)
      self.child_node = child_node

    async def _run_impl(
        self,
        *,
        ctx: Context,
        node_input: Any,
    ) -> AsyncGenerator[Any, None]:
      if self.child_node:
        output = await ctx.run_node(self.child_node, node_input=node_input)
        self.child_output = output
        self.shared_state["child_output"] = output
      yield Event(output=f"{self.name}_output")

  mock_model = testing_utils.MockModel.create(
      responses=[
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: dynamic_start")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent(
                  [types.Part.from_text(text="Acknowledged: dynamic_end")]
              )
          ),
          LlmResponse(
              content=testing_utils.ModelContent([
                  types.Part.from_function_call(
                      name="finish_task",
                      args={"result": "DynamicLiveNode_output"},
                  )
              ])
          ),
      ]
  )
  live_node = LlmAgent(
      name="DynamicLiveNode",
      model=mock_model,
      mode="task",
      instruction="Handle dynamic live interaction.",
  )
  scheduler_node = _DynamicLiveSchedulerNode(
      name="SchedulerNode", child_node=live_node
  )

  wf = Workflow(name="dynamic_wf", edges=[(START, scheduler_node)])

  live_queue = LiveRequestQueue()
  live_queue.send_realtime(
      types.Blob(data=b"dynamic_start", mime_type="audio/pcm")
  )
  live_queue.send_realtime(
      types.Blob(data=b"dynamic_end", mime_type="audio/pcm")
  )

  ss = InMemorySessionService()
  runner = Runner(app_name=wf.name, node=wf, session_service=ss)
  session = await ss.create_session(app_name=wf.name, user_id="u")

  events = []
  async for event in runner.run_live(
      user_id="u",
      session_id=session.id,
      live_request_queue=live_queue,
      run_config=testing_utils.RunConfig(
          get_session_config={"state_delta": {"__START__": "start_input"}}
      ),
  ):
    events.append(event)

  # Assert exact outputs sequence
  outputs = [e.output for e in events if e.output is not None]
  assert outputs == [
      {"result": "DynamicLiveNode_output"},
      "SchedulerNode_output",
  ]
  assert scheduler_node.shared_state.get("child_output") == {
      "result": "DynamicLiveNode_output"
  }

  # Assert content events
  content_texts = [
      p.text
      for e in events
      if e.content and e.content.parts and e.output is None
      for p in e.content.parts
      if p.text
  ]
  assert content_texts == [
      "Acknowledged: dynamic_start",
      "Acknowledged: dynamic_end",
  ]
  assert [b.data for b in mock_model.live_blobs] == [
      b"dynamic_start",
      b"dynamic_end",
  ]


@pytest.mark.asyncio
async def test_live_node_output_passed_to_downstream():
  """CUJ 5: Dedicated test verifying output of a live node is passed to the next node."""
  mock_model = testing_utils.MockModel.create(
      responses=[
          LlmResponse(
              content=testing_utils.ModelContent([
                  types.Part.from_function_call(
                      name="finish_task",
                      args={"result": "LiveNode_output"},
                  )
              ])
          ),
      ]
  )
  live_node = LlmAgent(
      name="LiveNode",
      model=mock_model,
      mode="task",
      instruction="Handle live interaction.",
  )
  non_live_node = _MockNonLiveNode(name="NonLiveNode")

  wf = Workflow(
      name="dataflow_workflow",
      edges=[
          (START, live_node),
          (live_node, non_live_node),
      ],
  )

  live_queue = LiveRequestQueue()
  live_queue.send_realtime(types.Blob(data=b"start_msg", mime_type="audio/pcm"))
  live_queue.send_realtime(types.Blob(data=b"end_msg", mime_type="audio/pcm"))

  ss = InMemorySessionService()
  runner = Runner(app_name=wf.name, node=wf, session_service=ss)
  session = await ss.create_session(app_name=wf.name, user_id="u")

  events = []
  async for event in runner.run_live(
      user_id="u",
      session_id=session.id,
      live_request_queue=live_queue,
      run_config=testing_utils.RunConfig(
          get_session_config={"state_delta": {"__START__": "start_input"}}
      ),
  ):
    events.append(event)

  outputs = [e.output for e in events if e.output is not None]
  assert outputs == [{"result": "LiveNode_output"}, "NonLiveNode_output"]
  assert non_live_node.shared_state.get("actual_input") == {
      "result": "LiveNode_output"
  }, "The downstream node must receive the live node's exact output"
  assert [b.data for b in mock_model.live_blobs] == [b"start_msg", b"end_msg"]


@pytest.mark.asyncio
async def test_single_turn_agent_runs_as_non_live_in_live_session():
  """CUJ 6: A single_turn LlmAgent in a live session runs as non-live and consumes node_input."""
  mock_model = testing_utils.MockModel.create(
      responses=[
          "SingleTurn_output",
      ]
  )
  prep_node = _ConstantNode(
      name="ConstantNode", output_value="initial_text_input"
  )
  single_turn_node = LlmAgent(
      name="SingleTurnNode",
      model=mock_model,
      mode="single_turn",
      instruction="Summarize the input.",
  )
  capture = _MockNonLiveNode(name="capture")

  wf = Workflow(
      name="single_turn_live_wf",
      edges=[
          (START, prep_node),
          (prep_node, single_turn_node),
          (single_turn_node, capture),
      ],
  )

  live_queue = LiveRequestQueue()
  live_queue.send_realtime(
      types.Blob(data=b"ignored_audio", mime_type="audio/pcm")
  )

  ss = InMemorySessionService()
  runner = Runner(app_name=wf.name, node=wf, session_service=ss)
  session = await ss.create_session(app_name=wf.name, user_id="u")

  events = []
  async for event in runner.run_live(
      user_id="u",
      session_id=session.id,
      live_request_queue=live_queue,
  ):
    events.append(event)

  outputs = [e.output for e in events if e.output is not None]
  assert outputs == ["initial_text_input", "capture_output"]
  assert capture.shared_state.get("actual_input") == "SingleTurn_output"
  # Verify that the model received the initial_text_input (node_input) and NOT the live queue audio
  assert len(mock_model.requests) == 1
  assert (
      mock_model.requests[0].contents[0].parts[0].text == "initial_text_input"
  )
  assert mock_model.live_blobs == []
