# 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 NodeRunner → Context as result channel.

Verifies that NodeRunner correctly populates ctx.output, ctx.route,
and ctx.interrupt_ids from yielded events and direct assignment,
and that resume state (prior_output, prior_interrupt_ids) is carried
forward correctly.
"""

from unittest.mock import AsyncMock
from unittest.mock import MagicMock

from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.context import Context
from google.adk.agents.invocation_context import InvocationContext
from google.adk.events.event import Event
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow._node_runner import NodeRunner
from google.genai import types
import pytest

# --- Helpers ---


def _make_ctx(invocation_id='inv-test', enqueue_events=None, node_path=''):
  """Create a minimal Context mock with IC."""
  mock_agent = MagicMock(spec=BaseAgent)
  real_session = Session(
      id='test_session', app_name='test_app', user_id='test_user'
  )
  real_session_service = InMemorySessionService()

  ic = InvocationContext(
      invocation_id=invocation_id,
      agent=mock_agent,
      session=real_session,
      session_service=real_session_service,
  )

  collected = enqueue_events if enqueue_events is not None else []

  async def _enqueue(event):
    collected.append(event)

  object.__setattr__(ic, '_enqueue_event', AsyncMock(side_effect=_enqueue))

  ctx = Context(
      invocation_context=ic,
      node_path=node_path,
  )
  return ctx, collected


# =========================================================================
# Context as RESULT — fields populated by NodeRunner after execution
# =========================================================================


# --- ctx.output from yielded events ---


@pytest.mark.asyncio
async def test_yield_value_sets_ctx_output():
  """Yielding a value sets ctx.output on the returned context."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield 'hello'

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'hello'


@pytest.mark.asyncio
async def test_yield_event_output_sets_ctx_output():
  """Yielding Event(output=X) sets ctx.output."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(output='from_event')

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'from_event'


@pytest.mark.asyncio
async def test_no_yield_leaves_ctx_output_none():
  """A node that yields nothing leaves ctx.output as None."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      return
      yield  # noqa: unreachable

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output is None


# --- ctx.output set directly ---


@pytest.mark.asyncio
async def test_ctx_output_set_directly():
  """Setting ctx.output directly produces a deferred output event."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      ctx.output = 'direct'
      yield  # noqa: unreachable

  parent_ctx, events = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'direct'
  output_events = [e for e in events if e.output is not None]
  assert len(output_events) == 1
  assert output_events[0].output == 'direct'


@pytest.mark.asyncio
async def test_ctx_output_direct_with_state_delta():
  """Deferred output bundles pending state deltas onto the same event."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      ctx.state['key'] = 'val'
      ctx.output = 'result'
      yield  # noqa: unreachable

  parent_ctx, events = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'result'
  output_events = [e for e in events if e.output is not None]
  assert len(output_events) == 1
  assert output_events[0].actions.state_delta['key'] == 'val'


@pytest.mark.asyncio
async def test_deferred_output_emitted_after_intermediate():
  """ctx.output set directly emits after intermediate content events."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      ctx.output = 'deferred'
      yield Event(content=types.Content(parts=[types.Part(text='working')]))

  parent_ctx, events = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'deferred'
  assert len(events) == 2
  assert events[0].content.parts[0].text == 'working'
  assert events[1].output == 'deferred'


# --- ctx.output validation ---


@pytest.mark.asyncio
async def test_double_output_raises():
  """Setting ctx.output twice raises ValueError."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      ctx.output = 'first'
      ctx.output = 'second'
      yield  # noqa: unreachable

  parent_ctx, events = _make_ctx()
  await NodeRunner(node=_Node(name='n'), parent_ctx=parent_ctx).run()
  error_events = [e for e in events if e.error_code]
  assert len(error_events) == 1
  assert error_events[0].error_code == 'ValueError'
  assert 'already set' in error_events[0].error_message


@pytest.mark.asyncio
async def test_yield_then_ctx_output_raises():
  """Yielding output then setting ctx.output raises ValueError."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield 'first'
      ctx.output = 'second'

  parent_ctx, events = _make_ctx()
  await NodeRunner(node=_Node(name='n'), parent_ctx=parent_ctx).run()
  error_events = [e for e in events if e.error_code]
  assert len(error_events) == 1
  assert error_events[0].error_code == 'ValueError'
  assert 'already set' in error_events[0].error_message


# --- ctx.route ---


@pytest.mark.asyncio
async def test_yield_route_sets_ctx_route():
  """Yielding Event(route=R) sets ctx.route."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(output='out', route='next')

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'out'
  assert child_ctx.route == 'next'


@pytest.mark.asyncio
async def test_ctx_route_set_directly():
  """Setting ctx.route directly is readable after run."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      ctx.route = 'branch_a'
      yield 'out'

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.route == 'branch_a'


# --- ctx.interrupt_ids ---


@pytest.mark.asyncio
async def test_interrupt_sets_ctx_interrupt_ids():
  """Yielding an interrupt event populates ctx.interrupt_ids."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(
          content=types.Content(
              parts=[
                  types.Part(
                      function_call=types.FunctionCall(
                          name='tool', args={}, id='fc-1'
                      )
                  )
              ]
          ),
          long_running_tool_ids={'fc-1'},
      )

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.interrupt_ids == {'fc-1'}
  assert child_ctx.output is None


@pytest.mark.asyncio
async def test_output_and_interrupt_coexist():
  """Output and interrupt can coexist across separate events."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield 'result'
      yield Event(
          content=types.Content(
              parts=[
                  types.Part(
                      function_call=types.FunctionCall(
                          name='tool', args={}, id='fc-1'
                      )
                  )
              ]
          ),
          long_running_tool_ids={'fc-1'},
      )

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'result'
  assert child_ctx.interrupt_ids == {'fc-1'}


@pytest.mark.asyncio
async def test_duplicate_interrupt_ids_deduplicated():
  """Duplicate interrupt IDs are deduplicated (set semantics)."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(long_running_tool_ids={'fc-1', 'fc-2'})
      yield Event(long_running_tool_ids={'fc-2', 'fc-3'})

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.interrupt_ids == {'fc-1', 'fc-2', 'fc-3'}


# --- Output delegation (use_as_output) ---


@pytest.mark.asyncio
async def test_delegated_output_not_enqueued():
  """When output is delegated, the output event is not enqueued."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      ctx._output_delegated = True
      yield 'delegated_value'

  parent_ctx, events = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'delegated_value'
  output_events = [e for e in events if e.output is not None]
  assert len(output_events) == 0


@pytest.mark.asyncio
async def test_delegated_ctx_output_not_emitted():
  """When output is delegated and set via ctx.output, no event emitted."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      ctx._output_delegated = True
      ctx.output = 'delegated_direct'
      yield  # noqa: unreachable

  parent_ctx, events = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'delegated_direct'
  output_events = [e for e in events if e.output is not None]
  assert len(output_events) == 0


@pytest.mark.asyncio
async def test_delegated_output_preserves_event_details():
  """When output is delegated, the event is enqueued but output is suppressed."""
  from google.adk.events.event_actions import EventActions

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      ctx._output_delegated = True
      yield Event(
          output='delegated_value',
          actions=EventActions(state_delta={'foo': 'bar'}),
          content=types.Content(role='model', parts=[types.Part(text='hello')]),
      )

  parent_ctx, events = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'), parent_ctx=parent_ctx
  ).run()

  assert child_ctx.output == 'delegated_value'
  assert len(events) == 1
  event = events[0]
  assert event.output is None
  assert event.actions.state_delta == {'foo': 'bar'}
  assert event.content.parts[0].text == 'hello'


# =========================================================================
# Context as INPUT — resume state provided to NodeRunner at construction
# =========================================================================


@pytest.mark.asyncio
async def test_prior_output_carried_forward():
  """Prior output from a previous run is available on ctx.output."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      return
      yield  # noqa: unreachable

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'),
      parent_ctx=parent_ctx,
      prior_output='cached_result',
  ).run()

  assert child_ctx.output == 'cached_result'


@pytest.mark.asyncio
async def test_prior_interrupt_ids_carried_forward():
  """Prior interrupt IDs from a previous run are on ctx."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      return
      yield  # noqa: unreachable

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'),
      parent_ctx=parent_ctx,
      prior_interrupt_ids={'fc-old'},
  ).run()

  assert 'fc-old' in child_ctx.interrupt_ids


@pytest.mark.asyncio
async def test_prior_and_new_interrupt_ids_merged():
  """New interrupt IDs are merged with prior ones."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(
          content=types.Content(
              parts=[
                  types.Part(
                      function_call=types.FunctionCall(
                          name='tool', args={}, id='fc-new'
                      )
                  )
              ]
          ),
          long_running_tool_ids={'fc-new'},
      )

  parent_ctx, _ = _make_ctx()
  child_ctx = await NodeRunner(
      node=_Node(name='n'),
      parent_ctx=parent_ctx,
      prior_interrupt_ids={'fc-old'},
  ).run()

  assert child_ctx.interrupt_ids == {'fc-old', 'fc-new'}


# =========================================================================
# event_author — parent orchestrator overrides event author
# =========================================================================


@pytest.mark.asyncio
async def test_event_author_defaults_to_node_name():
  """Without event_author, events use the node's own name."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield 'result'

  parent_ctx, events = _make_ctx()
  await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()

  assert events[0].author == 'my_node'


@pytest.mark.asyncio
async def test_event_author_overrides_node_name():
  """When parent sets event_author, events use that instead."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield 'result'

  parent_ctx, events = _make_ctx()
  parent_ctx.event_author = 'my_workflow'
  await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()

  assert events[0].author == 'my_workflow'


@pytest.mark.asyncio
async def test_event_author_overrides_preset_author():
  """event_author always wins, even over a pre-set event author."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(author='custom_author', output='result')

  parent_ctx, events = _make_ctx()
  parent_ctx.event_author = 'my_workflow'
  await NodeRunner(node=_Node(name='my_node'), parent_ctx=parent_ctx).run()

  assert events[0].author == 'my_workflow'


# =========================================================================
# Branch propagation tests
# =========================================================================


@pytest.mark.asyncio
async def test_override_branch_used_in_node_runner():
  """NodeRunner uses override_branch if provided."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(output='result')

  parent_ctx, events = _make_ctx()
  await NodeRunner(
      node=_Node(name='n'),
      parent_ctx=parent_ctx,
      override_branch='custom_branch',
  ).run()

  assert events[0].branch == 'custom_branch'


@pytest.mark.asyncio
async def test_use_sub_branch_appends_segment_to_branch():
  """NodeRunner appends node_name@run_id to branch when use_sub_branch is True."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(output='result')

  parent_ctx, events = _make_ctx()
  parent_ctx._invocation_context.branch = 'parent_branch'
  await NodeRunner(
      node=_Node(name='n'),
      parent_ctx=parent_ctx,
      use_sub_branch=True,
      run_id='1',
  ).run()

  assert events[0].branch == 'parent_branch.n@1'


@pytest.mark.asyncio
async def test_sequential_branch_propagation():
  """NodeRunner inherits parent branch when use_sub_branch is False."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(output='result')

  parent_ctx, events = _make_ctx()
  parent_ctx._invocation_context.branch = 'parent_branch'
  await NodeRunner(
      node=_Node(name='n'),
      parent_ctx=parent_ctx,
      use_sub_branch=False,
  ).run()

  assert events[0].branch == 'parent_branch'


@pytest.mark.asyncio
async def test_child_event_branch_does_not_mutate_parent_ic():
  """A child node altering its branch does not mutate the parent's shared InvocationContext branch."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      yield Event(output='result', branch='new_child_branch')

  parent_ctx, events = _make_ctx()
  parent_ctx._invocation_context.branch = 'parent_branch'
  await NodeRunner(
      node=_Node(name='n'),
      parent_ctx=parent_ctx,
      use_sub_branch=False,
  ).run()

  assert events[0].branch == 'new_child_branch'
  # The parent's branch must remain unchanged.
  assert parent_ctx._invocation_context.branch == 'parent_branch'


@pytest.mark.asyncio
async def test_override_isolation_scope_used_in_node_runner():
  """NodeRunner sets isolation_scope on child context and enriches emitted events."""

  class _Node(BaseNode):

    async def _run_impl(self, *, ctx, node_input):
      assert ctx.isolation_scope == 'task:fc-999'
      yield Event(output='result')

  parent_ctx, events = _make_ctx()
  await NodeRunner(
      node=_Node(name='n'),
      parent_ctx=parent_ctx,
      override_isolation_scope='task:fc-999',
  ).run()

  assert events[0].isolation_scope == 'task:fc-999'
