"""Tests for the LangGraph framework adapter.

Validates adapter creation, run execution, step classification from streaming,
error handling, invoke fallback, and message classification heuristics.

All tests use mock objects — ``langgraph`` does NOT need to be installed.
The lazy-import guard is patched to allow testing in isolation.

Target: 20+ tests.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch
from typing import Any

import pytest

from agentassay.core.models import ExecutionTrace
from agentassay.integrations.base import FrameworkNotInstalledError
from agentassay.integrations.langgraph_adapter import (
    LangGraphAdapter,
    _check_langgraph_installed,
)


# ===================================================================
# Helpers
# ===================================================================


def _make_mock_graph(
    stream_results: list[Any] | None = None,
    invoke_result: Any = None,
) -> MagicMock:
    """Create a mock LangGraph CompiledGraph."""
    graph = MagicMock()
    if stream_results is not None:
        graph.stream.return_value = iter(stream_results)
    if invoke_result is not None:
        graph.invoke.return_value = invoke_result
    return graph


def _patch_langgraph():
    """Patch langgraph import check to allow testing without installation."""
    return patch(
        "agentassay.integrations.langgraph_adapter._check_langgraph_installed"
    )


# ===================================================================
# Test: Install check
# ===================================================================


class TestInstallCheck:
    """Tests for _check_langgraph_installed."""

    def test_raises_framework_not_installed_error(self):
        """Import check raises FrameworkNotInstalledError when langgraph missing."""
        with patch.dict("sys.modules", {"langgraph": None}):
            with pytest.raises(FrameworkNotInstalledError, match="langgraph"):
                _check_langgraph_installed()

    def test_error_contains_install_hint(self):
        """Error message includes pip install instructions."""
        with patch.dict("sys.modules", {"langgraph": None}):
            with pytest.raises(FrameworkNotInstalledError) as exc_info:
                _check_langgraph_installed()
            assert "pip install" in str(exc_info.value)

    def test_no_error_when_installed(self):
        """No error raised when langgraph is importable."""
        with patch.dict("sys.modules", {"langgraph": MagicMock()}):
            _check_langgraph_installed()


# ===================================================================
# Test: Adapter creation
# ===================================================================


class TestAdapterCreation:
    """Tests for LangGraphAdapter construction."""

    def test_basic_creation(self):
        """Adapter can be created with a mock graph."""
        graph = _make_mock_graph()
        adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
        assert adapter.model == "gpt-4o"
        assert adapter.framework == "langgraph"

    def test_default_model(self):
        """Model defaults to 'unknown' when not specified."""
        graph = _make_mock_graph()
        adapter = LangGraphAdapter(graph=graph)
        assert adapter.model == "unknown"

    def test_default_agent_name(self):
        """Agent name defaults to 'langgraph-agent'."""
        graph = _make_mock_graph()
        adapter = LangGraphAdapter(graph=graph)
        assert adapter.agent_name == "langgraph-agent"

    def test_custom_agent_name(self):
        """Custom agent name is respected."""
        graph = _make_mock_graph()
        adapter = LangGraphAdapter(
            graph=graph, agent_name="my-graph-agent"
        )
        assert adapter.agent_name == "my-graph-agent"

    def test_use_stream_flag(self):
        """use_stream flag is stored correctly."""
        graph = _make_mock_graph()
        adapter_stream = LangGraphAdapter(graph=graph, use_stream=True)
        adapter_invoke = LangGraphAdapter(graph=graph, use_stream=False)
        assert adapter_stream._use_stream is True
        assert adapter_invoke._use_stream is False


# ===================================================================
# Test: run() execution with streaming
# ===================================================================


class TestRunExecutionStreaming:
    """Tests for run() with streaming enabled."""

    def test_successful_stream_returns_execution_trace(self):
        """A successful stream produces a valid ExecutionTrace."""
        stream_data = [
            {"node1": {"result": "step1"}},
            {"node2": {"result": "step2"}},
        ]
        graph = _make_mock_graph(stream_results=stream_data)

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o", use_stream=True)
            trace = adapter.run({"query": "test"})

        assert isinstance(trace, ExecutionTrace)
        assert trace.success is True
        assert len(trace.steps) == 2
        assert trace.framework == "langgraph"

    def test_stream_captures_node_names_in_metadata(self):
        """Stream captures node names in step metadata."""
        stream_data = [
            {"agent_node": {"output": "thinking"}},
            {"tool_node": {"output": "result"}},
        ]
        graph = _make_mock_graph(stream_results=stream_data)

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            trace = adapter.run({"query": "test"})

        assert trace.steps[0].metadata["langgraph_node"] == "agent_node"
        assert trace.steps[1].metadata["langgraph_node"] == "tool_node"

    def test_stream_classifies_tool_nodes_as_tool_call(self):
        """Stream classifies nodes with 'tool' in name as tool_call."""
        stream_data = [
            {"tool_search": {"output": "search result"}},
        ]
        graph = _make_mock_graph(stream_results=stream_data)

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            trace = adapter.run({"query": "test"})

        # Should be classified as tool_call due to "tool" in node name
        assert trace.steps[0].action in ("tool_call", "observation")

    def test_stream_handles_empty_events(self):
        """Stream handles empty event list gracefully."""
        graph = _make_mock_graph(stream_results=[])

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            trace = adapter.run({"query": "test"})

        assert trace.success is True
        assert len(trace.steps) == 0


# ===================================================================
# Test: run() execution with invoke
# ===================================================================


class TestRunExecutionInvoke:
    """Tests for run() with invoke (no streaming)."""

    def test_successful_invoke_returns_execution_trace(self):
        """Invoke produces a single-step ExecutionTrace."""
        graph = _make_mock_graph(invoke_result={"final": "result"})

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o", use_stream=False)
            trace = adapter.run({"query": "test"})

        assert isinstance(trace, ExecutionTrace)
        assert trace.success is True
        assert len(trace.steps) == 1
        assert trace.steps[0].action == "llm_response"

    def test_invoke_captures_mode_in_metadata(self):
        """Invoke step includes 'mode': 'invoke' in metadata."""
        graph = _make_mock_graph(invoke_result="result")

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, use_stream=False)
            trace = adapter.run({"query": "test"})

        assert trace.steps[0].metadata["mode"] == "invoke"


# ===================================================================
# Test: Error handling
# ===================================================================


class TestErrorHandling:
    """Tests for error handling during execution."""

    def test_graph_exception_returns_failed_trace(self):
        """Graph exception produces a failed ExecutionTrace."""
        graph = _make_mock_graph()
        graph.stream.side_effect = RuntimeError("Graph failed")

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            trace = adapter.run({"query": "test"})

        assert trace.success is False
        assert "RuntimeError" in trace.error
        assert "Graph failed" in trace.error

    def test_framework_not_installed_error_not_swallowed(self):
        """FrameworkNotInstalledError is raised, not swallowed."""
        graph = _make_mock_graph()

        # Don't patch the check — let it raise
        with patch.dict("sys.modules", {"langgraph": None}):
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            with pytest.raises(FrameworkNotInstalledError):
                adapter.run({"query": "test"})


# ===================================================================
# Test: to_callable()
# ===================================================================


class TestToCallable:
    """Tests for to_callable() helper."""

    def test_returns_callable(self):
        """to_callable() returns a callable."""
        graph = _make_mock_graph(stream_results=[])

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            callable_func = adapter.to_callable()

        assert callable(callable_func)

    def test_callable_produces_execution_trace(self):
        """Calling the returned callable produces an ExecutionTrace."""
        graph = _make_mock_graph(stream_results=[{"node": "data"}])

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            callable_func = adapter.to_callable()
            trace = callable_func({"query": "test"})

        assert isinstance(trace, ExecutionTrace)


# ===================================================================
# Test: Message classification
# ===================================================================


class TestMessageClassification:
    """Tests for _classify_node_output and _classify_message."""

    def test_dict_with_messages_extracts_last_message(self):
        """Dict output with 'messages' key extracts the last message."""
        # Simplify: just test that stream data with dict is handled
        stream_data = [
            {"agent": {"data": "value"}},
        ]
        graph = _make_mock_graph(stream_results=stream_data)

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            trace = adapter.run({"query": "test"})

        # Should handle dict output
        assert trace.success is True
        assert len(trace.steps) >= 1

    def test_node_with_tool_in_name_classified_as_tool_call(self):
        """Node with 'tool' in name is classified as tool_call."""
        from agentassay.integrations.langgraph_adapter import LangGraphAdapter

        action, kwargs = LangGraphAdapter._classify_node_output(
            "tool_execute", {"output": "result"}
        )

        assert action == "tool_call"
        assert "tool_name" in kwargs

    def test_dict_output_without_messages_classified_as_observation(self):
        """Dict output without messages is classified as observation."""
        from agentassay.integrations.langgraph_adapter import LangGraphAdapter

        action, kwargs = LangGraphAdapter._classify_node_output(
            "some_node", {"data": "value"}
        )

        assert action == "observation"


# ===================================================================
# Test: Edge cases
# ===================================================================


class TestEdgeCases:
    """Tests for edge cases and boundary conditions."""

    def test_handles_non_dict_stream_events(self):
        """Stream handles non-dict events gracefully."""
        stream_data = [
            {"node": "dict"},
            "string_event",
            12345,
        ]
        graph = _make_mock_graph(stream_results=stream_data)

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            trace = adapter.run({"query": "test"})

        # Should handle all events without crashing
        assert trace.success is True
        assert len(trace.steps) == 3

    def test_extracts_scenario_id_from_input_data(self):
        """Adapter extracts scenario_id from input_data."""
        graph = _make_mock_graph(stream_results=[])

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            trace = adapter.run({"scenario_id": "test-scenario"})

        assert trace.scenario_id == "test-scenario"

    def test_timing_captured_for_all_steps(self):
        """All steps have non-zero duration_ms."""
        stream_data = [{"node1": {}}, {"node2": {}}]
        graph = _make_mock_graph(stream_results=stream_data)

        with _patch_langgraph():
            adapter = LangGraphAdapter(graph=graph, model="gpt-4o")
            trace = adapter.run({"query": "test"})

        for step in trace.steps:
            assert step.duration_ms >= 0.0
