# 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 Workflow schema validation (input_schema, output_schema)."""

from __future__ import annotations

from google.adk.apps.app import App
from google.adk.events.event import Event
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.workflow import BaseNode
from google.adk.workflow import JoinNode
from google.adk.workflow import START
from google.adk.workflow._workflow import Workflow
from google.genai import types
from pydantic import BaseModel
from pydantic import ValidationError
import pytest

from .. import testing_utils
from .workflow_testing_utils import create_parent_invocation_context


class _OutputModel(BaseModel):
  name: str
  value: int


class _OtherModel(BaseModel):
  name: str
  value: int
  extra: str = 'default'


@pytest.mark.asyncio
async def test_workflow_output_schema_validates_terminal(
    request: pytest.FixtureRequest,
):
  """Workflow.output_schema validates when downstream reads the output."""

  def produce() -> dict:
    return {'name': 'result', 'value': 10}

  def consume(node_input: dict) -> str:
    return f"got {node_input['name']}"

  inner = Workflow(
      name='wf',
      edges=[(START, produce)],
      output_schema=_OutputModel,
  )
  outer = Workflow(
      name='outer',
      edges=[(START, inner, consume)],
  )
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))
  data_events = [e for e in events if isinstance(e, Event) and e.output]
  assert any(e.output == 'got result' for e in data_events)


@pytest.mark.asyncio
async def test_workflow_output_schema_rejects_invalid(
    request: pytest.FixtureRequest,
):
  """Workflow.output_schema rejects invalid output when downstream reads."""

  def produce_bad() -> dict:
    return {'wrong_field': 'oops'}

  def consume(node_input: dict) -> str:
    return 'should not reach'

  inner = Workflow(
      name='wf',
      edges=[(START, produce_bad)],
      output_schema=_OutputModel,
  )
  outer = Workflow(
      name='outer',
      edges=[(START, inner, consume)],
  )
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  with pytest.raises(ValueError):
    await runner.run_async(testing_utils.get_user_content('start'))


@pytest.mark.asyncio
async def test_workflow_output_schema_coerces_defaults(
    request: pytest.FixtureRequest,
):
  """Workflow.output_schema coerces terminal output (fills defaults)."""

  def produce() -> dict:
    return {'name': 'x', 'value': 1}

  def consume(node_input: dict) -> dict:
    return node_input

  inner = Workflow(
      name='wf',
      edges=[(START, produce)],
      output_schema=_OtherModel,
  )
  outer = Workflow(
      name='outer',
      edges=[(START, inner, consume)],
  )
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))
  data_events = [e for e in events if isinstance(e, Event) and e.output]
  consume_events = [e for e in data_events if e.node_info.name == 'consume']
  assert len(consume_events) == 1
  assert consume_events[0].output == {
      'name': 'x',
      'value': 1,
      'extra': 'default',
  }


@pytest.mark.asyncio
async def test_nested_workflow_output_schema(
    request: pytest.FixtureRequest,
):
  """Nested workflow's output_schema validates before passing to parent."""

  def inner_produce() -> dict:
    return {'name': 'inner', 'value': 3}

  inner_workflow = Workflow(
      name='inner_wf',
      edges=[(START, inner_produce)],
      output_schema=_OutputModel,
  )

  def outer_consume(node_input: dict) -> str:
    return f"got {node_input['name']}"

  outer_workflow = Workflow(
      name='outer_wf',
      edges=[
          (START, inner_workflow),
          (inner_workflow, outer_consume),
      ],
  )
  app = App(name=request.function.__name__, root_agent=outer_workflow)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))

  data_events = [e for e in events if isinstance(e, Event) and e.output]
  assert any(e.output == 'got inner' for e in data_events)


@pytest.mark.asyncio
async def test_workflow_output_schema_validates_multiple_terminals(
    request: pytest.FixtureRequest,
):
  """Each terminal output is validated when downstream reads."""

  class _JoinedModel(BaseModel):
    branch_a: _OtherModel
    branch_b: _OtherModel

  def branch_a() -> dict:
    return {'name': 'from_a', 'value': 1}

  def branch_b() -> dict:
    return {'name': 'from_b', 'value': 2}

  join = JoinNode(name='join')

  inner = Workflow(
      name='wf',
      edges=[
          (START, branch_a),
          (START, branch_b),
          (branch_a, join),
          (branch_b, join),
      ],
      output_schema=_JoinedModel,
  )

  def consume(node_input: dict) -> dict:
    return node_input

  outer = Workflow(
      name='outer',
      edges=[(START, inner, consume)],
  )
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))
  consume_events = [
      e
      for e in events
      if isinstance(e, Event) and e.output and e.node_info.name == 'consume'
  ]
  assert len(consume_events) == 1

  # Both terminal outputs should have 'extra' filled by _OtherModel default.
  output = consume_events[0].output
  assert output['branch_a'] == {
      'name': 'from_a',
      'value': 1,
      'extra': 'default',
  }
  assert output['branch_b'] == {
      'name': 'from_b',
      'value': 2,
      'extra': 'default',
  }


@pytest.mark.asyncio
async def test_workflow_output_schema_rejects_invalid_among_multiple_terminals(
    request: pytest.FixtureRequest,
):
  """One invalid terminal among multiple raises validation error."""

  class _JoinedModel(BaseModel):
    branch_good: _OutputModel
    branch_bad: _OutputModel

  def branch_good() -> dict:
    return {'name': 'ok', 'value': 1}

  def branch_bad() -> dict:
    return {'wrong_field': 'oops'}

  join = JoinNode(name='join')

  inner = Workflow(
      name='wf',
      edges=[
          (START, branch_good),
          (START, branch_bad),
          (branch_good, join),
          (branch_bad, join),
      ],
      output_schema=_JoinedModel,
  )

  def consume(node_input: dict) -> str:
    return 'should not reach'

  outer = Workflow(
      name='outer',
      edges=[(START, inner, consume)],
  )
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  with pytest.raises(ValueError):
    await runner.run_async(testing_utils.get_user_content('start'))


# ── Primitive and generic type output_schema ─────────────────────────


@pytest.mark.asyncio
async def test_workflow_output_schema_int_coerces(
    request: pytest.FixtureRequest,
):
  """Workflow output_schema=int coerces string to int at read time."""

  def produce() -> str:
    return '42'

  def consume(node_input: int) -> int:
    return node_input

  inner = Workflow(
      name='wf',
      edges=[(START, produce)],
      output_schema=int,
  )
  outer = Workflow(name='outer', edges=[(START, inner, consume)])
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))
  consume_events = [
      e
      for e in events
      if isinstance(e, Event)
      and e.output is not None
      and e.node_info.name == 'consume'
  ]
  assert len(consume_events) == 1
  assert consume_events[0].output == 42


@pytest.mark.asyncio
async def test_workflow_output_schema_int_rejects_invalid(
    request: pytest.FixtureRequest,
):
  """Workflow output_schema=int rejects non-coercible value at read time."""

  def produce() -> dict:
    return {'key': 'value'}

  def consume(node_input: int) -> int:
    return node_input

  inner = Workflow(
      name='wf',
      edges=[(START, produce)],
      output_schema=int,
  )
  outer = Workflow(name='outer', edges=[(START, inner, consume)])
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  with pytest.raises(ValueError):
    await runner.run_async(testing_utils.get_user_content('start'))


@pytest.mark.asyncio
async def test_workflow_output_schema_list_of_str(
    request: pytest.FixtureRequest,
):
  """Workflow output_schema=list[str] validates list output at read time."""

  def produce() -> list:
    return ['a', 'b', 'c']

  def consume(node_input: list) -> list:
    return node_input

  inner = Workflow(
      name='wf',
      edges=[(START, produce)],
      output_schema=list[str],
  )
  outer = Workflow(name='outer', edges=[(START, inner, consume)])
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))
  consume_events = [
      e
      for e in events
      if isinstance(e, Event)
      and e.output is not None
      and e.node_info.name == 'consume'
  ]
  assert len(consume_events) == 1
  assert consume_events[0].output == ['a', 'b', 'c']


@pytest.mark.asyncio
async def test_workflow_output_schema_list_of_basemodel(
    request: pytest.FixtureRequest,
):
  """Workflow output_schema=list[BaseModel] validates and serializes."""

  def produce() -> list:
    return [
        {'name': 'x', 'value': 1},
        {'name': 'y', 'value': 2},
    ]

  def consume(node_input: list) -> list:
    return node_input

  inner = Workflow(
      name='wf',
      edges=[(START, produce)],
      output_schema=list[_OutputModel],
  )
  outer = Workflow(name='outer', edges=[(START, inner, consume)])
  app = App(name=request.function.__name__, root_agent=outer)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))
  consume_events = [
      e
      for e in events
      if isinstance(e, Event)
      and e.output is not None
      and e.node_info.name == 'consume'
  ]
  assert len(consume_events) == 1
  assert consume_events[0].output == [
      {'name': 'x', 'value': 1},
      {'name': 'y', 'value': 2},
  ]


# ── End-to-end: input_schema + output_schema combined ──────────────


class _TaskOutput(BaseModel):
  result: str
  score: int


class _ReviewResult(BaseModel):
  result: str
  score: int
  reviewer: str = 'auto'


@pytest.mark.asyncio
async def test_e2e_input_and_output_schema_pipeline(
    request: pytest.FixtureRequest,
):
  """output_schema on producer + input_schema on consumer validates both."""

  def produce() -> _TaskOutput:
    return {'result': 'done', 'score': 88}

  def consume(node_input: _TaskOutput) -> str:
    return f'result={node_input.result}, score={node_input.score}'

  agent = Workflow(
      name='wf',
      edges=[(START, produce), (produce, consume)],
  )
  app = App(name=request.function.__name__, root_agent=agent)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))

  data_events = [e for e in events if isinstance(e, Event) and e.output]
  assert any(e.output == 'result=done, score=88' for e in data_events)


@pytest.mark.asyncio
async def test_e2e_output_schema_fails_before_input_schema(
    request: pytest.FixtureRequest,
):
  """Producer output_schema failure prevents consumer from running."""

  def produce_bad() -> _TaskOutput:
    return {'wrong': 'shape'}

  def consume(node_input: _TaskOutput) -> str:
    return 'should not reach'

  agent = Workflow(
      name='wf',
      edges=[(START, produce_bad), (produce_bad, consume)],
  )
  app = App(name=request.function.__name__, root_agent=agent)
  runner = testing_utils.InMemoryRunner(app=app)
  with pytest.raises(ValueError):
    await runner.run_async(testing_utils.get_user_content('start'))


@pytest.mark.asyncio
async def test_e2e_fan_out_join_with_schemas(
    request: pytest.FixtureRequest,
):
  """Fan-out -> join -> consume with input/output schemas."""

  class _JoinedResult(BaseModel):
    analyzer: dict
    summarizer: dict

  def analyzer() -> _TaskOutput:
    return {'result': 'analysis complete', 'score': 92}

  def summarizer() -> _TaskOutput:
    return {'result': 'summary complete', 'score': 78}

  join = JoinNode(name='join')

  def reviewer(node_input: dict) -> _ReviewResult:
    a = node_input['analyzer']
    s = node_input['summarizer']
    return {
        'result': f"{a['result']} + {s['result']}",
        'score': (a['score'] + s['score']) // 2,
    }

  agent = Workflow(
      name='wf',
      edges=[
          (START, analyzer),
          (START, summarizer),
          (analyzer, join),
          (summarizer, join),
          (join, reviewer),
      ],
  )
  app = App(name=request.function.__name__, root_agent=agent)
  runner = testing_utils.InMemoryRunner(app=app)
  events = await runner.run_async(testing_utils.get_user_content('start'))

  terminal = [
      e
      for e in events
      if isinstance(e, Event)
      and e.output is not None
      and e.node_info.name == 'reviewer'
  ]
  assert len(terminal) == 1
  assert terminal[0].output == {
      'result': 'analysis complete + summary complete',
      'score': 85,
      'reviewer': 'auto',
  }
  assert 'wf' in terminal[0].node_info.path
  assert 'reviewer' in terminal[0].node_info.path


@pytest.mark.asyncio
async def test_start_node_with_str_input_schema():
  """input_schema=str parses user text."""

  class _AssertingNode(BaseNode):

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      assert node_input == 'hello'
      yield 'done'

  node = _AssertingNode(name='node', input_schema=str)
  wf = Workflow(name='wf', edges=[(START, node)])

  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='hello')], role='user')
  events = []
  async for event in runner.run_async(
      user_id='u', session_id=session.id, new_message=msg
  ):
    events.append(event)

  data_events = [e for e in events if isinstance(e, Event) and e.output]
  assert any(e.output == 'done' for e in data_events)


@pytest.mark.asyncio
async def test_start_node_with_int_input_schema():
  """input_schema=int parses user text to int."""

  class _AssertingNode(BaseNode):

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      assert node_input == 42
      yield 'done'

  node = _AssertingNode(name='node', input_schema=int)
  wf = Workflow(name='wf', edges=[(START, node)])

  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='42')], role='user')
  events = []

  async for event in runner.run_async(
      user_id='u', session_id=session.id, new_message=msg
  ):
    events.append(event)

  data_events = [e for e in events if isinstance(e, Event) and e.output]
  assert any(e.output == 'done' for e in data_events)


@pytest.mark.asyncio
async def test_start_node_with_int_list_input_schema():
  """input_schema=list[int] parses JSON list."""

  class _AssertingNode(BaseNode):

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      assert node_input == [1, 2, 3]
      yield 'done'

  node = _AssertingNode(name='node', input_schema=list[int])
  wf = Workflow(name='wf', edges=[(START, node)])

  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='[1, 2, 3]')], role='user')
  events = []

  async for event in runner.run_async(
      user_id='u', session_id=session.id, new_message=msg
  ):
    events.append(event)

  data_events = [e for e in events if isinstance(e, Event) and e.output]
  assert any(e.output == 'done' for e in data_events)


@pytest.mark.asyncio
async def test_start_node_with_invalid_input_schema():
  """Invalid input against schema raises error."""

  class _MyModel(BaseModel):
    age: int

  class _AssertingNode(BaseNode):

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      yield 'done'

  node = _AssertingNode(name='node', input_schema=_MyModel)
  wf = Workflow(name='wf', edges=[(START, node)])

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

  # Pass invalid input (Content instead of dict with age)
  msg = types.Content(parts=[types.Part(text='hello')], role='user')

  # We expect it to raise ValidationError
  with pytest.raises(ValidationError):
    async for event in runner.run_async(
        user_id='u', session_id=session.id, new_message=msg
    ):
      pass


@pytest.mark.asyncio
async def test_start_node_receives_parsed_user_content_with_schema():
  """Parsed input replaces raw Content for first node."""

  class _AssertingNode(BaseNode):

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      assert isinstance(node_input, str)
      assert node_input == 'hello'
      yield 'done'

  node = _AssertingNode(name='node', input_schema=str)
  wf = Workflow(name='wf', edges=[(START, node)])

  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='hello')], role='user')
  events = []
  async for event in runner.run_async(
      user_id='u', session_id=session.id, new_message=msg
  ):
    events.append(event)

  data_events = [e for e in events if isinstance(e, Event) and e.output]
  assert any(e.output == 'done' for e in data_events)


@pytest.mark.asyncio
async def test_workflow_with_invalid_output_schema():
  """Workflow raises ValidationError if terminal output doesn't match output_schema."""

  from pydantic import ValidationError

  class _MyModel(BaseModel):
    name: str

  class _MyNode(BaseNode):

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      yield {'age': 10}

  node = _MyNode(name='node')
  wf = Workflow(name='wf', edges=[(START, node)], output_schema=_MyModel)

  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='hello')], role='user')

  with pytest.raises(ValidationError):
    async for event in runner.run_async(
        user_id='u', session_id=session.id, new_message=msg
    ):
      pass


@pytest.mark.asyncio
async def test_node_returns_content_json_parsed():
  """Node output as types.Content containing JSON is parsed if output_schema is defined."""

  class _MyModel(BaseModel):
    name: str
    age: int

  class _MyNode(BaseNode):

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      yield types.Content(
          parts=[types.Part(text='{"name": "Alice", "age": 30}')]
      )

  node = _MyNode(name='node', output_schema=_MyModel)
  wf = Workflow(name='wf', edges=[(START, node)])

  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='hello')], role='user')
  events = []

  async for event in runner.run_async(
      user_id='u', session_id=session.id, new_message=msg
  ):
    events.append(event)

  data_events = [e for e in events if isinstance(e, Event) and e.output]

  assert len(data_events) == 1
  assert data_events[0].output == {'name': 'Alice', 'age': 30}


@pytest.mark.asyncio
async def test_node_returns_raw_string_not_parsed():
  """Node output as raw JSON string is NOT parsed if output_schema is defined."""
  from pydantic import ValidationError

  class _MyModel(BaseModel):
    name: str
    age: int

  class _MyNode(BaseNode):

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      # This should fail validation because it's a string, not a dict/model
      yield '{"name": "Alice", "age": 30}'

  node = _MyNode(name='node', output_schema=_MyModel)
  wf = Workflow(name='wf', edges=[(START, node)])

  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='hello')], role='user')

  with pytest.raises(ValidationError):
    async for _ in runner.run_async(
        user_id='u', session_id=session.id, new_message=msg
    ):
      pass


@pytest.mark.asyncio
async def test_output_schema_enforced_by_runtime_without_manual_validation():
  """Runtime enforces output_schema even when _run_impl doesn't call _validate_output_data."""
  from pydantic import ValidationError

  class _MyModel(BaseModel):
    name: str
    age: int

  class _NaiveNode(BaseNode):
    """Node that yields raw data without any manual validation."""

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      yield {'color': 'red'}  # Does NOT match _MyModel

  node = _NaiveNode(name='node', output_schema=_MyModel)
  wf = Workflow(name='wf', edges=[(START, node)])

  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='hello')], role='user')

  with pytest.raises(ValidationError):
    async for _ in runner.run_async(
        user_id='u', session_id=session.id, new_message=msg
    ):
      pass


@pytest.mark.asyncio
async def test_output_schema_enforced_for_valid_raw_yield():
  """Runtime validates and coerces valid raw yields against output_schema."""

  class _MyModel(BaseModel):
    name: str
    age: int

  class _NaiveNode(BaseNode):
    """Node that yields valid raw data without manual validation."""

    async def _run_impl(
        self, *, ctx: Context, node_input: Any
    ) -> AsyncGenerator[Any, None]:
      yield {'name': 'Alice', 'age': 30}

  node = _NaiveNode(name='node', output_schema=_MyModel)
  wf = Workflow(name='wf', edges=[(START, node)])

  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='hello')], role='user')
  events = []

  async for event in runner.run_async(
      user_id='u', session_id=session.id, new_message=msg
  ):
    events.append(event)

  data_events = [e for e in events if isinstance(e, Event) and e.output]
  assert len(data_events) == 1
  assert data_events[0].output == {'name': 'Alice', 'age': 30}
