# 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 @node decorator and behavior."""

from __future__ import annotations

from unittest import mock

from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.llm_agent import LlmAgent
from google.adk.apps import App
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.tools.base_tool import BaseTool
from google.adk.workflow import FunctionNode
from google.adk.workflow import START
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._node import node
from google.adk.workflow._node import Node
from google.adk.workflow._parallel_worker import _ParallelWorker as ParallelWorker
from google.adk.workflow._retry_config import RetryConfig
from google.adk.workflow._tool_node import _ToolNode as ToolNode
from google.adk.workflow._workflow import Workflow
from google.genai import types
import pytest

from .. import testing_utils

ANY = mock.ANY


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


async def _run_workflow(wf, message="start"):
  """Run a Workflow through Runner, return collected events."""
  ss = InMemorySessionService()
  runner = Runner(app_name="test", node=wf, session_service=ss)
  session = await ss.create_session(app_name="test", user_id="u")
  msg = types.Content(parts=[types.Part(text=message)], role="user")
  events = []
  async for event in runner.run_async(
      user_id="u", session_id=session.id, new_message=msg
  ):
    events.append(event)
  return events, ss, session


def _output_by_node(events):
  """Extract (node_name_from_path, output) for child node events."""
  results = []
  for e in events:
    if e.output is not None and e.node_info.path and "/" in e.node_info.path:
      node_name = e.node_info.path.rsplit("/", 1)[-1]
      if "@" in node_name:
        node_name = node_name.rsplit("@", 1)[0]
      results.append((node_name, e.output))
  return results


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_node_decorator():
  """Tests that @node decorator can wrap a function and override its name."""

  @node(name="decorated_node")
  def my_func():
    return "Hello from decorated_func"

  assert my_func.name == "decorated_node"

  wf = Workflow(
      name="test_agent",
      edges=[
          (START, my_func),
      ],
  )
  events, _, _ = await _run_workflow(wf)

  by_node = _output_by_node(events)
  assert ("decorated_node", "Hello from decorated_func") in by_node


def test_node_parallel_worker_instance():
  """Tests that node() can wrap a node in ParallelWorker."""

  @node(parallel_worker=True)
  def my_func(node_input):
    return node_input

  assert isinstance(my_func, ParallelWorker)
  assert my_func.name == "my_func"

  def other_func(x):
    return x

  parallel_node = node(other_func, parallel_worker=True)
  assert isinstance(parallel_node, ParallelWorker)
  assert parallel_node.name == "other_func"


@pytest.mark.asyncio
async def test_node_parallel_worker_execution():
  """Tests that a node with parallel_worker=True correctly processes inputs."""

  @node(parallel_worker=True)
  async def my_func(node_input):
    return node_input * 2

  async def producer_func() -> list[int]:
    return [1, 2, 3]

  wf = Workflow(
      name="test_agent",
      edges=[
          (START, producer_func),
          (producer_func, my_func),
      ],
  )
  events, _, _ = await _run_workflow(wf)

  by_node = _output_by_node(events)
  assert ("producer_func", [1, 2, 3]) in by_node
  assert ("my_func", [2, 4, 6]) in by_node


def test_node_decorator_rerun_on_resume():
  """Tests that @node decorator can override rerun_on_resume."""

  @node(name="decorated_node", rerun_on_resume=True)
  def my_func():
    return "Hello from decorated_func"

  assert isinstance(my_func, FunctionNode)
  assert my_func.rerun_on_resume

  @node()
  def my_func2():
    return "Hello from decorated_func2"

  assert isinstance(my_func2, FunctionNode)
  assert not my_func2.rerun_on_resume


def test_node_decorator_parameter_binding():
  """Tests that @node decorator can configure parameter_binding."""

  @node(parameter_binding="node_input")
  def my_func(foo: str):
    return f"Hello {foo}"

  assert isinstance(my_func, FunctionNode)
  assert my_func.parameter_binding == "node_input"


@pytest.mark.asyncio
async def test_node_decorator_parameter_binding_execution():
  """Tests execution of a node with parameter_binding='node_input'."""

  async def producer_func() -> dict[str, Any]:
    return {"foo": "hello", "bar": 42}

  @node(parameter_binding="node_input")
  def my_func(foo: str, bar: int):
    return f"{foo}:{bar}"

  wf = Workflow(
      name="test_agent",
      edges=[
          (START, producer_func),
          (producer_func, my_func),
      ],
  )
  events, _, _ = await _run_workflow(wf)

  by_node = _output_by_node(events)
  assert ("my_func", "hello:42") in by_node


def test_node_function_with_base_node():
  """Tests that node() function returns a copied node when given a BaseNode."""

  @node(name="original")
  def original():
    pass

  wrapped = node(original, name="overridden", rerun_on_resume=True)

  assert isinstance(wrapped, FunctionNode)
  assert wrapped is not original
  assert wrapped.name == "overridden"
  assert wrapped.rerun_on_resume


class MyTool(BaseTool):
  name = "tool"
  description = "desc"

  async def _run_async_impl(self):
    return "done"


def test_node_no_unnecessary_wrap():
  """Tests that node() does not wrap LlmAgent, Agent, Tool, or func in OverridingNode."""

  llm_agent = LlmAgent(name="llm")
  llm_node = node(llm_agent, name="overridden_llm")

  assert isinstance(llm_node, LlmAgent)
  assert llm_node.name == "overridden_llm"
  assert llm_node.mode == "single_turn"

  agent = BaseAgent(name="agent")
  agent_node_inst = node(agent, name="overridden_agent", rerun_on_resume=True)
  assert isinstance(agent_node_inst, BaseAgent)
  assert agent_node_inst.name == "overridden_agent"
  assert agent_node_inst.rerun_on_resume

  tool_inst = MyTool(name="tool", description="desc")
  t_node = node(tool_inst, name="overridden_tool")
  assert isinstance(t_node, ToolNode)
  assert t_node.name == "overridden_tool"

  def my_func():
    pass

  f_node = node(my_func, name="overridden_func", rerun_on_resume=True)
  assert isinstance(f_node, FunctionNode)
  assert f_node.name == "overridden_func"
  assert f_node.rerun_on_resume


class StatefulTool(BaseTool):
  """A tool that modifies state via tool_context."""

  async def run_async(self, *, args, tool_context):
    tool_context.state["tool_key"] = "tool_value"
    tool_context.state["tool_count"] = 10
    return {"status": "ok"}


from .workflow_testing_utils import simplify_events_with_node


@pytest.mark.asyncio
async def test_tool_node_state_delta():
  """Tests that state set via tool_context.state in ToolNode is persisted."""

  tool_node = ToolNode(
      tool=StatefulTool(name="stateful_tool", description="Sets state values"),
  )

  def read_state(tool_key: str, tool_count: int) -> str:
    return f"tool_key={tool_key}, tool_count={tool_count}"

  def start_node():
    return {}

  wf = Workflow(
      name="test_tool_node_state_delta",
      edges=[
          (START, start_node),
          (start_node, tool_node),
          (tool_node, read_state),
      ],
  )

  events, _, _ = await _run_workflow(wf)

  simplified = simplify_events_with_node(
      events, include_workflow_output=True, include_state_delta=True
  )

  assert (
      "test_tool_node_state_delta@1/stateful_tool@1",
      {"output": {"status": "ok"}},
  ) in [(e[0], {"output": e[1].get("output")}) for e in simplified]

  assert (
      "test_tool_node_state_delta@1/read_state@1",
      {
          "output": "tool_key=tool_value, tool_count=10",
      },
  ) in [(e[0], {"output": e[1].get("output")}) for e in simplified]


class _CustomNode(Node):
  custom_val: str = "hello"
  rerun_on_resume: bool = True

  async def run_node_impl(self, *, ctx, node_input):
    yield f"subclass: {self.custom_val} -> {node_input}"


def test_node_subclassing_model_copy_preserves_identity():
  """Tests that Node.model_copy preserves the subclass class identity."""
  node_inst = _CustomNode(
      name="subclass", parallel_worker=True, custom_val="barrier"
  )
  assert node_inst.parallel_worker is True

  cloned = node_inst.model_copy()
  assert isinstance(cloned, _CustomNode)
  assert cloned.custom_val == "barrier"
  assert cloned.parallel_worker is True
  assert isinstance(cloned._inner_node, ParallelWorker)
  # Confirm inner node wraps a clone of _CustomNode preserving identity!
  assert isinstance(cloned._inner_node._node, _CustomNode)
  assert cloned._inner_node._node.custom_val == "barrier"
  assert cloned._inner_node._node.parallel_worker is False


@pytest.mark.asyncio
async def test_node_subclassing_execution_with_parallel_worker():
  """Tests that a subclassed Node with parallel_worker=True executes successfully."""
  subclass_node = _CustomNode(
      name="subclass", parallel_worker=True, custom_val="workflow"
  )

  async def producer():
    return ["input1", "input2"]

  wf = Workflow(
      name="test_agent",
      edges=[
          (START, producer),
          (producer, subclass_node),
      ],
  )

  events, _, _ = await _run_workflow(wf)
  by_node = _output_by_node(events)

  assert ("producer", ["input1", "input2"]) in by_node
  assert (
      "subclass",
      ["subclass: workflow -> input1", "subclass: workflow -> input2"],
  ) in by_node


def test_node_decorator_parallel_worker_max_parallel_workers():
  """Tests that node() correctly sets max_parallel_workers on ParallelWorker."""

  @node(parallel_worker=True, max_parallel_workers=3)
  def my_func(node_input):
    return node_input

  assert isinstance(my_func, ParallelWorker)
  assert my_func.max_parallel_workers == 3


def test_node_decorator_invalid_max_parallel_workers():
  """Tests that node() raises ValueError if max_parallel_workers is set without parallel_worker."""
  with pytest.raises(
      ValueError,
      match="max_parallel_workers can only be set when parallel_worker is True",
  ):

    @node(parallel_worker=False, max_parallel_workers=3)
    def my_func(node_input):
      return node_input


def test_node_subclass_invalid_max_parallel_workers():
  """Tests that Node subclass raises ValidationError if max_parallel_workers is set without parallel_worker."""
  from pydantic import ValidationError

  with pytest.raises(ValidationError) as exc_info:
    _CustomNode(name="subclass", parallel_worker=False, max_parallel_workers=3)

  assert (
      "max_parallel_workers can only be set when parallel_worker is True"
      in str(exc_info.value)
  )


def test_node_subclassing_model_copy_preserves_max_parallel_workers():
  """Tests that Node.model_copy preserves max_parallel_workers."""
  node_inst = _CustomNode(
      name="subclass",
      parallel_worker=True,
      max_parallel_workers=5,
      custom_val="barrier",
  )
  assert node_inst.parallel_worker is True
  assert node_inst.max_parallel_workers == 5
  assert node_inst._inner_node.max_parallel_workers == 5

  cloned = node_inst.model_copy()
  assert isinstance(cloned, _CustomNode)
  assert cloned.parallel_worker is True
  assert cloned.max_parallel_workers == 5
  assert isinstance(cloned._inner_node, ParallelWorker)
  assert cloned._inner_node.max_parallel_workers == 5
  assert cloned._inner_node._node.parallel_worker is False


def test_node_decorator_invalid_max_parallel_workers_less_than_one():
  """Tests that node() raises ValueError if max_parallel_workers is less than 1."""
  with pytest.raises(
      ValueError,
      match="max_parallel_workers must be greater than or equal to 1",
  ):

    @node(parallel_worker=True, max_parallel_workers=0)
    def my_func(node_input):
      return node_input


def test_node_subclass_invalid_max_parallel_workers_less_than_one():
  """Tests that Node subclass raises ValidationError if max_parallel_workers is less than 1."""
  from pydantic import ValidationError

  with pytest.raises(ValidationError) as exc_info:
    _CustomNode(name="subclass", parallel_worker=True, max_parallel_workers=0)

  assert "max_parallel_workers must be greater than or equal to 1" in str(
      exc_info.value
  )


def test_parallel_worker_invalid_max_parallel_workers_less_than_one():
  """Tests that ParallelWorker constructor raises ValueError if max_parallel_workers is less than 1."""

  def dummy_func(x):
    return x

  with pytest.raises(
      ValueError,
      match="max_parallel_workers must be greater than or equal to 1",
  ):
    ParallelWorker(node=dummy_func, max_parallel_workers=0)
