# Copyright (c) Microsoft. All rights reserved.

import os
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Dict, Optional, cast

import litellm
import openai
import pytest

from agentlightning.litagent import LitAgent
from agentlightning.llm_proxy import LLMProxy
from agentlightning.reward import emit_reward
from agentlightning.runner import LitAgentRunner
from agentlightning.store.client_server import LightningStoreClient, LightningStoreServer
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.tracer.agentops import AgentOpsTracer
from agentlightning.types import LLM, AttemptedRollout, NamedResources, Rollout

from ..common.network import get_free_port
from ..common.tracer import clear_tracer_provider
from ..common.vllm import VLLM_AVAILABLE, RemoteOpenAIServer


class InitRunnerFunction:

    def __call__(
        self,
        agent: LitAgent[Any],
        *,
        resources: Optional[Dict[str, LLM]] = None,
    ) -> Awaitable[tuple[LitAgentRunner[Any], InMemoryLightningStore]]: ...


@pytest.fixture(
    params=[
        pytest.param("agentops", marks=pytest.mark.agentops),
    ]
)
def init_runner(request: pytest.FixtureRequest) -> InitRunnerFunction:
    async def init_runner_fn(
        agent: LitAgent[Any],
        *,
        resources: Optional[Dict[str, LLM]] = None,
    ) -> tuple[LitAgentRunner[Any], InMemoryLightningStore]:
        store = InMemoryLightningStore()
        llm_resource: NamedResources = resources or {"llm": LLM(endpoint="http://localhost", model="dummy")}  # type: ignore[assignment]
        await store.update_resources("default", llm_resource)

        # This is always AgentOpsTracer for now
        runner = LitAgentRunner[Any](tracer=AgentOpsTracer(), poll_interval=0.01)
        runner.init(agent)
        runner.init_worker(worker_id=0, store=store)
        return runner, store

    return init_runner_fn  # type: ignore


def teardown_runner(runner: LitAgentRunner[Any]) -> None:
    runner.teardown_worker(worker_id=0)
    runner.teardown()


@pytest.fixture(scope="module", autouse=True)
def setup_module():
    # This must execute only once for this module.
    # Once agentops tracer is initialized, it cannot be reset,
    # otherwise it will never be rewired.
    clear_tracer_provider()
    yield


@pytest.mark.asyncio
async def test_runner_integration_basic_rollout(init_runner: InitRunnerFunction) -> None:
    class EchoAgent(LitAgent[str]):
        async def validation_rollout_async(self, task: str, resources: Dict[str, Any], rollout: Any) -> None:
            emit_reward(1.0)

    agent = EchoAgent()
    runner, store = await init_runner(agent)
    try:
        await runner.step("hello integration")
    finally:
        teardown_runner(runner)

    rollouts = await store.query_rollouts()
    assert rollouts and rollouts[0].status == "succeeded"
    attempts = await store.query_attempts(rollouts[0].rollout_id)
    spans = await store.query_spans(rollouts[0].rollout_id, attempts[-1].attempt_id)
    assert any(span.attributes.get("agentlightning.reward.0.value") == 1.0 for span in spans)


@pytest.mark.asyncio
@pytest.mark.openai
async def test_runner_integration_with_openai(init_runner: InitRunnerFunction) -> None:
    class OpenAIAgent(LitAgent[str]):
        async def validation_rollout_async(self, task: str, resources: NamedResources, rollout: Rollout) -> float:
            llm = cast(LLM, resources["llm"])
            client = openai.AsyncOpenAI(base_url=llm.endpoint, api_key=llm.api_key)
            response = await client.chat.completions.create(
                model=llm.model,
                messages=[{"role": "user", "content": task}],
            )
            assert response.choices, "OpenAI response should contain choices"
            return 0.0

    if not (os.getenv("OPENAI_BASE_URL") and os.getenv("OPENAI_API_KEY")):
        raise RuntimeError("OpenAI endpoint or key not configured")

    base_url = os.environ["OPENAI_BASE_URL"]
    api_key = os.environ["OPENAI_API_KEY"]
    model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")

    agent = OpenAIAgent()
    resources = {"llm": LLM(endpoint=base_url, model=model, api_key=api_key)}
    runner, store = await init_runner(agent, resources=resources)
    try:
        await runner.step("Say hello in one word")
    finally:
        teardown_runner(runner)

    rollouts = await store.query_rollouts()
    assert rollouts and rollouts[0].status == "succeeded"


@pytest.mark.asyncio
@pytest.mark.openai
@pytest.mark.skipif(
    not (os.getenv("OPENAI_BASE_URL") and os.getenv("OPENAI_API_KEY")),
    reason="OpenAI endpoint or key not configured",
)
async def test_runner_integration_with_litellm_proxy(init_runner: InitRunnerFunction) -> None:
    class LiteLLMAgent(LitAgent[str]):
        def validation_rollout(self, task: str, resources: NamedResources, rollout: Rollout) -> float:
            llm = cast(LLM, resources["llm"])
            response = litellm.completion(  # type: ignore
                model=llm.model,
                messages=[{"role": "user", "content": task}],
            )
            assert response.get("choices"), "litellm proxy should return choices"  # type: ignore
            return 0.0

    if not (os.getenv("OPENAI_BASE_URL") and os.getenv("OPENAI_API_KEY")):
        raise RuntimeError("OpenAI endpoint or key not configured")

    agent = LiteLLMAgent()
    resources = {"llm": LLM(endpoint="http://dummy", model="openai/gpt-4o-mini")}
    runner, store = await init_runner(agent, resources=resources)
    try:
        await runner.step("Give me a short greeting")
    finally:
        teardown_runner(runner)

    rollouts = await store.query_rollouts()
    assert rollouts and rollouts[0].status == "succeeded"


@pytest.fixture
def server():
    if not VLLM_AVAILABLE:
        pytest.skip("vLLM is not available")
    vllm_port = get_free_port()
    with RemoteOpenAIServer(
        model="Qwen/Qwen2.5-0.5B-Instruct",
        vllm_serve_args=[
            "--gpu-memory-utilization",
            "0.7",
            "--enable-auto-tool-choice",
            "--tool-call-parser",
            "hermes",
            "--port",
            str(vllm_port),
        ],
    ) as server:
        yield server


class LLMProxyWithClearedTracerProvider(LLMProxy):
    """LLMProxy that clears the tracer provider before serving.

    It will be run in a separate process, so the tracer provider initialized there does not
    interfere with the main process's tracer provider.
    """

    @asynccontextmanager
    async def _serve_context(self) -> AsyncGenerator[None, None]:
        # This will be run inside the LLM proxy's own process
        clear_tracer_provider()
        async with super()._serve_context():
            yield


@pytest.mark.asyncio
@pytest.mark.gpu
async def test_runner_integration_with_spawned_litellm_proxy(
    init_runner: InitRunnerFunction, server: RemoteOpenAIServer
) -> None:
    class ProxyAgent(LitAgent[str]):
        async def validation_rollout_async(self, task: str, resources: NamedResources, rollout: Rollout) -> float:
            attempted_rollout = cast(AttemptedRollout, rollout)
            llm_resource = cast(LLM, resources["llm"])
            client = openai.AsyncOpenAI(
                base_url=llm_resource.get_base_url(attempted_rollout.rollout_id, attempted_rollout.attempt.attempt_id),
                api_key="dummy",
            )
            response = await client.chat.completions.create(
                model=llm_resource.model,
                messages=[{"role": "user", "content": task}],
            )
            assert response.choices, "Proxy should return at least one choice"
            return 0.5

    agent = ProxyAgent()
    runner, store = await init_runner(agent)

    server_store = LightningStoreServer(store=store, host="127.0.0.1", port=get_free_port())
    await server_store.start()
    client_store = LightningStoreClient(server_store.endpoint)

    proxy = LLMProxyWithClearedTracerProvider(
        port=get_free_port(),
        model_list=[
            {
                "model_name": "gpt-4o-arbitrary",
                "litellm_params": {
                    "model": "hosted_vllm/" + server.model,
                    "api_base": server.url_for("v1"),
                },
            }
        ],
        store=client_store,
    )

    await proxy.start()

    try:
        await runner.step("Say hello to Agent Lightning", resources={"llm": proxy.as_resource()})

        rollouts = await client_store.query_rollouts()
        assert rollouts and rollouts[0].status == "succeeded"

        spans = await client_store.query_spans(rollouts[0].rollout_id, "latest")
        assert len(spans) > 1
        first_spans = [span for span in spans if span.sequence_id == 1]
        assert len(first_spans) > 1
        assert any("llm.hosted_vllm.choices" in span.attributes for span in first_spans)
        assert any("llm.hosted_vllm.prompt_token_ids" in span.attributes for span in first_spans)
        assert any("gen_ai.prompt.0.content" in span.attributes for span in first_spans)

        second_spans = [span for span in spans if span.sequence_id == 2]
        assert len(second_spans) == 1
        assert second_spans[0].name == "openai.chat.completion"

        last_spans = [span for span in spans if span.sequence_id == max(span.sequence_id for span in spans)]
        assert len(last_spans) == 1
        assert last_spans[0].name == "agentlightning.annotation"
        assert (
            last_spans[0].attributes.get("agentlightning.reward.0.value") == 0.5
        ), f"Expected reward to be 0.5, found {last_spans[0].attributes}"
    finally:
        teardown_runner(runner)
        await proxy.stop()
        await client_store.close()
        await server_store.stop()
