# Copyright (c) Microsoft. All rights reserved.

import asyncio
import logging
import random
import time
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Sequence, Tuple, cast

import pytest
from opentelemetry import trace as trace_api
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
from opentelemetry.trace import SpanContext, TraceFlags, TraceState
from opentelemetry.trace.status import Status, StatusCode

from agentlightning.execution.events import ExecutionEvent, ThreadingEvent
from agentlightning.litagent import LitAgent
from agentlightning.reward import emit_reward, find_final_reward
from agentlightning.runner import LitAgentRunner
from agentlightning.runner.base import Runner
from agentlightning.semconv import AGL_ANNOTATION
from agentlightning.store.base import UNSET, LightningStore, Unset
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.tracer.base import Tracer
from agentlightning.types import LLM, Hook, NamedResources, PromptTemplate, Rollout, Span, SpanCoreFields, Worker


@pytest.fixture(scope="module", autouse=True)
def setup_module():
    trace_api.set_tracer_provider(TracerProvider())
    yield


def create_readable_span(name: str, attributes: Optional[Dict[str, Any]] = None) -> ReadableSpan:
    trace_id = random.getrandbits(128)
    span_id = random.getrandbits(64)
    context = SpanContext(
        trace_id=trace_id,
        span_id=span_id,
        is_remote=False,
        trace_flags=TraceFlags(TraceFlags.SAMPLED),
        trace_state=TraceState(),
    )
    status = Status(status_code=StatusCode.UNSET)
    return ReadableSpan(
        name=name,
        context=context,
        parent=None,
        resource=Resource.create({}),
        attributes=attributes or {},
        events=(),
        links=(),
        status=status,
    )


def create_agent_span(
    rollout_id: str,
    attempt_id: str,
    sequence_id: int,
    name: str,
    attributes: Optional[Dict[str, Any]] = None,
) -> Span:
    readable = create_readable_span(name, attributes)
    return Span.from_opentelemetry(
        readable,
        rollout_id=rollout_id,
        attempt_id=attempt_id,
        sequence_id=sequence_id,
    )


class DummyTracer(Tracer):
    def __init__(self) -> None:
        super().__init__()
        self._last_trace: List[Span] = []
        self._contexts: List[Dict[str, Any]] = []
        self._sequence_id = 0

    def init(self, *args: Any, **kwargs: Any) -> None:
        self._last_trace.clear()

    def teardown(self, *args: Any, **kwargs: Any) -> None:
        self._last_trace.clear()

    def get_last_trace(self) -> List[Span]:
        return list(self._last_trace)

    @asynccontextmanager
    async def trace_context(
        self,
        name: Optional[str] = None,
        *,
        store: Optional[LightningStore] = None,
        rollout_id: Optional[str] = None,
        attempt_id: Optional[str] = None,
    ) -> AsyncGenerator[List[Span], None]:
        previous = self._contexts[-1] if self._contexts else None
        current = {
            "name": name,
            "store": store,
            "rollout_id": rollout_id,
            "attempt_id": attempt_id,
        }
        self._contexts.append(current)
        self._last_trace = []
        try:
            yield self._last_trace
        finally:
            self._contexts.pop()
            if previous is None:
                self._contexts = []

    def record_span(self, name: str, attributes: Optional[Dict[str, Any]] = None) -> ReadableSpan:
        span = create_readable_span(name, attributes)
        rollout_id = "rollout-dummy"
        attempt_id = "attempt-dummy"
        sequence_id = self._sequence_id
        self._sequence_id += 1
        if self._contexts:
            current = self._contexts[-1]
            rollout_id = current["rollout_id"]
            attempt_id = current["attempt_id"]
        self._last_trace.append(Span.from_opentelemetry(span, rollout_id, attempt_id, sequence_id))
        return span


class RecordingStore(InMemoryLightningStore):
    """In-memory store that records worker heartbeat updates for inspection in tests."""

    def __init__(self) -> None:
        super().__init__()
        self.worker_updates: List[Tuple[str, Optional[Dict[str, Any]]]] = []

    async def update_worker(
        self,
        worker_id: str,
        heartbeat_stats: Dict[str, Any] | Unset = UNSET,
    ) -> Worker:
        payload = None if isinstance(heartbeat_stats, Unset) else heartbeat_stats
        self.worker_updates.append((worker_id, payload))
        return await super().update_worker(worker_id, heartbeat_stats=heartbeat_stats)


class HeartbeatAgent(LitAgent[Dict[str, Any]]):
    """Minimal agent used for heartbeat-only runner tests."""

    def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
        return 0.0


async def setup_heartbeat_runner(
    *,
    heartbeat_interval: float = 0.05,
    heartbeat_launch_mode: Literal["asyncio", "thread"] = "thread",
    heartbeat_include_gpu: bool = False,
) -> tuple[LitAgentRunner[Any], RecordingStore]:
    """Create a runner wired to a RecordingStore for heartbeat tests."""

    store = RecordingStore()
    runner = LitAgentRunner[Any](
        tracer=DummyTracer(),
        heartbeat_interval=heartbeat_interval,
        heartbeat_launch_mode=heartbeat_launch_mode,
        heartbeat_include_gpu=heartbeat_include_gpu,
    )
    agent = HeartbeatAgent()
    runner.init(agent)
    runner.init_worker(worker_id=0, store=store)
    return runner, store


async def setup_runner(
    agent: LitAgent[Any],
    *,
    tracer: Optional[DummyTracer] = None,
    max_rollouts: Optional[int] = None,
    poll_interval: float = 0.01,
    hooks: Sequence[Hook] = (),
) -> tuple[LitAgentRunner[Any], InMemoryLightningStore, DummyTracer]:
    tracer = tracer or DummyTracer()
    store = InMemoryLightningStore()
    await store.update_resources("default", {"llm": LLM(endpoint="http://localhost", model="dummy")})

    runner = LitAgentRunner[Any](tracer=tracer, max_rollouts=max_rollouts, poll_interval=poll_interval)
    runner.init(agent=agent, hooks=hooks)
    runner.init_worker(worker_id=0, store=store)
    return runner, store, tracer


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


async def assert_single_attempt_succeeded(store: InMemoryLightningStore) -> tuple[str, str]:
    rollouts = await store.query_rollouts()
    assert len(rollouts) == 1
    rollout = rollouts[0]
    attempts = await store.query_attempts(rollout.rollout_id)
    assert attempts[-1].status == "succeeded"
    return rollout.rollout_id, attempts[-1].attempt_id


class RecordingHook(Hook):
    def __init__(self) -> None:
        super().__init__()
        self.calls: List[str] = []
        self.received_spans: Optional[List[ReadableSpan] | List[Span]] = None

    async def on_rollout_start(self, *, agent: LitAgent[Any], runner: Runner[Any], rollout: Rollout) -> None:
        self.calls.append("on_rollout_start")

    async def on_trace_start(
        self, *, agent: LitAgent[Any], runner: Runner[Any], tracer: Tracer, rollout: Rollout
    ) -> None:
        self.calls.append("on_trace_start")

    async def on_trace_end(
        self, *, agent: LitAgent[Any], runner: Runner[Any], tracer: Tracer, rollout: Rollout
    ) -> None:
        self.calls.append("on_trace_end")

    async def on_rollout_end(
        self,
        *,
        agent: LitAgent[Any],
        runner: Runner[Any],
        rollout: Rollout,
        spans: List[ReadableSpan] | List[Span],
    ) -> None:
        self.calls.append("on_rollout_end")
        self.received_spans = spans


@pytest.mark.asyncio
async def test_step_records_spans_for_none_result() -> None:
    tracer = DummyTracer()

    class AsyncSpanAgent(LitAgent[Dict[str, Any]]):
        async def validation_rollout_async(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> None:
            span = tracer.record_span("work", {"task_id": task["task_id"]})
            store = cast(LitAgentRunner[Dict[str, Any]], self.runner).get_store()
            await store.add_otel_span(rollout.rollout_id, rollout.attempt.attempt_id, span)  # type: ignore[attr-defined]
            return None

    agent = AsyncSpanAgent()
    runner, store, _ = await setup_runner(agent, tracer=tracer)
    try:
        await runner.step({"task_id": 1})
    finally:
        teardown_runner(runner)

    rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
    spans = await store.query_spans(rollout_id, attempt_id)
    assert [span.name for span in spans] == ["work"]
    assert find_final_reward(spans) is None


@pytest.mark.asyncio
async def test_step_emits_reward_for_float_result() -> None:
    class RewardAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            return 0.75

    agent = RewardAgent()
    runner, store, _ = await setup_runner(agent)
    try:
        await runner.step({"prompt": "hello"})
    finally:
        teardown_runner(runner)

    rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
    spans = await store.query_spans(rollout_id, attempt_id)
    rewards = [span.attributes.get("agentlightning.reward.0.value") for span in spans if span.name == AGL_ANNOTATION]
    assert rewards == [0.75]


@pytest.mark.asyncio
async def test_step_emits_reward_for_bool_result(caplog: pytest.LogCaptureFixture) -> None:
    class BoolRewardAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> bool:
            return True

    agent = BoolRewardAgent()
    runner, store, _ = await setup_runner(agent)
    caplog.set_level(logging.WARNING)
    try:
        await runner.step({"prompt": "hello"})
    finally:
        teardown_runner(runner)

    assert "Reward is not a number" in caplog.text
    rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
    spans = await store.query_spans(rollout_id, attempt_id)
    rewards = [span.attributes.get("agentlightning.reward.0.value") for span in spans if span.name == AGL_ANNOTATION]
    assert rewards == [1.0]


@pytest.mark.asyncio
async def test_step_raises_for_invalid_result_type() -> None:
    class InvalidResultAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(  # type: ignore[reportIncompatibleMethodOverride]
            self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
        ) -> Dict[str, Any]:
            return {"unexpected": True}

    agent = InvalidResultAgent()
    runner, store, _ = await setup_runner(agent)
    try:
        with pytest.raises(TypeError, match="Invalid raw result type"):
            await runner.step({"task": "bad-result"})
    finally:
        teardown_runner(runner)

    rollouts = await store.query_rollouts()
    assert len(rollouts) == 1
    attempts = await store.query_attempts(rollouts[0].rollout_id)
    assert attempts[-1].status == "failed"


@pytest.mark.asyncio
async def test_readable_spans_return_skip_store_when_tracer_is_otel(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    class DummyTracerWithOtel(DummyTracer):
        pass

    class RecordingStore(InMemoryLightningStore):
        def __init__(self) -> None:
            super().__init__()
            self.add_otel_span_calls = 0

        async def add_otel_span(
            self, rollout_id: str, attempt_id: str, readable_span: ReadableSpan, sequence_id: int | None = None
        ) -> Span | None:
            self.add_otel_span_calls += 1
            return await super().add_otel_span(rollout_id, attempt_id, readable_span, sequence_id)

    monkeypatch.setattr("agentlightning.runner.agent.OtelTracer", DummyTracerWithOtel)

    store = RecordingStore()
    await store.update_resources("default", {"llm": LLM(endpoint="http://localhost", model="dummy")})

    tracer = DummyTracerWithOtel()
    runner = LitAgentRunner[Any](tracer=tracer)
    agent = HeartbeatAgent()
    runner.init(agent)
    runner.init_worker(worker_id=0, store=store)

    attempted_rollout = await store.start_rollout(input={"task": "otel"}, mode="val")
    spans = [create_readable_span("otel-span")]

    result_spans = await runner._post_process_rollout_result(  # pyright: ignore[reportPrivateUsage]
        attempted_rollout, spans
    )

    assert all(isinstance(span, Span) for span in result_spans)
    # Warned, but still logged
    assert [span.name for span in result_spans] == ["otel-span"]
    assert store.add_otel_span_calls == 1
    assert "Tracer is already an OpenTelemetry tracer" in caplog.text

    teardown_runner(runner)


@pytest.mark.asyncio
async def test_post_process_readable_spans_adds_each_span() -> None:
    agent = HeartbeatAgent()
    runner, store, _ = await setup_runner(agent)
    attempted_rollout = await store.start_rollout(input={"task": "post-process"}, mode="val")

    spans = [create_readable_span("case-2-span-a"), create_readable_span("case-2-span-b")]

    try:
        result_spans = await runner._post_process_rollout_result(  # pyright: ignore[reportPrivateUsage]
            attempted_rollout, spans
        )
    finally:
        teardown_runner(runner)

    assert [span.name for span in result_spans] == ["case-2-span-a", "case-2-span-b"]
    stored_spans = await store.query_spans(attempted_rollout.rollout_id, attempted_rollout.attempt.attempt_id)
    assert [span.name for span in stored_spans] == ["case-2-span-a", "case-2-span-b"]


@pytest.mark.asyncio
async def test_post_process_span_core_fields_create_spans() -> None:
    agent = HeartbeatAgent()
    runner, store, _ = await setup_runner(agent)
    attempted_rollout = await store.start_rollout(input={"task": "reward-list"}, mode="val")

    span_core_fields = [emit_reward(0.5, propagate=False), emit_reward(-0.2, propagate=False)]

    try:
        result_spans = await runner._post_process_rollout_result(  # pyright: ignore[reportPrivateUsage]
            attempted_rollout, span_core_fields
        )
    finally:
        teardown_runner(runner)

    assert all(span.name == AGL_ANNOTATION for span in result_spans)
    stored_spans = await store.query_spans(attempted_rollout.rollout_id, attempted_rollout.attempt.attempt_id)
    assert [span.attributes.get("agentlightning.reward.0.value") for span in stored_spans] == [0.5, -0.2]


@pytest.mark.asyncio
async def test_step_handles_non_llm_resource() -> None:
    class PromptAgent(LitAgent[str]):
        def validation_rollout(self, task: str, resources: Dict[str, Any], rollout: Any) -> float:
            template = resources["template"]
            assert isinstance(template, PromptTemplate)
            rendered = template.template.format(name=task)
            assert task in rendered
            return 0.1

    agent = PromptAgent()
    runner, store, _ = await setup_runner(agent)
    try:
        await store.update_resources(
            "prompt-resource",
            {"template": PromptTemplate(template="Hello {name}!", engine="f-string")},
        )
        await runner.step("Ada")
    finally:
        teardown_runner(runner)

    rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
    spans = await store.query_spans(rollout_id, attempt_id)
    rewards = [span.attributes.get("agentlightning.reward.0.value") for span in spans if span.name == AGL_ANNOTATION]
    assert rewards == [0.1]


@pytest.mark.asyncio
async def test_step_accepts_readable_span_list() -> None:
    class ReadableSpanAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(
            self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
        ) -> List[ReadableSpan]:
            return [create_readable_span(f"trace-{i}", {"idx": i}) for i in range(2)]

    agent = ReadableSpanAgent()
    runner, store, _ = await setup_runner(agent)
    try:
        await runner.step({"payload": True})
    finally:
        teardown_runner(runner)

    rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
    spans = await store.query_spans(rollout_id, attempt_id)
    assert [span.name for span in spans] == ["trace-0", "trace-1"]


@pytest.mark.asyncio
async def test_step_accepts_agent_span_list() -> None:
    class AgentSpanAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> List[Span]:
            return [
                create_agent_span(rollout.rollout_id, rollout.attempt.attempt_id, 1, "custom-1", {"order": 1}),
                create_agent_span(rollout.rollout_id, rollout.attempt.attempt_id, 2, "custom-2", {"order": 2}),
            ]

    agent = AgentSpanAgent()
    runner, store, _ = await setup_runner(agent)
    try:
        await runner.step({"payload": False})
    finally:
        teardown_runner(runner)

    rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
    spans = await store.query_spans(rollout_id, attempt_id)
    assert [span.name for span in spans] == ["custom-1", "custom-2"]


@pytest.mark.asyncio
async def test_iter_respects_max_rollouts() -> None:
    class CountingAgent(LitAgent[Dict[str, Any]]):
        def __init__(self) -> None:
            super().__init__()
            self.processed: List[int] = []

        def training_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            self.processed.append(task["idx"])
            return 0.0

    agent = CountingAgent()
    runner, store, _ = await setup_runner(agent, max_rollouts=2)

    for idx in range(3):
        await store.enqueue_rollout({"idx": idx}, mode="train")

    try:
        await asyncio.wait_for(runner.iter(), timeout=1)
    finally:
        teardown_runner(runner)

    assert agent.processed == [0, 1]
    rollouts = await store.query_rollouts()
    statuses = {rollout.rollout_id: rollout.status for rollout in rollouts}
    assert list(statuses.values()).count("succeeded") == 2


@pytest.mark.asyncio
async def test_iter_stops_when_event_is_set() -> None:
    stop_event = ThreadingEvent()

    class StoppableAgent(LitAgent[Dict[str, Any]]):
        def __init__(self) -> None:
            super().__init__()
            self.processed: List[int] = []

        async def training_rollout_async(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> None:
            self.processed.append(task["idx"])
            if len(self.processed) == 1:
                stop_event.set()
            await asyncio.sleep(0.05)
            return None

    agent = StoppableAgent()
    runner, store, _ = await setup_runner(agent)

    for idx in range(3):
        await store.enqueue_rollout({"idx": idx}, mode="train")

    iter_task = asyncio.create_task(runner.iter(event=stop_event))
    try:
        await asyncio.wait_for(asyncio.to_thread(stop_event.wait, timeout=1), timeout=2)
        await asyncio.wait_for(iter_task, timeout=1)
    finally:
        teardown_runner(runner)

    assert agent.processed == [0]
    rollouts = await store.query_rollouts()
    succeeded = [rollout for rollout in rollouts if rollout.status == "succeeded"]
    assert len(succeeded) == 1


@pytest.mark.asyncio
async def test_iter_waits_when_queue_empty_calls_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
    stop_event = ThreadingEvent()

    class IdleAgent(LitAgent[Any]):
        def training_rollout(self, task: Any, resources: Any, rollout: Any) -> None:
            return None

    agent = IdleAgent()
    runner, _, _ = await setup_runner(agent, poll_interval=0.01)

    sleep_calls = 0

    async def fake_sleep(event: Optional[ExecutionEvent] = None) -> None:
        nonlocal sleep_calls
        sleep_calls += 1
        if event is not None:
            event.set()

    monkeypatch.setattr(runner, "_sleep_until_next_poll", fake_sleep)

    try:
        await runner.iter(event=stop_event)
    finally:
        teardown_runner(runner)

    assert sleep_calls >= 1


@pytest.mark.asyncio
async def test_async_validation_rollout_used() -> None:
    class AsyncValidationAgent(LitAgent[Dict[str, Any]]):
        def __init__(self) -> None:
            super().__init__()
            self.validation_calls = 0
            self.training_calls = 0

        async def validation_rollout_async(
            self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
        ) -> float:
            self.validation_calls += 1
            return 0.0

        async def training_rollout_async(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            self.training_calls += 1
            return 0.0

    agent = AsyncValidationAgent()
    runner, store, _ = await setup_runner(agent, max_rollouts=1)
    await store.enqueue_rollout({"idx": 1}, mode="val")

    try:
        await runner.iter()
    finally:
        teardown_runner(runner)

    assert agent.validation_calls == 1
    assert agent.training_calls == 0


@pytest.mark.asyncio
async def test_training_rollout_sync_used() -> None:
    class SyncTrainingAgent(LitAgent[Dict[str, Any]]):
        def __init__(self) -> None:
            super().__init__()
            self.training_calls = 0

        def training_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> None:
            self.training_calls += 1
            return None

    agent = SyncTrainingAgent()
    runner, store, _ = await setup_runner(agent, max_rollouts=1)
    await store.enqueue_rollout({"idx": 99}, mode="train")

    try:
        await runner.iter()
    finally:
        teardown_runner(runner)

    assert agent.training_calls == 1


@pytest.mark.asyncio
async def test_step_handles_agent_exception_marks_attempt_failed() -> None:
    class FailingAgent(LitAgent[Dict[str, Any]]):
        def training_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> None:
            raise RuntimeError("boom")

    agent = FailingAgent()
    runner, store, _ = await setup_runner(agent)
    with pytest.raises(RuntimeError):
        await runner.step({"task": "x"})

    rollouts = await store.query_rollouts()
    assert len(rollouts) == 1
    attempts = await store.query_attempts(rollouts[0].rollout_id)
    assert attempts[-1].status == "failed"
    teardown_runner(runner)


@pytest.mark.asyncio
async def test_agent_emits_multiple_rewards() -> None:
    class RewardListAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(
            self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
        ) -> List[SpanCoreFields]:
            return [emit_reward(0.2, propagate=False), emit_reward(0.6, propagate=False)]

    agent = RewardListAgent()
    runner, store, _ = await setup_runner(agent)
    try:
        await runner.step({"task": "reward"})
    finally:
        teardown_runner(runner)

    rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
    spans = await store.query_spans(rollout_id, attempt_id)
    reward_values = [
        span.attributes.get("agentlightning.reward.0.value") for span in spans if span.name == AGL_ANNOTATION
    ]
    assert reward_values == [0.2, 0.6]


@pytest.mark.asyncio
async def test_hooks_triggered_in_order() -> None:
    hook = RecordingHook()

    class HookAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(
            self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
        ) -> List[ReadableSpan]:
            return [create_readable_span("hook-span")]

    agent = HookAgent()
    runner, store, _ = await setup_runner(agent, hooks=[hook])
    try:
        await runner.step({"task": "hook"})
    finally:
        teardown_runner(runner)

    assert hook.calls == ["on_rollout_start", "on_trace_start", "on_trace_end", "on_rollout_end"]
    assert hook.received_spans is not None
    rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
    spans = await store.query_spans(rollout_id, attempt_id)
    assert [span.name for span in spans] == ["hook-span"]


@pytest.mark.asyncio
async def test_step_returns_completed_rollout() -> None:
    """Test that step() returns a Rollout object after execution."""

    class SimpleAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            return 0.85

    agent = SimpleAgent()
    runner, store, _ = await setup_runner(agent)
    try:
        result = await runner.step({"task": "test"})
    finally:
        teardown_runner(runner)

    # Verify the result is a Rollout object
    assert isinstance(result, Rollout)
    assert result.status == "succeeded"
    assert result.input == {"task": "test"}

    # Verify the rollout was stored correctly
    rollouts = await store.query_rollouts()
    assert len(rollouts) == 1
    assert rollouts[0].rollout_id == result.rollout_id


@pytest.mark.asyncio
async def test_step_returns_rollout_with_spans() -> None:
    """Test that the returned rollout can be used to query spans."""

    class SpanAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(
            self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
        ) -> List[ReadableSpan]:
            return [create_readable_span("test-span-1"), create_readable_span("test-span-2")]

    agent = SpanAgent()
    runner, store, _ = await setup_runner(agent)
    try:
        result = await runner.step({"task": "test"})
    finally:
        teardown_runner(runner)

    # Verify we can query spans using the returned rollout
    attempts = await store.query_attempts(result.rollout_id)
    assert len(attempts) > 0
    spans = await store.query_spans(result.rollout_id, attempts[-1].attempt_id)
    assert len(spans) == 2
    assert [span.name for span in spans] == ["test-span-1", "test-span-2"]


@pytest.mark.asyncio
async def test_step_raises_when_rollout_fetch_fails(monkeypatch: pytest.MonkeyPatch) -> None:
    """Test that step() raises RuntimeError when completed rollout cannot be fetched."""

    class SimpleAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            return 0.5

    agent = SimpleAgent()
    runner, store, _ = await setup_runner(agent)

    # Mock get_rollout_by_id to return None
    original_get_rollout = store.get_rollout_by_id

    async def mock_get_rollout_by_id(rollout_id: str) -> Optional[Rollout]:
        return None

    monkeypatch.setattr(store, "get_rollout_by_id", mock_get_rollout_by_id)

    try:
        with pytest.raises(RuntimeError, match="Failed to fetch completed rollout by id after step"):
            await runner.step({"task": "test"})
    finally:
        monkeypatch.setattr(store, "get_rollout_by_id", original_get_rollout)
        teardown_runner(runner)


@pytest.mark.asyncio
async def test_step_impl_returns_rollout_id() -> None:
    """Test that _step_impl returns the rollout_id after execution."""

    class SimpleAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            return 0.9

    agent = SimpleAgent()
    runner, store, _ = await setup_runner(agent)

    # Create an attempted rollout
    attempted_rollout = await store.start_rollout(input={"task": "test"}, mode="val")

    try:
        # Call _step_impl directly and verify it returns rollout_id
        result = await runner._step_impl(  # pyright: ignore[reportPrivateUsage]
            attempted_rollout, raise_on_exception=True
        )
    finally:
        teardown_runner(runner)

    # Verify the result is a string (rollout_id)
    assert isinstance(result, str)
    assert result == attempted_rollout.rollout_id


@pytest.mark.asyncio
async def test_step_impl_returns_rollout_id_on_resource_failure() -> None:
    """Test that _step_impl returns rollout_id even when resources fail to fetch."""

    class SimpleAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            return 0.9

    agent = SimpleAgent()
    runner, store, _ = await setup_runner(agent)

    # Create an attempted rollout with invalid resources_id
    attempted_rollout = await store.start_rollout(input={"task": "test"}, mode="val", resources_id="invalid-id")

    try:
        # Call _step_impl with raise_on_exception=False (to test the early return path)
        result = await runner._step_impl(  # pyright: ignore[reportPrivateUsage]
            attempted_rollout, raise_on_exception=False
        )
    finally:
        teardown_runner(runner)

    # Verify the result is a string (rollout_id) even on early return
    assert isinstance(result, str)
    assert result == attempted_rollout.rollout_id


@pytest.mark.asyncio
async def test_step_with_custom_resources_returns_rollout() -> None:
    """Test that step() with custom resources returns a valid Rollout."""

    class ResourceAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            # Verify we received the custom LLM
            llm = resources.get("llm")
            assert llm is not None
            assert llm.model == "custom-model"
            return 0.95

    agent = ResourceAgent()
    runner, _store, _ = await setup_runner(agent)

    custom_resources: NamedResources = {"llm": LLM(endpoint="http://custom", model="custom-model")}

    try:
        result = await runner.step({"task": "test"}, resources=custom_resources)
    finally:
        teardown_runner(runner)

    # Verify the result is a valid Rollout
    assert isinstance(result, Rollout)
    assert result.status == "succeeded"
    assert result.input == {"task": "test"}

    # Verify the rollout has the correct resources_id
    assert result.resources_id is not None


@pytest.mark.asyncio
async def test_step_registers_worker_id_on_start_rollout(monkeypatch: pytest.MonkeyPatch) -> None:
    """runner.step should pass the formatted worker ID down to the store."""

    class WorkerAwareAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
            return 1.0

    agent = WorkerAwareAgent()
    runner, store, _ = await setup_runner(agent)

    expected_worker_label = runner.get_worker_id()
    captured: Dict[str, Optional[str]] = {}
    original_start_rollout = store.start_rollout

    async def wrapped_start_rollout(*args: Any, **kwargs: Any):
        captured["worker_id"] = kwargs.get("worker_id")
        return await original_start_rollout(*args, **kwargs)

    monkeypatch.setattr(store, "start_rollout", wrapped_start_rollout)

    try:
        await runner.step({"task": "worker-aware"})
    finally:
        teardown_runner(runner)

    assert captured["worker_id"] == expected_worker_label


@pytest.mark.asyncio
async def test_iter_passes_worker_id_to_dequeue(monkeypatch: pytest.MonkeyPatch) -> None:
    """iter() should poll the store with the formatted worker identifier."""

    class IdleAgent(LitAgent[Dict[str, Any]]):
        def validation_rollout(
            self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
        ) -> float:  # pragma: no cover - not invoked
            return 0.0

    agent = IdleAgent()
    runner, store, _ = await setup_runner(agent, poll_interval=0.01)

    expected_worker_label = runner.get_worker_id()
    captured: Dict[str, Optional[str]] = {}
    event = ThreadingEvent()

    async def fake_dequeue(*, worker_id: Optional[str] = None):
        captured["worker_id"] = worker_id
        event.set()
        return None

    async def fast_sleep(self: LitAgentRunner[Any], event: Optional[ExecutionEvent] = None) -> None:
        if event is not None:
            event.set()

    monkeypatch.setattr(store, "dequeue_rollout", fake_dequeue)
    monkeypatch.setattr(LitAgentRunner, "_sleep_until_next_poll", fast_sleep)

    try:
        await runner.iter(event=event)
    finally:
        teardown_runner(runner)

    assert captured["worker_id"] == expected_worker_label


@pytest.mark.asyncio
async def test_emit_heartbeat_updates_worker_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
    snapshot = {"cpu_pct": 42.0, "mem_pct": 10.5}
    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)

    runner, store = await setup_heartbeat_runner(heartbeat_interval=0.1)
    worker_label = runner.get_worker_id()
    try:
        await runner._emit_heartbeat(store)  # pyright: ignore[reportPrivateUsage]
    finally:
        teardown_runner(runner)

    assert store.worker_updates == [(worker_label, snapshot)]
    worker = await store.get_worker_by_id(worker_label)
    assert worker is not None
    assert worker.heartbeat_stats == snapshot
    assert worker.last_heartbeat_time is not None


@pytest.mark.asyncio
async def test_emit_heartbeat_passes_include_gpu(monkeypatch: pytest.MonkeyPatch) -> None:
    snapshot = {"cpu_pct": 11.1}
    requested_flags: List[bool] = []

    async def immediate_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
        return func(*args, **kwargs)

    monkeypatch.setattr("agentlightning.runner.agent.asyncio.to_thread", immediate_to_thread)

    def fake_system_snapshot(include_gpu: bool = False) -> Dict[str, Any]:
        requested_flags.append(include_gpu)
        return snapshot

    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)

    runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05, heartbeat_include_gpu=True)
    worker_label = runner.get_worker_id()
    try:
        await runner._emit_heartbeat(store)  # pyright: ignore[reportPrivateUsage]
    finally:
        teardown_runner(runner)

    assert requested_flags == [True]
    assert store.worker_updates == [(worker_label, snapshot)]


@pytest.mark.asyncio
async def test_emit_heartbeat_skips_when_snapshot_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
    snapshot = {"cpu_pct": 9.9}
    runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05)
    interval = runner._heartbeat_interval  # pyright: ignore[reportPrivateUsage]

    async def slow_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
        await asyncio.sleep(interval + 0.05)
        return func(*args, **kwargs)

    monkeypatch.setattr("agentlightning.runner.agent.asyncio.to_thread", slow_to_thread)
    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)

    try:
        await runner._emit_heartbeat(store)  # pyright: ignore[reportPrivateUsage]
    finally:
        teardown_runner(runner)

    assert store.worker_updates == []


@pytest.mark.asyncio
async def test_emit_heartbeat_skips_when_store_update_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
    snapshot = {"cpu_pct": 8.8}

    async def immediate_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
        return func(*args, **kwargs)

    monkeypatch.setattr("agentlightning.runner.agent.asyncio.to_thread", immediate_to_thread)
    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)

    runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05)
    interval = runner._heartbeat_interval  # pyright: ignore[reportPrivateUsage]
    original_update_worker = store.update_worker

    async def slow_update_worker(*args: Any, **kwargs: Any) -> Worker:
        await asyncio.sleep(interval + 0.05)
        return await original_update_worker(*args, **kwargs)

    monkeypatch.setattr(store, "update_worker", slow_update_worker)

    try:
        await runner._emit_heartbeat(store)  # pyright: ignore[reportPrivateUsage]
    finally:
        teardown_runner(runner)

    assert store.worker_updates == []


@pytest.mark.asyncio
async def test_heartbeat_loop_runs_until_stopped(monkeypatch: pytest.MonkeyPatch) -> None:
    """Test that heartbeat loop runs with the default launch mode until stopped."""
    snapshot = {"timestamp": 1234567890}
    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)

    runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05)
    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is not None

    try:
        await asyncio.sleep(0.12)
    finally:
        await stop_heartbeat()

    update_count = len(store.worker_updates)
    assert update_count >= 1
    assert all(stats == snapshot for _, stats in store.worker_updates if stats is not None)

    await asyncio.sleep(0.06)
    assert len(store.worker_updates) == update_count

    teardown_runner(runner)


@pytest.mark.asyncio
async def test_asyncio_heartbeat_loop_runs_until_stopped(monkeypatch: pytest.MonkeyPatch) -> None:
    """Test that asyncio heartbeat loop runs until stopped (explicit asyncio mode test)."""
    snapshot = {"timestamp": 1234567890}
    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)

    runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05, heartbeat_launch_mode="asyncio")
    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is not None

    try:
        await asyncio.sleep(0.12)
    finally:
        await stop_heartbeat()

    update_count = len(store.worker_updates)
    assert update_count >= 1
    assert all(stats == snapshot for _, stats in store.worker_updates if stats is not None)

    await asyncio.sleep(0.06)
    assert len(store.worker_updates) == update_count

    teardown_runner(runner)


@pytest.mark.asyncio
async def test_thread_heartbeat_loop_runs_until_stopped(monkeypatch: pytest.MonkeyPatch) -> None:
    snapshot = {"timestamp": time.time()}
    include_flags: List[bool] = []

    def fake_system_snapshot(include_gpu: bool = False) -> Dict[str, Any]:
        include_flags.append(include_gpu)
        return snapshot

    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)

    runner, store = await setup_heartbeat_runner(
        heartbeat_interval=0.05,
        heartbeat_launch_mode="thread",
        heartbeat_include_gpu=True,
    )
    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is not None

    try:
        await asyncio.sleep(0.3)
    finally:
        await stop_heartbeat()
        teardown_runner(runner)

    assert len(store.worker_updates) >= 1
    assert all(stats == snapshot for _, stats in store.worker_updates if stats is not None)
    assert include_flags and all(include_flags)


@pytest.mark.asyncio
async def test_thread_heartbeat_handles_producer_exception(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    """Test that thread mode producer handles exceptions and continues running."""
    call_count = 0

    def fake_system_snapshot(_include_gpu: bool = False) -> Dict[str, Any]:
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise RuntimeError("Simulated snapshot failure")
        return {"cpu_pct": 25.0}

    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)

    caplog.set_level(logging.WARNING)
    runner, store = await setup_heartbeat_runner(
        heartbeat_interval=0.02,
        heartbeat_launch_mode="thread",
    )
    runner._interval_jitter = 0.01  # pyright: ignore[reportPrivateUsage]
    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is not None

    try:
        # Wait long enough for at least 2 cycles (2 * (interval + jitter) + buffer)
        await asyncio.sleep(0.15)
    finally:
        await stop_heartbeat()
        teardown_runner(runner)

    # Verify the producer logged the exception but continued
    assert "system_snapshot failed" in caplog.text
    assert call_count >= 2


@pytest.mark.asyncio
async def test_thread_heartbeat_handles_consumer_exception(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    """Test that thread mode consumer handles store update exceptions and continues."""
    snapshot = {"cpu_pct": 33.0}
    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)

    runner, store = await setup_heartbeat_runner(
        heartbeat_interval=0.02,
        heartbeat_launch_mode="thread",
    )
    runner._interval_jitter = 0.01  # pyright: ignore[reportPrivateUsage]

    update_count = 0
    original_update_worker = store.update_worker

    async def failing_update_worker(*args: Any, **kwargs: Any) -> Worker:
        nonlocal update_count
        update_count += 1
        if update_count == 1:
            raise RuntimeError("Simulated store failure")
        return await original_update_worker(*args, **kwargs)

    monkeypatch.setattr(store, "update_worker", failing_update_worker)

    caplog.set_level(logging.WARNING)
    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is not None

    try:
        # Wait long enough for at least 2 cycles
        await asyncio.sleep(0.15)
    finally:
        await stop_heartbeat()
        teardown_runner(runner)

    # Verify the consumer logged the exception but continued
    assert "update failed" in caplog.text
    assert update_count >= 2


@pytest.mark.asyncio
async def test_thread_heartbeat_waits_for_first_snapshot(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    """Test that thread mode consumer skips update when no snapshot is available yet."""
    snapshot_ready = False

    def fake_system_snapshot(_include_gpu: bool = False) -> Dict[str, Any]:
        nonlocal snapshot_ready
        if not snapshot_ready:
            time.sleep(0.1)  # Simulate slow first snapshot
            snapshot_ready = True
        return {"cpu_pct": 42.0}

    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)

    caplog.set_level(logging.DEBUG)
    runner, store = await setup_heartbeat_runner(
        heartbeat_interval=0.05,
        heartbeat_launch_mode="thread",
    )
    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is not None

    try:
        await asyncio.sleep(0.25)
    finally:
        await stop_heartbeat()
        teardown_runner(runner)

    # Verify the consumer logged that no snapshot was available initially
    assert "no snapshot yet" in caplog.text


@pytest.mark.asyncio
async def test_thread_heartbeat_logs_stale_snapshot_only_once(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    """Test that stale snapshot warning is only logged once per stale snapshot."""
    call_count = 0

    def fake_system_snapshot(_include_gpu: bool = False) -> Dict[str, Any]:
        nonlocal call_count
        call_count += 1
        # Only create one snapshot, then hang
        if call_count == 1:
            return {"cpu_pct": 50.0}
        # Simulate hung producer
        time.sleep(10)
        return {"cpu_pct": 99.0}

    monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)

    caplog.set_level(logging.WARNING)
    runner, store = await setup_heartbeat_runner(
        heartbeat_interval=0.01,
        heartbeat_launch_mode="thread",
    )
    runner._interval_jitter = 0.01  # pyright: ignore[reportPrivateUsage]

    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is not None

    try:
        # Wait long enough for the single snapshot to become stale
        # and for multiple consumer iterations to check it
        # stale_after = 0.01 + 0.01 + 1.0 = 1.02s
        await asyncio.sleep(1.3)
    finally:
        await stop_heartbeat()
        teardown_runner(runner)

    # Count how many times the stale warning appears
    stale_warnings = caplog.text.count("snapshot stale")
    # Should only warn once, even though consumer checked multiple times
    assert stale_warnings == 1


@pytest.mark.asyncio
async def test_heartbeat_disabled_when_interval_zero() -> None:
    """Test that heartbeat loop is not started when interval is 0 or negative."""
    runner, store = await setup_heartbeat_runner(heartbeat_interval=0.0)
    try:
        stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
        assert stop_heartbeat is None
    finally:
        teardown_runner(runner)


@pytest.mark.asyncio
async def test_heartbeat_disabled_when_no_worker_id(caplog: pytest.LogCaptureFixture) -> None:
    """Test that heartbeat loop returns None when worker_id is not set."""
    store = RecordingStore()
    runner = LitAgentRunner[Any](
        tracer=DummyTracer(),
        heartbeat_interval=0.05,
    )
    agent = HeartbeatAgent()
    runner.init(agent)
    # Note: NOT calling init_worker, so worker_id remains None

    caplog.set_level(logging.WARNING)
    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is None
    assert "Cannot start heartbeat loop without worker_id" in caplog.text

    teardown_runner(runner)


@pytest.mark.asyncio
async def test_emit_heartbeat_propagates_cancelled_error() -> None:
    """Test that CancelledError is properly propagated in _emit_heartbeat."""
    runner, store = await setup_heartbeat_runner(heartbeat_interval=0.1)

    async def cancelling_to_thread(_func: Any, *_args: Any, **_kwargs: Any) -> Any:
        raise asyncio.CancelledError()

    original_to_thread = asyncio.to_thread
    try:
        asyncio.to_thread = cancelling_to_thread  # type: ignore
        with pytest.raises(asyncio.CancelledError):
            await runner._emit_heartbeat(store)  # pyright: ignore[reportPrivateUsage]
    finally:
        asyncio.to_thread = original_to_thread  # type: ignore
        teardown_runner(runner)


@pytest.mark.asyncio
async def test_asyncio_heartbeat_continues_after_exception(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    """Test that asyncio heartbeat loop continues running after exceptions."""
    call_count = 0

    async def failing_emit_heartbeat(_self: Any, _store: Any) -> None:
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise RuntimeError("Simulated heartbeat failure")

    monkeypatch.setattr(LitAgentRunner, "_emit_heartbeat", failing_emit_heartbeat)

    caplog.set_level(logging.ERROR)
    runner, store = await setup_heartbeat_runner(
        heartbeat_interval=0.02,
        heartbeat_launch_mode="asyncio",
    )
    runner._interval_jitter = 0.01  # pyright: ignore[reportPrivateUsage]
    stop_heartbeat = runner._start_heartbeat_loop(store)  # pyright: ignore[reportPrivateUsage]
    assert stop_heartbeat is not None

    try:
        # Wait long enough for at least 2 cycles (2 * (interval + jitter) + buffer)
        await asyncio.sleep(0.15)
    finally:
        await stop_heartbeat()
        teardown_runner(runner)

    # Verify the loop logged the exception but continued
    assert "Heartbeat failed" in caplog.text
    assert call_count >= 2
