"""Tests for #219 — web.agent_telemetry structured agent-platform events.

Verifies the emitter contract the recovery-loop observability relies on:
- ``emit`` stamps ts/v/event/outcome and drops ``None`` fields.
- ``instrument`` lifts standard + result keys, times the call, and derives the
  outcome (ok / noop for ``{"ok": False}`` / error on exception).
- an exception is re-raised after the error event — the wrapped op is **never
  silently swallowed** (constitution §6 / INV-G4).
- the decorator is transparent: wrapped functions stay coroutine functions.

Pure-stdlib module (no Paperclip/web deps), so this runs without importorskip.
"""

from __future__ import annotations

import asyncio
import inspect
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))  # web/

from agent_telemetry import SCHEMA_VERSION, emit, instrument  # noqa: E402
import agent_telemetry  # noqa: E402


@pytest.fixture
def captured(monkeypatch):
    """Intercept the sink; yield the list of emitted event dicts."""
    events: list[dict] = []
    monkeypatch.setattr(agent_telemetry, "_SINK", events.append)
    return events


def test_emit_stamps_schema_and_drops_none(captured):
    emit("x.test", role="ceo", case_number="8125-09-24", issue_id=None)
    (e,) = captured
    assert e["event"] == "x.test"
    assert e["v"] == SCHEMA_VERSION
    assert e["outcome"] == "ok"
    assert e["ts"].endswith("Z")
    assert e["case_number"] == "8125-09-24"
    assert "issue_id" not in e  # None fields dropped


def test_instrument_success_lifts_result_keys(captured):
    @instrument("interaction.reaped", result_keys=("cancelled",))
    async def reap():
        return {"ok": True, "cancelled": 3}

    result = asyncio.run(reap())
    assert result == {"ok": True, "cancelled": 3}
    (e,) = captured
    assert e["event"] == "interaction.reaped"
    assert e["outcome"] == "ok"
    assert e["cancelled"] == 3
    assert "duration_ms" in e


def test_instrument_ok_false_is_noop(captured):
    @instrument("interaction.cancelled", keys=("issue_id",))
    async def cancel(issue_id, interaction_id):
        return {"ok": False, "error": "already resolved"}

    asyncio.run(cancel("iss-1", "int-9"))
    (e,) = captured
    assert e["outcome"] == "noop"
    assert e["issue_id"] == "iss-1"


def test_instrument_reraises_after_error_event(captured):
    @instrument("agent.wakeup", role="ceo")
    async def wake(case_number, company_id=""):
        raise RuntimeError("boom")

    with pytest.raises(RuntimeError, match="boom"):
        asyncio.run(wake("1043-04-26", company_id="cmp"))

    (e,) = captured
    assert e["outcome"] == "error"
    assert "boom" in e["error"]
    assert e["agent_role"] == "ceo"
    assert e["case_number"] == "1043-04-26"
    assert e["company_id"] == "cmp"


def test_decorator_is_transparent():
    @instrument("agent.wakeup", role="analyst")
    async def wake(case_number, company_id=""):
        return {"ok": True}

    assert inspect.iscoroutinefunction(wake)
    assert wake.__name__ == "wake"


def test_emit_never_raises_on_sink_failure(monkeypatch):
    def boom(_payload):
        raise ValueError("sink down")

    monkeypatch.setattr(agent_telemetry, "_SINK", boom)
    # Must not propagate — telemetry is best-effort, never fatal to the op.
    emit("agent.wakeup", role="ceo")
