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

from __future__ import annotations

from google.adk.events.event import Event
from google.adk.events.event import NodeInfo
from google.adk.events.request_input import RequestInput
from google.adk.workflow._base_node import BaseNode
from google.adk.workflow.utils._rehydration_utils import _ChildScanState
from google.adk.workflow.utils._rehydration_utils import _process_rehydrated_output
from google.adk.workflow.utils._rehydration_utils import _reconstruct_node_states
from google.adk.workflow.utils._rehydration_utils import _unwrap_response
from google.adk.workflow.utils._rehydration_utils import _validate_resume_response
from google.adk.workflow.utils._rehydration_utils import _wrap_response
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_event
from google.genai import types
from pydantic import BaseModel
import pytest

# --- _wrap_response ---


class TestWrapResponse:

  def test_dict_returned_as_is(self):
    d = {"foo": "bar"}
    assert _wrap_response(d) is d

  def test_string_wrapped(self):
    assert _wrap_response("hello") == {"result": "hello"}

  def test_int_wrapped(self):
    assert _wrap_response(42) == {"result": 42}

  def test_none_wrapped(self):
    assert _wrap_response(None) == {"result": None}

  def test_list_wrapped(self):
    assert _wrap_response([1, 2]) == {"result": [1, 2]}


# --- _unwrap_response ---


class TestUnwrapResponse:

  def test_single_result_key_string(self):
    assert _unwrap_response({"result": "hello"}) == "hello"

  def test_single_result_key_int(self):
    assert _unwrap_response({"result": 42}) == 42

  def test_single_result_key_none(self):
    assert _unwrap_response({"result": None}) is None

  def test_dict_without_result_key_unchanged(self):
    d = {"foo": "bar"}
    assert _unwrap_response(d) == {"foo": "bar"}

  def test_dict_with_multiple_keys_unchanged(self):
    d = {"result": "x", "other": "y"}
    assert _unwrap_response(d) == {"result": "x", "other": "y"}

  def test_non_dict_unchanged(self):
    assert _unwrap_response("hello") == "hello"
    assert _unwrap_response(42) == 42
    assert _unwrap_response(None) is None

  def test_json_string_parsed_to_dict(self):
    """Web frontend sends {"result": '{"approved": false}'}."""
    assert _unwrap_response({"result": '{"approved": false}'}) == {
        "approved": False
    }

  def test_json_string_parsed_to_list(self):
    assert _unwrap_response({"result": "[1, 2, 3]"}) == [1, 2, 3]

  def test_json_string_parsed_to_number(self):
    assert _unwrap_response({"result": "42"}) == 42

  def test_json_string_parsed_to_bool(self):
    assert _unwrap_response({"result": "true"}) is True

  def test_non_json_string_stays_string(self):
    assert _unwrap_response({"result": "plain text"}) == "plain text"

  def test_roundtrip_wrap_unwrap_string(self):
    assert _unwrap_response(_wrap_response("hello")) == "hello"

  def test_roundtrip_wrap_unwrap_dict(self):
    """Dicts are not wrapped, so unwrap is a no-op."""
    d = {"foo": "bar"}
    assert _unwrap_response(_wrap_response(d)) == d


# --- _process_rehydrated_output ---


class TestProcessRehydratedOutput:

  def test_extracts_plain_text_without_schema(self):
    node = BaseNode(name="dummy")
    content = types.Content(parts=[types.Part(text="hello world")])
    assert _process_rehydrated_output(node, content) == "hello world"

  def test_returns_plain_text_even_if_json_when_no_schema(self):
    node = BaseNode(name="dummy")
    content = types.Content(parts=[types.Part(text='{"foo": "bar"}')])
    assert _process_rehydrated_output(node, content) == '{"foo": "bar"}'

  def test_parses_json_text_with_output_schema(self):
    class MySchema(BaseModel):
      foo: str

    node = BaseNode(name="dummy", output_schema=MySchema)
    content = types.Content(parts=[types.Part(text='{"foo": "bar"}')])
    assert _process_rehydrated_output(node, content) == {"foo": "bar"}

  def test_joins_multiple_parts(self):
    node = BaseNode(name="dummy")
    content = types.Content(
        parts=[types.Part(text="hello "), types.Part(text="world")]
    )
    assert _process_rehydrated_output(node, content) == "hello world"

  def test_filters_thought_parts(self):
    class MySchema(BaseModel):
      answer: int

    node = BaseNode(name="dummy", output_schema=MySchema)
    content = types.Content(
        parts=[
            types.Part(text="thinking...", thought=True),
            types.Part(text='{"answer": 42}'),
        ]
    )
    assert _process_rehydrated_output(node, content) == {"answer": 42}

  def test_returns_none_for_empty_text(self):
    node = BaseNode(name="dummy")
    content = types.Content(parts=[types.Part(text="  ")])
    assert _process_rehydrated_output(node, content) is None

  def test_gracefully_falls_back_on_schema_mismatch(self, caplog):
    class MySchema(BaseModel):
      foo: str
      bar: int  # Required field that is missing in the stored output

    node = BaseNode(name="dummy", output_schema=MySchema)
    content = types.Content(parts=[types.Part(text='{"foo": "only"}')])

    # Should NOT raise ValueError, but fallback to unvalidated parsed dict
    res = _process_rehydrated_output(node, content)
    assert res == {"foo": "only"}
    assert (
        "Validation failed for rehydrated output against schema" in caplog.text
    )

  def test_raises_value_error_if_not_valid_json_on_schema_mismatch(self):
    class MySchema(BaseModel):
      foo: str

    node = BaseNode(name="dummy", output_schema=MySchema)
    content = types.Content(parts=[types.Part(text="invalid json")])

    # Should raise ValueError because it's not valid JSON
    with pytest.raises(
        ValueError,
        match="Validation failed for rehydrated output against schema",
    ):
      _process_rehydrated_output(node, content)


# --- _validate_resume_response ---


class TestValidateResumeResponse:

  def test_none_schema_returns_data(self):
    assert _validate_resume_response("hello", None) == "hello"

  def test_str_to_int_coercion(self):
    assert _validate_resume_response("42", {"type": "integer"}) == 42

  def test_str_to_float_coercion(self):
    assert _validate_resume_response("42.5", {"type": "number"}) == 42.5

  def test_str_to_bool_true(self):
    assert _validate_resume_response("true", {"type": "boolean"}) is True
    assert _validate_resume_response("1", {"type": "boolean"}) is True

  def test_str_to_bool_false(self):
    assert _validate_resume_response("false", {"type": "boolean"}) is False
    assert _validate_resume_response("0", {"type": "boolean"}) is False

  def test_invalid_coercion_raises_value_error(self):
    with pytest.raises(ValueError):
      _validate_resume_response("abc", {"type": "integer"})

  def test_object_schema_validates_dict_type(self):
    schema = {"type": "object"}
    assert _validate_resume_response({"name": "Alice"}, schema) == {
        "name": "Alice"
    }

    with pytest.raises(ValueError, match="Failed to coerce data to object"):
      _validate_resume_response("not a dict", schema)

  def test_array_schema_validates_list_type(self):
    schema = {"type": "array"}
    assert _validate_resume_response([1, 2], schema) == [1, 2]

    with pytest.raises(ValueError, match="Failed to coerce data to array"):
      _validate_resume_response("not a list", schema)

  def test_pydantic_type_validation(self):
    class User(BaseModel):
      name: str
      age: int

    assert _validate_resume_response(
        {"name": "Alice", "age": 30}, User
    ) == User(name="Alice", age=30)


# --- _reconstruct_node_states ---


class TestScanNodeEvents:

  def test_scan_empty_events(self):
    results = _reconstruct_node_states([], "/wf@1", invocation_id="test_id")
    assert results == {}

  def test_scan_direct_child_output(self):
    event = Event(
        node_info=NodeInfo(path="/wf@1/node_a@1"),
        output="node_a output",
        invocation_id="test_id",
    )
    results = _reconstruct_node_states(
        [event], "/wf@1", invocation_id="test_id", group_by_direct_child=True
    )

    assert "node_a@1" in results
    assert results["node_a@1"].output == "node_a output"
    assert results["node_a@1"].run_id == "1"

  def test_scan_message_as_output(self):
    content = types.Content(parts=[types.Part(text="hello")])
    event = Event(
        node_info=NodeInfo(path="/wf@1/node_a@1"),
        content=content,
        invocation_id="test_id",
    )
    event.node_info.message_as_output = True

    results = _reconstruct_node_states(
        [event], "/wf@1", invocation_id="test_id", group_by_direct_child=True
    )

    assert "node_a@1" in results
    assert results["node_a@1"].output == content

  def test_scan_descendant_interrupts(self):
    event = Event(
        node_info=NodeInfo(path="/wf@1/node_a@1/sub_node@1"),
        long_running_tool_ids={"interrupt-1"},
        invocation_id="test_id",
    )
    results = _reconstruct_node_states(
        [event], "/wf@1", invocation_id="test_id", group_by_direct_child=True
    )

    assert "node_a@1" in results
    assert "interrupt-1" in results["node_a@1"].interrupt_ids

  def test_scan_resolve_interrupts(self):
    event_int = Event(
        node_info=NodeInfo(path="/wf@1/node_a@1"),
        long_running_tool_ids={"interrupt-1"},
        invocation_id="test_id",
    )
    event_fr = Event(
        author="user",
        content=types.Content(
            parts=[
                types.Part(
                    function_response=types.FunctionResponse(
                        id="interrupt-1",
                        name="adk_request_input",
                        response={"result": "user answer"},
                    )
                )
            ]
        ),
        invocation_id="test_id",
    )

    # Act
    results = _reconstruct_node_states(
        [event_int, event_fr],
        "/wf@1",
        invocation_id="test_id",
        group_by_direct_child=True,
    )

    # Assert
    assert "node_a@1" in results
    assert "interrupt-1" in results["node_a@1"].resolved_ids
    assert (
        results["node_a@1"].resolved_responses["interrupt-1"] == "user answer"
    )

  def test_scan_matches_specific_node_path_without_child_grouping(self):
    """Scanning matches events for a specific node path when not grouping by direct child."""
    event = Event(
        node_info=NodeInfo(path="/wf@1/node_a@1"),
        output="node_a output",
        invocation_id="test_id",
    )

    # Act
    results = _reconstruct_node_states(
        [event],
        "/wf@1/node_a@1",
        invocation_id="test_id",
        group_by_direct_child=False,
    )

    # Assert
    assert "/wf@1/node_a@1" in results
    assert results["/wf@1/node_a@1"].output == "node_a output"

  def test_scan_validates_and_coerces_response_against_schema(self):
    """Scanning validates and coerces user response data against the provided schema."""

    class MySchema(BaseModel):
      count: int

    ri = RequestInput(
        interrupt_id="interrupt-1",
        response_schema=MySchema,
    )
    event_int = create_request_input_event(ri)
    event_int.node_info = NodeInfo(path="/wf@1/node_a@1")
    event_int.invocation_id = "test_id"

    event_fr = Event(
        author="user",
        content=types.Content(
            parts=[
                types.Part(
                    function_response=types.FunctionResponse(
                        id="interrupt-1",
                        name="adk_request_input",
                        response={"result": '{"count": "42"}'},
                    )
                )
            ]
        ),
        invocation_id="test_id",
    )

    # Act
    results = _reconstruct_node_states(
        [event_int, event_fr],
        "/wf@1",
        invocation_id="test_id",
        group_by_direct_child=True,
    )

    # Assert
    assert "node_a@1" in results
    assert results["node_a@1"].resolved_responses["interrupt-1"] == {
        "count": 42
    }
