# Copyright (c) Microsoft. All rights reserved.

"""
Integration tests for various agent frameworks with AgentLightning.

This module tests the integration of AgentLightning with:

- Autogen AgentChat
- LangChain/LangGraph
- OpenAI Agent SDK
- AgentOps
- Reward tracking functionality

Uses real agent frameworks but defaults to a mock OpenAI API server.

Set `USE_OPENAI=true`, plus `OPENAI_BASE_URL` and `OPENAI_API_KEY` environment variables to run
against the real API with an OpenAI model of your choice (`gpt-4.1-nano` by default).
"""

import difflib
import json
import os
import pprint
import re
import shutil
import time
import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Literal, Mapping, Optional, Tuple, Union

import litellm
import pytest
import requests
from agents import Agent, AgentHooks, GuardrailFunctionOutput, InputGuardrail, RunConfig, Runner, function_tool
from agents.mcp import MCPServerStdio
from agents.models.openai_provider import OpenAIProvider
from autogen_agentchat.agents import AssistantAgent
from autogen_agentchat.conditions import TextMentionTermination
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_ext.models.openai import OpenAIChatCompletionClient
from autogen_ext.tools.mcp import McpWorkbench, StdioServerParams
from fastapi import FastAPI

try:
    import langchain  # type: ignore

    LANGCHAIN_INSTALLED = True
except ImportError:
    LANGCHAIN_INSTALLED = False  # type: ignore

if TYPE_CHECKING or LANGCHAIN_INSTALLED:
    from langchain.agents import create_agent  # pyright: ignore[reportUnknownVariableType]
    from langchain.chat_models import init_chat_model
    from langchain_community.agent_toolkits import SQLDatabaseToolkit
    from langchain_community.utilities import SQLDatabase
    from langchain_core.messages import AIMessage
    from langchain_core.output_parsers import StrOutputParser
    from langchain_core.prompts import ChatPromptTemplate
    from langchain_core.tools import tool  # pyright: ignore[reportUnknownVariableType]
    from langchain_openai import ChatOpenAI
    from langgraph.graph import END, START, MessagesState, StateGraph

from openai import OpenAI
from opentelemetry.sdk.trace import ReadableSpan
from pydantic import BaseModel

from agentlightning.adapter.triplet import TracerTraceToTriplet, TraceTree
from agentlightning.emitter.annotation import operation
from agentlightning.emitter.reward import emit_reward
from agentlightning.semconv import AGL_REWARD
from agentlightning.tracer import Tracer
from agentlightning.tracer.agentops import AgentOpsTracer
from agentlightning.types import Span
from agentlightning.utils.server_launcher import PythonServerLauncher, PythonServerLauncherArgs

USE_OPENAI = os.environ.get("USE_OPENAI", "false").lower() == "true"
OPENAI_MODEL = "gpt-4.1-mini"
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if USE_OPENAI:
    assert (
        OPENAI_BASE_URL is not None and OPENAI_API_KEY is not None
    ), "OPENAI_BASE_URL and OPENAI_API_KEY must be set when USE_OPENAI is true"


@dataclass
class OpenAISettings:
    base_url: str
    api_key: str
    model: str


class MockOpenAICompatibleServer:
    """
    A mock server that mimics the OpenAI Chat Completions API for testing purposes.
    It provides deterministic, canned responses based on the content of the prompt.
    Now supports replaying from prompt caches.
    """

    def __init__(self) -> None:
        self.app = FastAPI()
        self.server_thread = None
        self.server = None
        self._prev_openai_base_url: Optional[str] = None
        self.prompt_caches = self._load_prompt_caches()
        self._setup_routes()

        self._server_launcher = PythonServerLauncher(self.app, PythonServerLauncherArgs(launch_mode="thread"))

    def _prompt_cache_path(self) -> str:
        return os.path.join(os.path.dirname(__file__), "../assets/prompt_caches.jsonl")

    def _load_prompt_caches(self):
        cache_path = self._prompt_cache_path()
        caches: List[Dict[str, Any]] = []
        if os.path.exists(cache_path):
            with open(cache_path, "r") as f:
                for line in f:
                    try:
                        caches.append(json.loads(line))
                    except Exception:
                        continue
        return caches

    def _find_best_cache_match(self, request_dict: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], float]:
        """
        Find the cached request with the highest similarity to the incoming request.
        Returns (response, similarity_score) or (None, 0.0) if not found.
        """

        def normalize_messages(msgs: List[Dict[str, Any]]) -> str:
            # Flatten messages to a string for comparison
            if not msgs:
                return ""
            return "\n".join(f"{m.get('role','')}:{m.get('content','')}" for m in msgs)

        req_msgs = request_dict.get("messages", [])
        req_tools = request_dict.get("tools", "")
        req_str = normalize_messages(req_msgs) + f"\ntools:{req_tools}"

        best_score = 0.0
        best_response = None
        for cache in self.prompt_caches:
            cache_req = cache.get("request", {})
            cache_msgs = cache_req.get("messages", [])
            cache_tools = cache_req.get("tools", "")
            cache_str = normalize_messages(cache_msgs) + f"\ntools:{cache_tools}"

            # Use difflib for quick ratio
            score = difflib.SequenceMatcher(None, req_str, cache_str).ratio()
            if score > best_score:
                best_score = score
                best_response = cache.get("response")
        return best_response, best_score

    def _setup_routes(self):
        @self.app.get("/health")
        def health_check():  # pyright: ignore[reportUnusedFunction]
            return {"status": "ok"}

        @self.app.post("/v1/chat/completions")
        def chat_completions(request: Dict[str, Any]):  # pyright: ignore[reportUnusedFunction]
            if USE_OPENAI:
                assert OPENAI_BASE_URL is not None and OPENAI_API_KEY is not None
                # Call Real OpenAI API to get prompt cache
                response = requests.post(
                    OPENAI_BASE_URL.rstrip("/") + "/chat/completions",
                    json=request,
                    headers={"Authorization": f"Bearer {OPENAI_API_KEY}"},
                )
                if response.status_code != 200:
                    raise ValueError(f"Failed to call OpenAI API: {response.status_code} {response.text}")
                response_dict = response.json()
                with open(self._prompt_cache_path(), "a") as f:
                    f.write(json.dumps({"request": request, "response": response_dict}) + "\n")
                return response_dict

            # Try to find the best match in prompt caches
            cached_response, score = self._find_best_cache_match(request)
            if cached_response and score > 0.8:
                time.sleep(0.1)  # Simulate network delay
                # Return the cached response directly
                cached_response["prompt_token_ids"] = [1, 2, 3]
                cached_response["response_token_ids"] = [[4, 5, 6]]
                return cached_response
            raise ValueError("No suitable cached response found. Please ensure the prompt caches are populated.")

    async def __aenter__(self):
        # Start the server manually
        await self._server_launcher.start()
        return OpenAISettings(
            base_url=f"{self._server_launcher.access_endpoint}/v1",
            api_key="dummy",
            model=OPENAI_MODEL,
        )

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        await self._server_launcher.stop()


async def agent_pure_openai(settings: OpenAISettings, tracer: Tracer) -> None:
    """A simple agent using the `openai` library."""
    client = OpenAI(base_url=settings.base_url, api_key=settings.api_key)
    response = client.chat.completions.create(
        model=settings.model, messages=[{"role": "user", "content": "What is the capital of France?"}]
    )
    assert "Paris" in response.choices[0].message.content  # type: ignore


async def agent_litellm(settings: OpenAISettings, tracer: Tracer) -> None:
    """Agent using `litellm` to call the mock server."""
    response = litellm.completion(  # type: ignore
        model="openai/" + settings.model,
        messages=[{"role": "user", "content": "What is 2 + 2?"}],
        base_url=settings.base_url,
        api_key=settings.api_key,
    )
    assert "4" in response.choices[0].message.content  # type: ignore


async def agent_langchain(settings: OpenAISettings, tracer: Tracer) -> None:
    """A simple LangChain agent."""
    llm = ChatOpenAI(model=settings.model, openai_api_base=settings.base_url, openai_api_key=settings.api_key)  # type: ignore
    prompt = ChatPromptTemplate.from_messages([("human", "{input}")])  # type: ignore
    chain = prompt | llm | StrOutputParser()  # type: ignore
    result = chain.invoke({"input": "What is the capital of France?"})  # type: ignore
    assert "Paris" in result


async def agent_langchain_tooluse(settings: OpenAISettings, tracer: Tracer) -> None:
    """A LangChain agent that uses a calculator tool."""

    @tool
    def multiply(a_and_b: str) -> int:
        """A simple calculator tool that multiplies two integers."""
        a, b = re.search(r"(\d+).*?(\d+)", a_and_b).groups()  # type: ignore
        return int(a) * int(b)

    llm = ChatOpenAI(
        model=settings.model,
        temperature=0,
        openai_api_base=settings.base_url,  # type: ignore
        openai_api_key=settings.api_key,  # type: ignore
        disable_streaming=True,
    )
    tools = [multiply]
    agent = create_agent(  # type: ignore
        model=llm,
        tools=tools,
        system_prompt="You are a helpful assistant. Use the multiply tool to answer math questions.",
    )
    langchain_callback_handler = tracer.get_langchain_handler()
    result = agent.invoke(  # type: ignore
        {"messages": [{"role": "user", "content": "what is 42 * 12"}]},
        {"callbacks": [langchain_callback_handler]} if langchain_callback_handler else None,
    )
    assert "504" in result["messages"][-1].content


async def agent_langgraph(settings: OpenAISettings, tracer: Tracer) -> None:
    """An agent built with LangGraph for stateful, cyclical workflows."""
    llm = init_chat_model(
        "openai:" + settings.model, openai_api_base=settings.base_url, openai_api_key=settings.api_key
    )
    db = SQLDatabase.from_uri("sqlite:///" + os.path.join(os.path.dirname(__file__), "../assets/chinook.db"))  # type: ignore
    toolkit = SQLDatabaseToolkit(db=db, llm=llm)
    tools = toolkit.get_tools()

    def get_tool(name: str) -> Any:
        return next(t for t in tools if t.name == name)

    get_schema_tool = next(tool for tool in tools if tool.name == "sql_db_schema")
    run_query_tool = next(tool for tool in tools if tool.name == "sql_db_query")

    def get_schema(state: MessagesState) -> MessagesState:
        """Execute the get_schema tool based on the last message's tool calls."""
        last_message = state["messages"][-1]
        tool_messages: List[Any] = []
        for tool_call in getattr(last_message, "tool_calls", []):
            result = get_schema_tool.invoke(tool_call)  # type: ignore
            tool_messages.append(result)
        return {"messages": tool_messages}

    def run_query(state: MessagesState) -> MessagesState:
        """Execute the run_query tool based on the last message's tool calls."""
        last_message = state["messages"][-1]
        tool_messages: List[Any] = []
        for tool_call in getattr(last_message, "tool_calls", []):
            result = run_query_tool.invoke(tool_call)  # type: ignore
            tool_messages.append(result)
        return {"messages": tool_messages}

    def list_tables(state: MessagesState) -> MessagesState:
        tool_call: Dict[str, Any] = {
            "name": "sql_db_list_tables",
            "args": {},
            "id": "abc123",
            "type": "tool_call",
        }
        tool_call_message = AIMessage(content="", tool_calls=[tool_call])

        list_tables_tool = next(tool for tool in tools if tool.name == "sql_db_list_tables")
        tool_message = list_tables_tool.invoke(tool_call)  # type: ignore
        response = AIMessage(f"Available tables: {tool_message.content}")

        return {"messages": [tool_call_message, tool_message, response]}

    def call_get_schema(state: MessagesState) -> MessagesState:
        # Note that LangChain enforces that all models accept `tool_choice="any"`
        # as well as `tool_choice=<string name of tool>`.
        llm_with_tools = llm.bind_tools([get_schema_tool], tool_choice="any")  # type: ignore
        response = llm_with_tools.invoke(state["messages"])

        return {"messages": [response]}

    # Generate SQL Query
    def generate_query(state: MessagesState) -> MessagesState:
        prompt = f"""
    You are an agent for SQL ({db.dialect}).
    Write a query to answer the user. Limit results to 5. Do not modify data.
    """
        msg = {"role": "system", "content": prompt}
        llm_with_tools = llm.bind_tools([get_tool("sql_db_query")])  # type: ignore
        resp = llm_with_tools.invoke([msg] + state["messages"])
        return {"messages": [resp]}

    # Double-check SQL Query
    def check_query(state: MessagesState) -> MessagesState:
        prompt = f"""
    You are a SQL expert. Double check the following {db.dialect} query for mistakes.
    Rewrite if needed. Otherwise, output as is.
    """
        user_query = state["messages"][-1].tool_calls[0]["args"]["query"]  # type: ignore
        llm_with_tools = llm.bind_tools([get_tool("sql_db_query")], tool_choice="any")  # type: ignore
        resp = llm_with_tools.invoke([{"role": "system", "content": prompt}, {"role": "user", "content": user_query}])
        resp.id = state["messages"][-1].id  # keep consistent ID for trace
        return {"messages": [resp]}

    # Conditional edge: if query tool-call exists, check query, else done
    def should_continue(state: MessagesState) -> Literal[END, "check_query"]:  # type: ignore
        last = state["messages"][-1]
        return "check_query" if getattr(last, "tool_calls", None) else END

    # 5. Build the agent graph
    builder = StateGraph(MessagesState)
    builder.add_node(list_tables)  # type: ignore
    builder.add_node(call_get_schema)  # type: ignore
    builder.add_node(get_schema)  # type: ignore
    builder.add_node(generate_query)  # type: ignore
    builder.add_node(check_query)  # type: ignore
    builder.add_node(run_query)  # type: ignore
    builder.add_edge(START, "list_tables")
    builder.add_edge("list_tables", "call_get_schema")
    builder.add_edge("call_get_schema", "get_schema")
    builder.add_edge("get_schema", "generate_query")
    builder.add_conditional_edges(
        "generate_query",
        should_continue,  # type: ignore
    )
    builder.add_edge("check_query", "run_query")
    builder.add_edge("run_query", "generate_query")
    agent = builder.compile()  # type: ignore

    # 6. Run a sample question
    question = "Which sales agent made the most in sales in 2009?"
    langchain_callback_handler = tracer.get_langchain_handler()
    result = agent.invoke(  # type: ignore
        {"messages": [{"role": "user", "content": question}]},  # type: ignore
        {"callbacks": [langchain_callback_handler]} if langchain_callback_handler else None,
    )
    assert "Steve Johnson" in result["messages"][-1].content
    assert len(result["messages"]) > 5


async def agent_autogen_multiagent(settings: OpenAISettings, tracer: Tracer) -> None:
    """A multi-agent conversation with AutoGen."""

    model_client = OpenAIChatCompletionClient(
        model=settings.model,
        base_url=settings.base_url,
        api_key=settings.api_key,
    )

    primary_agent = AssistantAgent(
        "primary",
        model_client=model_client,
        system_message="You are a helpful AI assistant.",
    )

    critic_agent = AssistantAgent(
        "critic",
        model_client=model_client,
        system_message="Provide constructive feedback. Respond with 'APPROVE' to when your feedbacks are addressed.",
    )

    text_termination = TextMentionTermination("APPROVE")

    # Create a team with the primary and critic agents.
    team = RoundRobinGroupChat([primary_agent, critic_agent], termination_condition=text_termination, max_turns=4)

    result = await team.run(task="Write a short poem about the fall season.")
    sources = [msg.source for msg in result.messages]
    assert "primary" in sources
    assert "critic" in sources


async def agent_autogen_mcp(settings: OpenAISettings, tracer: Tracer) -> None:
    """An AutoGen agent using the Multi-agent Conversation Platform (MCP) and a tool (fixed usage)."""
    calculator_mcp_server = StdioServerParams(command="uvx", args=["mcp-server-calculator"])

    async with McpWorkbench(calculator_mcp_server) as workbench:
        model_client = OpenAIChatCompletionClient(
            model=settings.model,
            base_url=settings.base_url,
            api_key=settings.api_key,
        )
        agent = AssistantAgent(name="calc_agent", model_client=model_client, workbench=workbench)
        # Simulate a tool-use message
        response = await agent.run(task="What is 42 * 12?")
        assert "504" in response.messages[-1].content  # type: ignore


def openai_agents_sdk_run_config(settings: OpenAISettings) -> RunConfig:
    return RunConfig(
        model=settings.model,
        model_provider=OpenAIProvider(api_key=settings.api_key, base_url=settings.base_url, use_responses=False),
    )


async def openai_agents_sdk_eval_hook_and_guardrail(settings: OpenAISettings, tracer: Tracer) -> None:
    class HomeworkOutput(BaseModel):
        is_homework: bool
        reasoning: str

    class EvalHook(AgentHooks):
        async def on_end(self, context: Any, agent: Agent, output: Any):
            # Custom reward logic: reward if the answer contains 'no'
            final_reward = 1.0 if output and "no" in str(output).lower() else 0.0
            emit_reward(final_reward)
            final_rewards.append(final_reward)

    guardrail_agent = Agent(
        name="Guardrail check",
        instructions="Check if the user is asking about homework.",
        output_type=HomeworkOutput,
        hooks=EvalHook(),
    )

    async def homework_guardrail(ctx: Any, agent: Agent, input_data: Any):
        result = await Runner.run(
            guardrail_agent, input_data, context=ctx.context, run_config=openai_agents_sdk_run_config(settings)
        )
        final_output = result.final_output_as(HomeworkOutput)
        return GuardrailFunctionOutput(
            output_info=final_output,
            tripwire_triggered=not final_output.is_homework,
        )

    main_agent = Agent(
        name="Main Agent",
        instructions="Answer questions. If it's about homework, say so.",
        input_guardrails=[InputGuardrail(guardrail_function=homework_guardrail)],
        hooks=EvalHook(),
    )
    final_rewards: List[float] = []
    result = await Runner.run(
        main_agent,
        "The teacher asks to answer whether hummingbirds are mammals.",
        run_config=openai_agents_sdk_run_config(settings),
    )
    # Should trigger the guardrail and reward should be 1.0
    assert any(
        final_reward == 1.0 for final_reward in final_rewards
    ), f"Expected reward to have 1.0, got {final_rewards}"
    assert hasattr(result, "final_output")


async def openai_agents_sdk_mcp_tool_use(settings: OpenAISettings, tracer: Tracer) -> None:
    async with MCPServerStdio(params={"command": "uvx", "args": ["mcp-server-calculator"]}) as mcp_server:
        agent = Agent(
            name="MCP Tool Agent",
            instructions="Use the tools to answer the question.",
            mcp_servers=[mcp_server],
        )
        # The actual tool list and invocation will depend on the MCP server implementation
        # Here we just check that the agent can run with the MCP server attached
        result = await Runner.run(agent, "What is 43*57?", run_config=openai_agents_sdk_run_config(settings))
        assert hasattr(result, "final_output")
        assert "2451" in result.final_output_as(str)


async def openai_agents_sdk_handoff_tool_output_type_and_reward(settings: OpenAISettings, tracer: Tracer) -> None:
    class MathOutput(BaseModel):
        answer: int

    @function_tool
    def add(a: int, b: int) -> int:
        return a + b

    class RewardHook(AgentHooks):
        @operation(name=AGL_REWARD)
        async def on_end(self, context: Any, agent: Agent, output: Any):
            nonlocal final_reward
            # Use another agent to check the answer and compute reward
            checker = Agent(
                name="Checker",
                instructions="Return 1.0 if the answer is 8, else 0.0.",
                output_type=float,
            )
            result = await Runner.run(
                checker, str(getattr(output, "answer", "")), run_config=openai_agents_sdk_run_config(settings)
            )
            final_reward = float(result.final_output)
            emit_reward(final_reward)

    math_agent = Agent(
        name="MathAgent",
        instructions="Add two numbers.",
        tools=[add],
        output_type=MathOutput,
        hooks=RewardHook(),
    )

    history_agent = Agent(
        name="HistoryAgent",
        instructions="Answer history questions.",
        output_type=str,
    )

    triage_agent = Agent(
        name="TriageAgent",
        instructions="If the question is about math, handoff to MathAgent. Otherwise, handoff to HistoryAgent.",
        handoffs=[math_agent, history_agent],
    )

    # Math handoff
    final_reward = None
    result = await Runner.run(triage_agent, "What is 3+5?", run_config=openai_agents_sdk_run_config(settings))
    assert isinstance(result.final_output, MathOutput)
    assert result.final_output.answer == 8
    # The reward should be 1.0 (computed by the checker agent)
    assert final_reward == 1.0
    # History handoff
    result2 = await Runner.run(
        triage_agent, "Who was the first president of the US?", run_config=openai_agents_sdk_run_config(settings)
    )
    assert isinstance(result2.final_output, str)
    assert "president" in result2.final_output.lower()


AgentName = Literal[
    "agent_pure_openai",
    "agent_litellm",
    "agent_langchain",
    "agent_langchain_tooluse",
    "agent_langgraph",
    "agent_autogen_multiagent",
    "agent_autogen_mcp",
    "openai_agents_sdk_eval_hook_and_guardrail",
    "openai_agents_sdk_mcp_tool_use",
    "openai_agents_sdk_handoff_tool_output_type_and_reward",
]


AGENTOPS_EXPECTED_TREES: Mapping[AgentName, List[Tuple[Union[str, re.Pattern[str]], Union[str, re.Pattern[str]]]]] = {
    "agent_pure_openai": [("openai.chat.completion", "openai.chat.completion")],
    "agent_litellm": [("openai.chat.completion", "openai.chat.completion")],
    "agent_langchain": [("openai.chat.completion", "openai.chat.completion")],
    "agent_langchain_tooluse": [
        (re.compile(r"(chat_model\.llm)|(model)"), "openai.chat.completion"),
        (re.compile(r"(chat_model\.llm)|(model)"), "openai.chat.completion"),
    ],
    "agent_langgraph": [
        ("call_get_schema", "openai.chat.completion"),
        ("generate_query", "openai.chat.completion"),
        ("check_query", "openai.chat.completion"),
        ("run_query", re.compile(r"(tool.tool)|(sql_db_query)")),
    ],
    "agent_autogen_multiagent": [
        ("primary", "openai.chat.completion"),
        ("critic", "openai.chat.completion"),
    ],
    "agent_autogen_mcp": [
        ("calc_agent", "openai.chat.completion"),
    ],
    "openai_agents_sdk_eval_hook_and_guardrail": [
        (re.compile(r"(homework_guardrail)|(Guardrail check)"), "openai.chat.completion"),
        ("Main Agent", "openai.chat.completion"),
        ("Main Agent", "agentlightning.annotation"),
    ],
    "openai_agents_sdk_mcp_tool_use": [
        ("MCP Tool Agent", "openai.chat.completion"),
        ("MCP Tool Agent", "calculate"),
        ("MCP Tool Agent", "openai.chat.completion"),
    ],
    "openai_agents_sdk_handoff_tool_output_type_and_reward": [
        ("TriageAgent", "openai.chat.completion"),
        ("MathAgent", "openai.chat.completion"),
        ("MathAgent", "openai.chat.completion"),
        ("MathAgent", "agentlightning.annotation"),
        ("HistoryAgent", "openai.chat.completion"),
    ],
}

AGENTOPS_EXPECTED_TRIPLETS_NUMBER: Mapping[AgentName, int] = {
    "agent_pure_openai": 1,
    "agent_litellm": 1,
    "agent_langchain": 1,
    "agent_langchain_tooluse": 2,
    "agent_langgraph": 4,
    "agent_autogen_multiagent": 4,
    "agent_autogen_mcp": 1,
    "openai_agents_sdk_eval_hook_and_guardrail": 2,
    "openai_agents_sdk_mcp_tool_use": 2,
    "openai_agents_sdk_handoff_tool_output_type_and_reward": 5,
}

AGENTOPS_EXPECTED_REWARDS: Mapping[AgentName, Union[List[float | None], Tuple[List[float | None], ...]]] = {
    "openai_agents_sdk_eval_hook_and_guardrail": ([1.0, None], [None, 1.0]),
    "openai_agents_sdk_handoff_tool_output_type_and_reward": [None, None, 1.0, None, None],
}


AGENT_FUNCTIONS: Mapping[AgentName, Callable[[OpenAISettings, Tracer], Awaitable[Any]]] = {
    "agent_pure_openai": agent_pure_openai,
    "agent_litellm": agent_litellm,
    "agent_langchain": agent_langchain,
    "agent_langchain_tooluse": agent_langchain_tooluse,
    "agent_langgraph": agent_langgraph,
    "agent_autogen_multiagent": agent_autogen_multiagent,
    "agent_autogen_mcp": agent_autogen_mcp,
    "openai_agents_sdk_eval_hook_and_guardrail": openai_agents_sdk_eval_hook_and_guardrail,
    "openai_agents_sdk_mcp_tool_use": openai_agents_sdk_mcp_tool_use,
    "openai_agents_sdk_handoff_tool_output_type_and_reward": openai_agents_sdk_handoff_tool_output_type_and_reward,
}


def assert_expected_pairs_in_tree(
    root_tuple: Tuple[str, List[Any]],
    expected_pairs: List[Tuple[Union[str, re.Pattern[str]], Union[str, re.Pattern[str]]]],
) -> None:
    """
    Assert that every (ancestor_name, child_name) pair in `expected_pairs`
    occurs somewhere in the tree produced by TraceTree.names_tuple().
    """

    expected_patterns = [
        (re.compile(re.escape(x)) if isinstance(x, str) else x, re.compile(re.escape(y)) if isinstance(y, str) else y)
        for x, y in expected_pairs
    ]

    # Collect every node's full path from root → node
    paths: list[tuple[str, ...]] = []  # e.g. [["root", "A", "B"], ...]

    def _collect(node_tuple: tuple[str, Any], prefix: list[str]):
        name, children = node_tuple
        cur_path = prefix + [name]
        paths.append(tuple(cur_path))
        for child in children:
            _collect(child, cur_path)

    _collect(root_tuple, [])

    # Greedy—but safe—matching of each expected pair
    paths_used: list[bool] = [False] * len(paths)

    for anc_name, child_name in expected_patterns:
        matched = False
        for i, (p, used) in enumerate(zip(paths, paths_used, strict=True)):
            if child_name.search(p[-1]) is None or used:
                continue
            if any(anc_name.search(pv) is not None for pv in p):  # ancestor appears anywhere above (including itself)
                paths_used[i] = True
                matched = True
                break
        if not matched:
            err_msg = (
                f"Expected ancestor/child pair ({anc_name!r}, {child_name!r}) "
                "not found or child already matched.\n"
                f"Root paths: {pprint.pformat(paths)}\n"
                f"Expected pairs: {expected_pairs}"
            )
            print(err_msg)
            raise AssertionError(err_msg)


@pytest.fixture(
    params=[
        "agent_pure_openai",
        "agent_litellm",
        pytest.param("agent_langchain", marks=pytest.mark.langchain),
        pytest.param("agent_langchain_tooluse", marks=pytest.mark.langchain),
        pytest.param("agent_langgraph", marks=pytest.mark.langchain),
        "agent_autogen_multiagent",
        "agent_autogen_mcp",
        "openai_agents_sdk_eval_hook_and_guardrail",
        "openai_agents_sdk_mcp_tool_use",
        "openai_agents_sdk_handoff_tool_output_type_and_reward",
    ]
)
def agent_function(
    request: pytest.FixtureRequest,
) -> Tuple[AgentName, Callable[[OpenAISettings, Tracer], Awaitable[Any]]]:
    return request.param, AGENT_FUNCTIONS[request.param]


@pytest.mark.agentops
@pytest.mark.asyncio
async def test_tracer_integration_agentops(
    agent_function: Tuple[AgentName, Callable[[OpenAISettings, Tracer], Awaitable[Any]]],
):
    name, func = agent_function
    async with MockOpenAICompatibleServer() as settings:
        tracer = AgentOpsTracer()
        await _run_tracer_with_agent(settings, tracer, name, func)


@pytest.mark.weave
@pytest.mark.asyncio
async def test_tracer_integration_weave(
    agent_function: Tuple[AgentName, Callable[[OpenAISettings, Tracer], Awaitable[Any]]],
    monkeypatch: pytest.MonkeyPatch,
):
    from agentlightning.tracer.weave import WeaveTracer

    name, func = agent_function
    skip_assert = "autogen" in name

    if name == "openai_agents_sdk_handoff_tool_output_type_and_reward":
        monkeypatch.setitem(AGENTOPS_EXPECTED_TRIPLETS_NUMBER, name, 6)
        monkeypatch.setitem(AGENTOPS_EXPECTED_REWARDS, name, [None, None, None, 1.0, None, None])

    async with MockOpenAICompatibleServer() as settings:
        tracer = WeaveTracer()
        await _run_tracer_with_agent(settings, tracer, name, func, skip_assert)


async def _run_tracer_with_agent(
    settings: OpenAISettings,
    tracer: Tracer,
    agent_name: AgentName,
    agent_func: Callable[[OpenAISettings, Tracer], Awaitable[Any]],
    skip_assert: bool = False,
):
    with tracer.lifespan():
        async with tracer.trace_context(name=f"test_integration_{agent_name}"):
            await agent_func(settings, tracer)

        last_trace_normalized = [
            Span.from_opentelemetry(span, "dummy", "dummy", 0) if isinstance(span, ReadableSpan) else span
            for span in tracer.get_last_trace()
        ]
        assert len(last_trace_normalized) > 0

        for span in last_trace_normalized:
            print(">>> rollout_id =", span.rollout_id)
            print("... attempt_id =", span.attempt_id)
            print("... sequence_id =", span.sequence_id)
            print("... trace_id =", span.trace_id)
            print("... span_id =", span.span_id)
            print("... parent_id =", span.parent_id)
            print("... name =", span.name)
            print("... status =", span.status)
            print("... attributes =", span.attributes.keys())

        debug_dir = os.path.join(os.path.dirname(__file__), "debug", tracer.__class__.__name__)
        os.makedirs(debug_dir, exist_ok=True)
        with open(os.path.join(debug_dir, f"{agent_name}_raw.json"), "w") as f:
            json.dump([span.model_dump() for span in last_trace_normalized], f, indent=2)

        tree = TraceTree.from_spans(last_trace_normalized)

        if shutil.which("dot"):
            # Visualize the trace tree for debug
            tree.visualize(filename=os.path.join(debug_dir, agent_name))
        else:
            warnings.warn("dot is not installed. Skipping trace tree visualization.")

        tree.repair_hierarchy()
        triplets = TracerTraceToTriplet().adapt(last_trace_normalized)

        with open(os.path.join(debug_dir, f"{agent_name}_triplets.json"), "w") as f:
            json.dump([triplet.model_dump() for triplet in triplets], f, indent=2)

        if skip_assert:
            return

        assert_expected_pairs_in_tree(tree.names_tuple(), AGENTOPS_EXPECTED_TREES[agent_name])
        if len(triplets) != AGENTOPS_EXPECTED_TRIPLETS_NUMBER[agent_name]:
            triplet_assert_err_msg = f"Expected {AGENTOPS_EXPECTED_TRIPLETS_NUMBER[agent_name]} triplets, but got:\n{pprint.pformat(triplets)}"
            print(triplet_assert_err_msg)
            raise AssertionError(triplet_assert_err_msg)
        if agent_name in AGENTOPS_EXPECTED_REWARDS:
            expected_reward = AGENTOPS_EXPECTED_REWARDS[agent_name]
            if isinstance(expected_reward, tuple):
                # If the expected rewards are a tuple, make sure at least one of them matches
                if not any([r.reward in expected for r in triplets for expected in expected_reward]):
                    err_msg = f"Expected rewards {expected_reward}, but got: {pprint.pformat(triplets)}"
                    print(err_msg)
                    raise AssertionError(err_msg)
            else:
                if [r.reward for r in triplets] != expected_reward:
                    err_msg = f"Expected rewards {expected_reward}, but got: {pprint.pformat(triplets)}"
                    print(err_msg)
                    raise AssertionError(err_msg)
