# Copyright (c) Microsoft. All rights reserved.

"""Unit tests for workflow serialization helpers.

``resolve_type`` is annotated ``type | None`` and its result flows into
``reconstruct_to_type``, which calls ``issubclass``. A non-class attribute
(function, module member, etc.) would raise ``TypeError`` there, so the
resolver must only ever return actual classes.

``deserialize_workflow_output`` reverses the per-output ``serialize_value``
encoding the shared activity applies, so typed outputs are returned as the
original objects rather than checkpoint-marker dicts.

``serialize_value`` / ``deserialize_value`` are the internal codec; the
round-trip, ``reconstruct_to_type``, and ``strip_pickle_markers`` suites below
guard the type fidelity and the trust-boundary defense that neutralizes
attacker-injected pickle/type markers before they can reach ``pickle.loads()``.
"""

import json
from collections import OrderedDict
from dataclasses import dataclass

from agent_framework import (
    AgentExecutorRequest,
    AgentExecutorResponse,
    AgentResponse,
    Message,
    WorkflowEvent,
)
from pydantic import BaseModel

from agent_framework_durabletask._workflows.serialization import (
    SUBWORKFLOW_INPUT_KEY,
    deserialize_value,
    deserialize_workflow_event,
    deserialize_workflow_output,
    reconstruct_to_type,
    resolve_type,
    serialize_value,
    serialize_workflow_event,
    strip_pickle_markers,
    strip_subworkflow_markers,
)


@dataclass
class _Decision:
    """Module-level dataclass so it is picklable by serialize_value."""

    approved: bool
    note: str


class TestResolveType:
    """Test that resolve_type only returns real classes."""

    def test_resolves_a_real_class(self) -> None:
        assert resolve_type("collections:OrderedDict") is OrderedDict

    def test_returns_none_for_non_class_attribute(self) -> None:
        # json.dumps is a function; if resolve_type returned it, issubclass()
        # inside reconstruct_to_type() would raise TypeError at runtime.
        assert resolve_type("json:dumps") is None

    def test_returns_none_for_unknown_attribute(self) -> None:
        assert resolve_type("json:DoesNotExist") is None

    def test_returns_none_for_malformed_key(self) -> None:
        assert resolve_type("not-a-valid-key") is None


class TestDeserializeWorkflowOutput:
    """Reconstruction of stored workflow outputs."""

    def test_primitives_pass_through(self) -> None:
        # Mirror the stored shape: a list of yielded outputs, JSON round-tripped.
        stored = json.loads(json.dumps([serialize_value("hello"), serialize_value(42)]))

        assert deserialize_workflow_output(stored) == ["hello", 42]

    def test_typed_outputs_are_reconstructed(self) -> None:
        # A typed object is stored as a checkpoint-marker dict; it must come back
        # as the original object, not the marker dict.
        decision = _Decision(approved=True, note="ok")
        stored = json.loads(json.dumps([serialize_value(decision)]))

        result = deserialize_workflow_output(stored)

        assert result == [decision]
        assert isinstance(result[0], _Decision)

    def test_none_passes_through(self) -> None:
        assert deserialize_workflow_output(None) is None


@dataclass
class _Approval:
    """Module-level dataclass so it is picklable by serialize_value."""

    reason: str


def _roundtrip(event: WorkflowEvent) -> WorkflowEvent:
    # Mirror the real path: serialize, JSON round-trip through the custom status,
    # then reconstruct on the client.
    return deserialize_workflow_event(json.loads(json.dumps(serialize_workflow_event(event))))


class TestWorkflowEventRoundtrip:
    """serialize_workflow_event / deserialize_workflow_event preserve event identity."""

    def test_output_event_reconstructs_typed_data(self) -> None:
        result = _roundtrip(WorkflowEvent("output", data=_Approval(reason="ok"), executor_id="writer"))

        assert result.type == "output"
        assert result.executor_id == "writer"
        assert result.data == _Approval(reason="ok")
        assert isinstance(result.data, _Approval)

    def test_executor_completed_without_data_roundtrips_to_none(self) -> None:
        result = _roundtrip(WorkflowEvent.executor_completed("reviewer"))

        assert result.type == "executor_completed"
        assert result.executor_id == "reviewer"
        assert result.data is None

    def test_iteration_tag_is_preserved(self) -> None:
        # The orchestrator tags each event with its superstep before publishing.
        serialized = serialize_workflow_event(WorkflowEvent.executor_invoked("writer"))
        serialized["iteration"] = 3

        result = deserialize_workflow_event(json.loads(json.dumps(serialized)))

        assert result.type == "executor_invoked"
        assert result.iteration == 3

    def test_request_info_event_roundtrips(self) -> None:
        event: WorkflowEvent = WorkflowEvent.request_info(
            request_id="req-1",
            source_executor_id="approver",
            request_data=_Approval(reason="needs sign-off"),
            response_type=bool,
        )

        result = _roundtrip(event)

        assert result.type == "request_info"
        assert result.request_id == "req-1"
        assert result.source_executor_id == "approver"
        assert result.response_type is bool
        assert result.data == _Approval(reason="needs sign-off")


# Module-level test types (must be importable for checkpoint encoding roundtrip).
@dataclass
class SampleData:
    """Sample dataclass for testing checkpoint encoding roundtrip."""

    name: str
    value: int


class SampleModel(BaseModel):
    """Sample Pydantic model for testing checkpoint encoding roundtrip."""

    title: str
    count: int


@dataclass
class DataclassWithPydanticField:
    """Dataclass containing a Pydantic model field for testing nested serialization."""

    label: str
    model: SampleModel


class TestSerializationRoundtrip:
    """``serialize_value`` / ``deserialize_value`` round-trip the typed objects used in workflows."""

    def test_roundtrip_chat_message(self) -> None:
        """Test Message survives encode → decode roundtrip."""
        original = Message(role="user", contents=["Hello"])
        encoded = serialize_value(original)
        decoded = deserialize_value(encoded)

        assert isinstance(decoded, Message)
        assert decoded.role == "user"

    def test_roundtrip_agent_executor_request(self) -> None:
        """Test AgentExecutorRequest with nested Messages roundtrips."""
        original = AgentExecutorRequest(
            messages=[Message(role="user", contents=["Hi"])],
            should_respond=True,
        )
        encoded = serialize_value(original)
        decoded = deserialize_value(encoded)

        assert isinstance(decoded, AgentExecutorRequest)
        assert len(decoded.messages) == 1
        assert isinstance(decoded.messages[0], Message)
        assert decoded.should_respond is True

    def test_roundtrip_agent_executor_response(self) -> None:
        """Test AgentExecutorResponse with nested AgentResponse roundtrips."""
        original = AgentExecutorResponse(
            executor_id="test_exec",
            agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Reply"])]),
            full_conversation=[Message(role="assistant", contents=["Reply"])],
        )
        encoded = serialize_value(original)
        decoded = deserialize_value(encoded)

        assert isinstance(decoded, AgentExecutorResponse)
        assert decoded.executor_id == "test_exec"
        assert isinstance(decoded.agent_response, AgentResponse)

    def test_roundtrip_dataclass(self) -> None:
        """Test custom dataclass roundtrips."""
        original = SampleData(name="test", value=42)
        encoded = serialize_value(original)
        decoded = deserialize_value(encoded)

        assert isinstance(decoded, SampleData)
        assert decoded.name == "test"
        assert decoded.value == 42

    def test_roundtrip_pydantic_model(self) -> None:
        """Test Pydantic model roundtrips."""
        original = SampleModel(title="Hello", count=5)
        encoded = serialize_value(original)
        decoded = deserialize_value(encoded)

        assert isinstance(decoded, SampleModel)
        assert decoded.title == "Hello"
        assert decoded.count == 5

    def test_roundtrip_primitives(self) -> None:
        """Test primitives pass through unchanged."""
        assert serialize_value(None) is None
        assert serialize_value("hello") == "hello"
        assert serialize_value(42) == 42
        assert serialize_value(3.14) == 3.14
        assert serialize_value(True) is True

    def test_roundtrip_list_of_objects(self) -> None:
        """Test list of typed objects roundtrips."""
        original = [
            Message(role="user", contents=["Q"]),
            Message(role="assistant", contents=["A"]),
        ]
        encoded = serialize_value(original)
        decoded = deserialize_value(encoded)

        assert isinstance(decoded, list)
        assert len(decoded) == 2
        assert all(isinstance(m, Message) for m in decoded)

    def test_roundtrip_dict_of_objects(self) -> None:
        """Test dict with typed values roundtrips (used for shared state)."""
        original = {"count": 42, "msg": Message(role="user", contents=["Hi"])}
        encoded = serialize_value(original)
        decoded = deserialize_value(encoded)

        assert decoded["count"] == 42
        assert isinstance(decoded["msg"], Message)

    def test_roundtrip_dataclass_with_nested_pydantic(self) -> None:
        """Test dataclass containing a Pydantic model field roundtrips correctly.

        This covers the HITL pattern where AnalysisWithSubmission (dataclass)
        contains a ContentAnalysisResult (Pydantic BaseModel) field.
        """
        original = DataclassWithPydanticField(label="test", model=SampleModel(title="Nested", count=99))
        encoded = serialize_value(original)
        decoded = deserialize_value(encoded)

        assert isinstance(decoded, DataclassWithPydanticField)
        assert decoded.label == "test"
        assert isinstance(decoded.model, SampleModel)
        assert decoded.model.title == "Nested"
        assert decoded.model.count == 99


class TestReconstructToType:
    """Test suite for reconstruct_to_type function (used for HITL responses)."""

    def test_none_returns_none(self) -> None:
        """Test that None input returns None."""
        assert reconstruct_to_type(None, str) is None

    def test_already_correct_type(self) -> None:
        """Test that values already of the correct type are returned as-is."""
        assert reconstruct_to_type("hello", str) == "hello"
        assert reconstruct_to_type(42, int) == 42

    def test_non_dict_returns_original(self) -> None:
        """Test that non-dict values are returned as-is."""
        assert reconstruct_to_type("hello", int) == "hello"
        assert reconstruct_to_type([1, 2], dict) == [1, 2]

    def test_reconstruct_pydantic_model(self) -> None:
        """Test reconstruction of Pydantic model from plain dict."""

        class ApprovalResponse(BaseModel):
            approved: bool
            reason: str

        data = {"approved": True, "reason": "Looks good"}
        result = reconstruct_to_type(data, ApprovalResponse)

        assert isinstance(result, ApprovalResponse)
        assert result.approved is True
        assert result.reason == "Looks good"

    def test_reconstruct_dataclass(self) -> None:
        """Test reconstruction of dataclass from plain dict."""

        @dataclass
        class Feedback:
            score: int
            comment: str

        data = {"score": 5, "comment": "Great"}
        result = reconstruct_to_type(data, Feedback)

        assert isinstance(result, Feedback)
        assert result.score == 5
        assert result.comment == "Great"

    def test_reconstruct_from_checkpoint_markers(self) -> None:
        """Test that data with checkpoint markers is decoded via deserialize_value.

        reconstruct_to_type is general-purpose and handles trusted checkpoint
        data.  Untrusted HITL callers must call strip_pickle_markers() first.
        """
        original = SampleData(value=99, name="marker-test")
        encoded = serialize_value(original)

        result = reconstruct_to_type(encoded, SampleData)
        assert isinstance(result, SampleData)
        assert result.value == 99

    def test_unrecognized_dict_returns_original(self) -> None:
        """Test that unrecognized dicts are returned as-is."""

        @dataclass
        class Unrelated:
            completely_different: str

        data = {"some_key": "some_value"}
        result = reconstruct_to_type(data, Unrelated)

        assert result == data

    def test_reconstruct_strips_injected_pickle_markers(self) -> None:
        """End-to-end: strip_pickle_markers + reconstruct_to_type blocks attack.

        This mirrors the real HITL flow where callers sanitize before reconstruction.
        """
        malicious = {"__pickled__": "gASVDgAAAAAAAACMBHRlc3SULg==", "__type__": "builtins:str"}
        sanitized = strip_pickle_markers(malicious)
        result = reconstruct_to_type(sanitized, str)
        assert result is None


class TestStripPickleMarkers:
    """Security tests for strip_pickle_markers — the defence-in-depth layer
    that prevents untrusted HTTP input from reaching pickle.loads()."""

    def test_strips_top_level_pickle_marker(self) -> None:
        """A dict containing __pickled__ must be replaced with None."""
        data = {"__pickled__": "PAYLOAD", "__type__": "os:system"}
        assert strip_pickle_markers(data) is None

    def test_strips_top_level_type_marker_only(self) -> None:
        """Even __type__ alone (without __pickled__) must be neutralised."""
        data = {"__type__": "os:system", "other": "value"}
        assert strip_pickle_markers(data) is None

    def test_strips_nested_pickle_marker(self) -> None:
        """Pickle markers nested inside a dict must be neutralised."""
        data = {"safe": "value", "nested": {"__pickled__": "PAYLOAD", "__type__": "os:system"}}
        result = strip_pickle_markers(data)
        assert result == {"safe": "value", "nested": None}

    def test_strips_pickle_marker_in_list(self) -> None:
        """Pickle markers inside a list element must be neutralised."""
        data = [{"__pickled__": "PAYLOAD"}, "safe"]
        result = strip_pickle_markers(data)
        assert result == [None, "safe"]

    def test_strips_deeply_nested_marker(self) -> None:
        """Deeply nested pickle markers must be neutralised."""
        data = {"a": {"b": {"c": {"__pickled__": "deep"}}}}
        result = strip_pickle_markers(data)
        assert result == {"a": {"b": {"c": None}}}

    def test_preserves_safe_dict(self) -> None:
        """Dicts without pickle markers must be left untouched."""
        data = {"approved": True, "reason": "Looks good"}
        assert strip_pickle_markers(data) == data

    def test_preserves_primitives(self) -> None:
        """Primitive values must pass through unchanged."""
        assert strip_pickle_markers("hello") == "hello"
        assert strip_pickle_markers(42) == 42
        assert strip_pickle_markers(None) is None
        assert strip_pickle_markers(True) is True

    def test_preserves_safe_list(self) -> None:
        """Lists without pickle markers must be left untouched."""
        data = [1, "two", {"key": "value"}]
        assert strip_pickle_markers(data) == data

    def test_mixed_safe_and_malicious(self) -> None:
        """Only the malicious entries should be stripped; safe entries remain."""
        data = {
            "user_input": "hello",
            "evil": {"__pickled__": "PAYLOAD", "__type__": "os:system"},
            "count": 42,
        }
        result = strip_pickle_markers(data)
        assert result == {"user_input": "hello", "evil": None, "count": 42}


class TestStripSubworkflowMarkers:
    """Boundary defence: a forged sub-workflow envelope in untrusted input is removed.

    Only an internal child dispatch (post trust boundary) may carry the reserved
    key; if untrusted client input could, it would be treated as a trusted
    sub-orchestration payload and reach pickle.loads without sanitization.
    """

    def test_strips_input_key(self) -> None:
        data = {SUBWORKFLOW_INPUT_KEY: {"__pickled__": "evil"}, "real": 1}
        assert strip_subworkflow_markers(data) == {"real": 1}

    def test_strips_full_forged_envelope(self) -> None:
        data = {SUBWORKFLOW_INPUT_KEY: "x"}
        assert strip_subworkflow_markers(data) == {}

    def test_preserves_ordinary_dict(self) -> None:
        data = {"order_id": 42, "items": ["a", "b"]}
        assert strip_subworkflow_markers(data) == data

    def test_preserves_non_dict(self) -> None:
        assert strip_subworkflow_markers("hello") == "hello"
        assert strip_subworkflow_markers([1, 2]) == [1, 2]
        assert strip_subworkflow_markers(None) is None
