# Copyright (c) Microsoft. All rights reserved.

import asyncio
import logging
import multiprocessing
import random
from typing import Any, List, cast

import litellm
import openai
import opentelemetry.trace as trace_api
import pytest
from agentops.sdk.core import BatchSpanProcessor
from litellm.llms.custom_llm import CustomLLM
from litellm.types.utils import ModelResponse
from litellm.utils import custom_llm_setup
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import SimpleSpanProcessor

from agentlightning.llm_proxy import LightningSpanExporter, LLMProxy
from agentlightning.store import LightningStoreServer
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.store.threading import LightningStoreThreaded
from agentlightning.types import Span
from agentlightning.utils.server_launcher import PythonServerLauncherArgs

from ..common.network import get_free_port
from ..common.tracer import clear_tracer_provider

pytestmark = pytest.mark.llmproxy


class _FakeSpanContext:
    def __init__(self, span_id: int):
        self.span_id = span_id


class _FakeParent:
    def __init__(self, span_id: int):
        self.span_id = span_id


class _FakeReadableSpan:
    def __init__(self, span_id: int, parent_id: int | None, attrs: dict[str, str]):
        self._ctx = _FakeSpanContext(span_id)
        self.parent = None if parent_id is None else _FakeParent(parent_id)
        self.attributes = attrs
        self.name = f"span-{span_id}"

    def get_span_context(self):
        return self._ctx


class _FakeStore(InMemoryLightningStore):
    def __init__(self):
        super().__init__()
        self.added: list[tuple[str, str, int, _FakeReadableSpan]] = []

    async def add_otel_span(
        self, rollout_id: str, attempt_id: str, readable_span: ReadableSpan, sequence_id: int | None = None
    ) -> Span:
        assert isinstance(sequence_id, int)
        assert isinstance(readable_span, _FakeReadableSpan)
        self.added.append((rollout_id, attempt_id, sequence_id, readable_span))
        return cast(Span, None)


@pytest.mark.asyncio
async def test_exporter_tree_and_flush_headers_parsing():
    store = _FakeStore()
    exporter = LightningSpanExporter(store)

    # Build a root and two children. Headers distributed across spans.
    root = _FakeReadableSpan(1, None, {"metadata.requester_custom_headers": "{'x-rollout-id': 'r1'}"})
    child_a = _FakeReadableSpan(2, 1, {"metadata.requester_custom_headers": "{'x-attempt-id': 'a9'}"})
    child_b = _FakeReadableSpan(3, 1, {"metadata.requester_custom_headers": "{'x-sequence-id': '7'}"})

    # Push to buffer and export
    res = exporter.export(cast(List[ReadableSpan], [root, child_a, child_b]))
    assert res.name == "SUCCESS"

    # Give event loop a moment to run exporter coroutine
    await asyncio.sleep(0.1)

    # Should have flushed all three with merged headers
    assert len(store.added) == 3
    for rid, aid, sid, sp in store.added:
        assert rid == "r1"
        assert aid == "a9"
        assert sid == 7
        assert isinstance(sp, _FakeReadableSpan)

    exporter.shutdown()


def test_exporter_helpers():
    store = _FakeStore()
    exporter = LightningSpanExporter(store)

    # Tree: 10(root) -> 11(child) -> 12(grandchild); 20(root2)
    s10 = _FakeReadableSpan(10, None, {})
    s11 = _FakeReadableSpan(11, 10, {})
    s12 = _FakeReadableSpan(12, 11, {})
    s20 = _FakeReadableSpan(20, None, {})

    for _ in range(10):
        exporter._buffer = cast(List[ReadableSpan], [s10, s11, s12, s20])  # pyright: ignore[reportPrivateUsage]
        random.shuffle(exporter._buffer)  # pyright: ignore[reportPrivateUsage]

        roots = list(exporter._get_root_span_ids())  # pyright: ignore[reportPrivateUsage]
        assert set(roots) == {10, 20}

        subtree_ids = set(exporter._get_subtrees(10))  # pyright: ignore[reportPrivateUsage]
        assert subtree_ids == {10, 11, 12}

        popped = exporter._pop_subtrees(10)  # pyright: ignore[reportPrivateUsage]
        assert {sp.get_span_context().span_id for sp in popped} == {  # pyright: ignore[reportOptionalMemberAccess]
            10,
            11,
            12,
        }
        # Remaining buffer has only s20
        assert {
            sp.get_span_context().span_id  # pyright: ignore[reportOptionalMemberAccess]
            for sp in exporter._buffer  # pyright: ignore[reportPrivateUsage]
        } == {20}

    exporter.shutdown()

    # TODO: add more complex tests for the exporter helper


@pytest.mark.asyncio
async def test_update_model_list():
    store = InMemoryLightningStore()
    proxy = LLMProxy(
        model_list=[
            {
                "model_name": "gpt-4o-arbitrary",
                "litellm_params": {
                    "model": "openai/gpt-4o",
                },
            }
        ],
        launch_mode="asyncio",
        port=get_free_port(),
        store=store,
    )
    await proxy.start()
    assert proxy.is_running()
    assert proxy.model_list == [
        {
            "model_name": "gpt-4o-arbitrary",
            "litellm_params": {
                "model": "openai/gpt-4o",
            },
        }
    ]
    proxy.update_model_list(
        [
            {
                "model_name": "gpt-4o-arbitrary",
                "litellm_params": {
                    "model": "openai/gpt-4o-mini",
                },
            }
        ]
    )
    assert proxy.model_list == [
        {
            "model_name": "gpt-4o-arbitrary",
            "litellm_params": {
                "model": "openai/gpt-4o-mini",
            },
        }
    ]
    assert proxy.is_running()
    await proxy.stop()


@pytest.mark.asyncio
async def test_restart_resets_litellm_logging_worker() -> None:
    """LLMProxy.start() should recreate LiteLLM's logging worker on each run."""

    try:
        from litellm.litellm_core_utils import logging_worker as litellm_logging_worker
    except ImportError:
        pytest.skip("LiteLLM logging worker not available")

    store = InMemoryLightningStore()
    proxy = LLMProxy(
        model_list=[
            {
                "model_name": "dummy-model",
                # The backend is never invoked; only the proxy lifecycle matters here.
                "litellm_params": {"model": "gpt-3.5-turbo"},
            }
        ],
        store=store,
        launcher_args=PythonServerLauncherArgs(
            port=get_free_port(),
            launch_mode="asyncio",
            healthcheck_url="/health",
            startup_timeout=10.0,
            process_join_timeout=10.0,
        ),
    )

    try:
        await proxy.start()
        first_worker = litellm_logging_worker.GLOBAL_LOGGING_WORKER
        await proxy.stop()

        await proxy.start()
        second_worker = litellm_logging_worker.GLOBAL_LOGGING_WORKER
    finally:
        await proxy.stop()

    assert first_worker is not second_worker, "LiteLLM logging worker should be refreshed after restart"


class TestLLM(CustomLLM):
    def __init__(self, content: str) -> None:
        super().__init__()
        self.content = content

    def completion(self, *args: Any, **kwargs: Any) -> ModelResponse:
        return litellm.completion(  # type: ignore
            model="gpt-4o",
            messages=[{"role": "user", "content": "Hello world"}],
            mock_response=self.content,
        )

    async def acompletion(self, *args: Any, **kwargs: Any) -> ModelResponse:
        return litellm.completion(  # type: ignore
            model="gpt-4o",
            messages=[{"role": "user", "content": "Hello world"}],
            mock_response=self.content,
        )


@pytest.mark.asyncio
async def test_custom_llm_restarted_multiple_times(caplog: pytest.LogCaptureFixture) -> None:
    clear_tracer_provider()

    restart_times: int = 30

    store = LightningStoreThreaded(InMemoryLightningStore())
    caplog.set_level(logging.WARNING)

    port = get_free_port()
    try:
        llm_proxy = LLMProxy(
            model_list=[
                {
                    "model_name": "gpt-4o-arbitrary",
                    "litellm_params": {
                        # NOTE: The model after "/" cannot be an openai model like gpt-4o
                        # This might be a bug with litellm
                        "model": "test-llm/any-llm",
                    },
                }
            ],
            launcher_args=PythonServerLauncherArgs(
                launch_mode="thread",
                healthcheck_url="/health",
                port=port,
            ),
            store=store,
        )
        for restart_idx in range(restart_times):
            llm_instance = TestLLM(f"Hi! {restart_idx}")
            litellm.custom_provider_map = [{"provider": "test-llm", "custom_handler": llm_instance}]
            custom_llm_setup()
            await llm_proxy.restart()
            assert llm_proxy.is_running()

            openai_client = openai.AsyncOpenAI(
                base_url=llm_proxy.server_launcher.access_endpoint,
                api_key="token-abc123",
                timeout=5,
                max_retries=0,
            )
            response = await openai_client.chat.completions.create(
                model="gpt-4o-arbitrary",
                messages=[{"role": "user", "content": "Hello world"}],
                stream=False,
            )
            assert response.choices[0].message.content == f"Hi! {restart_idx}"

            error_logs = [record.message for record in caplog.records if record.levelno >= logging.ERROR]
            assert not error_logs, f"Found error logs: {error_logs}"
            assert not any("Cannot add callback" in record.message for record in caplog.records)

        await llm_proxy.stop()
    finally:
        litellm.custom_provider_map = []
        custom_llm_setup()


async def llm_proxy_span_exporter_loop(otlp_enabled: bool = False):
    store = LightningStoreThreaded(InMemoryLightningStore())

    if otlp_enabled:
        store = LightningStoreServer(store, "127.0.0.1", get_free_port())
        await store.start()

    llm_instance = TestLLM(f"Hi! I'm a test LLM")
    litellm.custom_provider_map = [{"provider": "test-llm", "custom_handler": llm_instance}]
    custom_llm_setup()
    proxy = LLMProxy(
        launcher_args=PythonServerLauncherArgs(
            launch_mode="thread",
            healthcheck_url="/health",
            port=get_free_port(),
        ),
        store=store,
        model_list=[
            {
                "model_name": "gpt-4o-arbitrary",
                "litellm_params": {
                    "model": "test-llm/any-llm",
                },
            }
        ],
    )
    await proxy.start()

    rollout = await store.start_rollout(None)
    resource = proxy.as_resource(rollout.rollout_id, rollout.attempt.attempt_id)

    client = openai.AsyncOpenAI(
        base_url=resource.endpoint,
        api_key="token-abc123",
        timeout=5,
        max_retries=0,
    )
    response = await client.chat.completions.create(
        model="gpt-4o-arbitrary",
        messages=[{"role": "user", "content": "Hello world"}],
        stream=False,
    )
    assert response.choices[0].message.content == "Hi! I'm a test LLM"

    spans = await store.query_spans(rollout.rollout_id, rollout.attempt.attempt_id)
    assert len(spans) > 0, "Should have captured spans"
    for span in spans:
        assert span.rollout_id == rollout.rollout_id, f"Span {span.name} has incorrect rollout_id"
        assert span.attempt_id == rollout.attempt.attempt_id, f"Span {span.name} has incorrect attempt_id"
        assert span.sequence_id == 1, f"Span {span.name} has incorrect sequence_id"

    tracer_provider = trace_api.get_tracer_provider()

    have_asserted_loop = False
    for span_processor in tracer_provider._active_span_processor._span_processors:  # type: ignore
        if isinstance(span_processor, (SimpleSpanProcessor, BatchSpanProcessor)):
            if isinstance(span_processor.span_exporter, LightningSpanExporter):
                if otlp_enabled:
                    assert span_processor.span_exporter._loop is None  # type: ignore
                else:
                    assert span_processor.span_exporter._loop is not None  # type: ignore
                have_asserted_loop = True
                break
    assert have_asserted_loop, f"LightningSpanExporter should be used with otlp_enabled={otlp_enabled}"

    await proxy.stop()

    if isinstance(store, LightningStoreServer):
        await store.stop()


def llm_proxy_span_exporter_loop_sync(otlp_enabled: bool = False):
    asyncio.run(llm_proxy_span_exporter_loop(otlp_enabled))


@pytest.mark.parametrize("otlp_enabled", [True, False])
def test_llm_proxy_span_exporter_loop(otlp_enabled: bool):
    context = multiprocessing.get_context("spawn")
    process = context.Process(target=llm_proxy_span_exporter_loop_sync, args=(otlp_enabled,))
    process.start()
    process.join(timeout=30.0)
    assert process.exitcode == 0
