"""Tests for Langfuse integration (OTel SpanProcessor approach)."""

from __future__ import annotations

import uuid as uuid_mod
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest

from llm_observatory.langfuse_patch import (
    Context0SpanProcessor,
    _process_span,
    _reset_patch_state,
    wrap_langfuse,
)


# ── Helpers ───────────────────────────────────────────────────────


def _is_valid_uuid(val: str) -> bool:
    try:
        uuid_mod.UUID(val)
        return True
    except (ValueError, AttributeError, TypeError):
        return False


def _find_event(events: list[dict], *, span_type: str, span_name: str | None = None) -> dict:
    for e in events:
        if e["span_type"] != span_type:
            continue
        if span_name is not None and e.get("span_name") != span_name:
            continue
        return e
    raise LookupError(f"No event with span_type={span_type!r}, span_name={span_name!r}")


def _find_events(events: list[dict], *, span_type: str) -> list[dict]:
    return [e for e in events if e["span_type"] == span_type]


# ── Fake OTel span builder ───────────────────────────────────────

_trace_counter = 0


def _fake_span(
    *,
    name: str = "test-span",
    trace_id: int | None = None,
    span_id: int | None = None,
    parent_span_id: int | None = None,
    start_time_ns: int = 0,
    end_time_ns: int = 0,
    attributes: dict | None = None,
) -> SimpleNamespace:
    """Build a fake OTel ReadableSpan matching the real structure."""
    global _trace_counter
    _trace_counter += 1

    tid = trace_id if trace_id is not None else _trace_counter * 0x1000
    sid = span_id if span_id is not None else _trace_counter * 0x100

    ctx = SimpleNamespace(trace_id=tid, span_id=sid)
    parent = SimpleNamespace(span_id=parent_span_id) if parent_span_id is not None else None

    return SimpleNamespace(
        name=name,
        context=ctx,
        parent=parent,
        start_time=start_time_ns,
        end_time=end_time_ns,
        attributes=attributes or {},
    )


# ── Fixtures ─────────────────────────────────────────────────────


@pytest.fixture(autouse=True)
def _reset_state():
    global _trace_counter
    _trace_counter = 0
    _reset_patch_state()
    yield
    _reset_patch_state()


@pytest.fixture
def mock_transport():
    transport = MagicMock()
    events: list[dict] = []
    transport.enqueue.side_effect = lambda e: events.append(e)
    with patch("llm_observatory.langfuse_patch.get_transport", return_value=transport):
        yield events


@pytest.fixture
def enabled_config():
    with patch("llm_observatory.langfuse_patch.get_config", return_value={"enabled": True}):
        yield


@pytest.fixture
def processor():
    return Context0SpanProcessor()


# ── Tests: wrap_langfuse basics ──────────────────────────────────


def test_wrap_langfuse_returns_client_as_is():
    client = {"chat": "fake"}
    result = wrap_langfuse(client)
    assert result is client


def test_wrap_langfuse_does_not_crash_without_langfuse():
    assert wrap_langfuse({}) == {}


# ── Tests: SpanProcessor interface ──────────────────────────────


def test_span_processor_has_required_methods(processor):
    assert callable(processor.on_start)
    assert callable(processor.on_end)
    assert callable(processor._on_ending)
    assert callable(processor.shutdown)
    assert callable(processor.force_flush)


# ── Tests: trace emission ────────────────────────────────────────


def test_trace_event_emitted_on_first_span(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="my-generation",
        trace_id=0xAAAA0000BBBB1111CCCC2222DDDD3333,
        span_id=0x1111222233334444,
        parent_span_id=0x5555666677778888,
        attributes={
            "langfuse.observation.type": "generation",
            "user.id": "user-alice",
            "session.id": "sess-001",
            "langfuse.trace.tags": ("prod", "chat"),
        },
    )
    processor.on_end(span)

    # Should emit 2 events: synthetic trace + generation
    assert len(mock_transport) == 2

    trace_event = _find_event(mock_transport, span_type="trace")
    assert _is_valid_uuid(trace_event["event_id"])
    assert trace_event["trace_id"] == trace_event["event_id"]
    assert trace_event["metadata"]["user_id"] == "user-alice"
    assert trace_event["metadata"]["session_id"] == "sess-001"
    assert trace_event["metadata"]["tags"] == ["prod", "chat"]


def test_trace_event_id_equals_trace_id(mock_transport, enabled_config, processor):
    span = _fake_span(
        trace_id=0xABCD,
        span_id=0x1234,
        parent_span_id=0x5678,
        attributes={"langfuse.observation.type": "generation"},
    )
    processor.on_end(span)

    trace_event = _find_event(mock_transport, span_type="trace")
    assert _is_valid_uuid(trace_event["event_id"])
    assert trace_event["event_id"] == trace_event["trace_id"]


def test_trace_dedup(mock_transport, enabled_config, processor):
    tid = 0xAAAA0000BBBB1111CCCC2222DDDD3333
    span1 = _fake_span(
        trace_id=tid, span_id=0x1111, parent_span_id=0x0001,
        attributes={"langfuse.observation.type": "span"},
    )
    span2 = _fake_span(
        trace_id=tid, span_id=0x2222, parent_span_id=0x0001,
        attributes={"langfuse.observation.type": "generation"},
    )
    processor.on_end(span1)
    processor.on_end(span2)

    trace_events = _find_events(mock_transport, span_type="trace")
    assert len(trace_events) == 1


# ── Tests: generation events ────────────────────────────────────


def test_generation_event_with_model_usage_input_output(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="chat-completion",
        trace_id=0xAAAA, span_id=0x1111, parent_span_id=0x5555,
        start_time_ns=1_000_000_000,  # 1s
        end_time_ns=2_500_000_000,    # 2.5s
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.model.name": "gpt-4o",
            "langfuse.observation.input": '[{"role":"user","content":"Hello"}]',
            "langfuse.observation.output": '{"role":"assistant","content":"Hi there!"}',
            "langfuse.observation.usage_details": '{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}',
            "user.id": "user-alice",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["span_name"] == "chat-completion"
    assert gen["request"]["model"] == "gpt-4o"
    assert gen["request"]["messages"] == [{"role": "user", "content": "Hello"}]
    assert gen["response"]["choices"][0]["message"]["content"] == "Hi there!"
    assert gen["usage"]["prompt_tokens"] == 10
    assert gen["usage"]["completion_tokens"] == 5
    assert gen["usage"]["total_tokens"] == 15
    assert gen["metrics"]["latency_ms"] == 1500
    assert gen["span_input"] == '[{"role":"user","content":"Hello"}]'
    assert gen["span_output"] == '{"role":"assistant","content":"Hi there!"}'


def test_usage_camel_case_format(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xBBBB, span_id=0x2222, parent_span_id=0x1111,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.usage_details": '{"promptTokens":42,"completionTokens":18}',
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["usage"]["prompt_tokens"] == 42
    assert gen["usage"]["completion_tokens"] == 18
    assert gen["usage"]["total_tokens"] == 60


def test_usage_input_output_format(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xCCCC, span_id=0x3333, parent_span_id=0x1111,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.usage_details": '{"input":100,"output":50}',
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["usage"]["prompt_tokens"] == 100
    assert gen["usage"]["completion_tokens"] == 50
    assert gen["usage"]["total_tokens"] == 150


def test_usage_zero_not_treated_as_falsy(mock_transport, enabled_config, processor):
    """0 is a valid token count — must not be skipped by the fallback chain."""
    span = _fake_span(
        name="gen", trace_id=0xCC01, span_id=0x3301, parent_span_id=0x1111,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.usage_details": '{"prompt_tokens":0,"completion_tokens":5,"total_tokens":5}',
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["usage"]["prompt_tokens"] == 0
    assert gen["usage"]["completion_tokens"] == 5
    assert gen["usage"]["total_tokens"] == 5


def test_plain_text_output_wrapped_as_response(mock_transport, enabled_config, processor):
    """Non-JSON output should be wrapped as {role: assistant, content: text}."""
    span = _fake_span(
        name="gen", trace_id=0xCC02, span_id=0x3302, parent_span_id=0x1111,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.output": "This is a plain text response",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["response"]["choices"][0]["message"]["content"] == "This is a plain text response"
    assert gen["response"]["choices"][0]["message"]["role"] == "assistant"


def test_error_level_sets_error_status(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xDDDD, span_id=0x4444, parent_span_id=0x1111,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.level": "ERROR",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["span_status"] == "error"


# ── Tests: span events ──────────────────────────────────────────


def test_span_event_emitted(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="document-retrieval",
        trace_id=0xEEEE, span_id=0x5555, parent_span_id=0x1111,
        start_time_ns=1_000_000_000,
        end_time_ns=1_050_000_000,
        attributes={
            "langfuse.observation.type": "span",
            "langfuse.observation.input": '"How does vector search work?"',
            "langfuse.observation.output": '["doc1","doc2"]',
        },
    )
    processor.on_end(span)

    span_event = _find_event(mock_transport, span_type="span")
    assert span_event["span_name"] == "document-retrieval"
    assert span_event["metrics"]["latency_ms"] == 50


# ── Tests: parent-child relationships ────────────────────────────


def test_parent_id_from_parent_span(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="child", trace_id=0xAAAA, span_id=0x2222, parent_span_id=0x1111,
        attributes={"langfuse.observation.type": "generation"},
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert _is_valid_uuid(gen["parent_id"])
    assert gen["parent_id"] != gen["trace_id"]


def test_parent_id_falls_back_to_trace_id(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="root-gen", trace_id=0xAAAA, span_id=0x2222,
        attributes={"langfuse.observation.type": "generation"},
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["parent_id"] == gen["trace_id"]


# ── Tests: root spans ───────────────────────────────────────────


def test_root_span_without_obs_type_only_emits_trace(mock_transport, enabled_config, processor):
    span = _fake_span(name="root-span", trace_id=0xFFFF, span_id=0x1111,
                       attributes={"langfuse.observation.type": "span"})
    processor.on_end(span)

    assert len(mock_transport) == 1
    assert mock_transport[0]["span_type"] == "trace"


def test_root_span_with_obs_type_emits_trace_and_generation(mock_transport, enabled_config, processor):
    """Standalone LangfuseOpenAI calls create a single root span that IS the generation."""
    span = _fake_span(
        name="root-gen", trace_id=0xFFF0, span_id=0x1110,
        attributes={"langfuse.observation.type": "generation"},
    )
    # Call on_start then on_end — the full lifecycle
    processor.on_start(span)
    processor.on_end(span)

    assert len(mock_transport) == 2
    trace_event = _find_event(mock_transport, span_type="trace")
    gen_event = _find_event(mock_transport, span_type="generation")
    assert trace_event["event_id"] == trace_event["trace_id"]
    assert gen_event["trace_id"] == trace_event["trace_id"]
    # event_id must be distinct from trace_id — not self-parenting
    assert gen_event["event_id"] != gen_event["trace_id"]


# ── Tests: metadata ─────────────────────────────────────────────


def test_metadata_from_span_attributes(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xAAAA, span_id=0x2222, parent_span_id=0x1111,
        attributes={
            "langfuse.observation.type": "generation",
            "user.id": "user-bob",
            "session.id": "sess-123",
            "langfuse.trace.tags": ("prod", "v2"),
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metadata"]["user_id"] == "user-bob"
    assert gen["metadata"]["session_id"] == "sess-123"
    assert gen["metadata"]["tags"] == ["prod", "v2"]


# ── Tests: safety ───────────────────────────────────────────────


def test_no_crash_when_on_end_throws_internally(processor):
    with patch("llm_observatory.langfuse_patch.get_config", side_effect=RuntimeError("boom")):
        # Should not raise
        processor.on_end(_fake_span())


def test_disabled_sdk_emits_nothing(mock_transport, processor):
    with patch("llm_observatory.langfuse_patch.get_config", return_value={"enabled": False}):
        processor.on_end(_fake_span(
            trace_id=0xAAAA, span_id=0x1111, parent_span_id=0x2222,
            attributes={"langfuse.observation.type": "generation"},
        ))

    assert len(mock_transport) == 0


def test_span_with_no_attributes(mock_transport, enabled_config, processor):
    span = _fake_span(name="bare-span", trace_id=0xAAAA, span_id=0x1111)
    # Should not raise
    processor.on_end(span)


# ── Tests: ID conversion ────────────────────────────────────────


def test_hex_ids_converted_to_valid_uuids(mock_transport, enabled_config, processor):
    span = _fake_span(
        trace_id=0xAAAABBBBCCCCDDDD, span_id=0x1111222233334444, parent_span_id=0x5555666677778888,
        attributes={"langfuse.observation.type": "generation"},
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert _is_valid_uuid(gen["event_id"])
    assert _is_valid_uuid(gen["trace_id"])
    assert _is_valid_uuid(gen["parent_id"])


# ── Tests: timing ───────────────────────────────────────────────


def test_latency_from_otel_nanoseconds(mock_transport, enabled_config, processor):
    span = _fake_span(
        trace_id=0xAAAA, span_id=0x1111, parent_span_id=0x2222,
        start_time_ns=1_000_000_000,
        end_time_ns=2_500_000_000,
        attributes={"langfuse.observation.type": "generation"},
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metrics"]["latency_ms"] == 1500


# ── Tests: multi-tenant ─────────────────────────────────────────


def test_different_users_separate_traces(mock_transport, enabled_config, processor):
    span_alice = _fake_span(
        name="alice-gen",
        trace_id=0xAAAA0001, span_id=0xA001, parent_span_id=0xA000,
        attributes={
            "langfuse.observation.type": "generation",
            "user.id": "user-alice",
            "session.id": "sess-alice",
            "langfuse.trace.tags": ("prod",),
        },
    )
    span_bob = _fake_span(
        name="bob-gen",
        trace_id=0xBBBB0002, span_id=0xB001, parent_span_id=0xB000,
        attributes={
            "langfuse.observation.type": "generation",
            "user.id": "user-bob",
            "session.id": "sess-bob",
            "langfuse.trace.tags": ("staging",),
        },
    )

    processor.on_end(span_alice)
    processor.on_end(span_bob)

    alice_gen = _find_event(mock_transport, span_type="generation", span_name="alice-gen")
    bob_gen = _find_event(mock_transport, span_type="generation", span_name="bob-gen")

    assert alice_gen["metadata"]["user_id"] == "user-alice"
    assert alice_gen["metadata"]["tags"] == ["prod"]
    assert bob_gen["metadata"]["user_id"] == "user-bob"
    assert bob_gen["metadata"]["tags"] == ["staging"]
    assert alice_gen["trace_id"] != bob_gen["trace_id"]


# ── Tests: RAG pipeline ─────────────────────────────────────────


def test_rag_pipeline_trace_span_generation(mock_transport, enabled_config, processor):
    tid = 0xCCCC0003
    root_sid = 0xC000

    retrieval = _fake_span(
        name="document-retrieval",
        trace_id=tid, span_id=0xC001, parent_span_id=root_sid,
        start_time_ns=1_000_000_000,
        end_time_ns=1_050_000_000,
        attributes={
            "langfuse.observation.type": "span",
            "langfuse.observation.input": '"How does vector search work?"',
            "langfuse.observation.output": '["doc1","doc2"]',
            "user.id": "user-charlie",
        },
    )

    generation = _fake_span(
        name="rag-completion",
        trace_id=tid, span_id=0xC002, parent_span_id=root_sid,
        start_time_ns=1_050_000_000,
        end_time_ns=2_800_000_000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.model.name": "gpt-4o",
            "langfuse.observation.input": '[{"role":"user","content":"context + question"}]',
            "langfuse.observation.output": '{"role":"assistant","content":"Vector search works by..."}',
            "langfuse.observation.usage_details": '{"prompt_tokens":50,"completion_tokens":30}',
            "user.id": "user-charlie",
        },
    )

    processor.on_end(retrieval)
    processor.on_end(generation)

    # 1 trace (deduped) + retrieval span + generation = 3 events
    assert len(mock_transport) == 3

    trace_event = _find_event(mock_transport, span_type="trace")
    span_event = _find_event(mock_transport, span_type="span")
    gen_event = _find_event(mock_transport, span_type="generation")

    assert trace_event["metadata"]["user_id"] == "user-charlie"
    assert span_event["span_name"] == "document-retrieval"
    assert span_event["metrics"]["latency_ms"] == 50
    assert gen_event["span_name"] == "rag-completion"
    assert gen_event["request"]["model"] == "gpt-4o"
    assert gen_event["usage"]["prompt_tokens"] == 50
    assert gen_event["metrics"]["latency_ms"] == 1750

    # All share the same trace_id
    assert span_event["trace_id"] == gen_event["trace_id"]
    assert trace_event["trace_id"] == span_event["trace_id"]


# ── Tests: tags parsing ─────────────────────────────────────────


def test_scalar_tag_not_split_into_characters(mock_transport, enabled_config, processor):
    """A single string tag like "prod" should become ["prod"], not ["p","r","o","d"]."""
    span = _fake_span(
        name="gen", trace_id=0xF001, span_id=0xF002, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.trace.tags": "prod",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metadata"]["tags"] == ["prod"]


def test_list_tags_preserved(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xF003, span_id=0xF004, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.trace.tags": ["alpha", "beta"],
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metadata"]["tags"] == ["alpha", "beta"]


# ── Tests: compat key fallbacks ────────────────────────────────


def test_langfuse_user_id_fallback(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xF010, span_id=0xF011, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.user.id": "compat-user",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metadata"]["user_id"] == "compat-user"


def test_langfuse_session_id_fallback(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xF020, span_id=0xF021, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.session.id": "compat-session",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metadata"]["session_id"] == "compat-session"


# ── Tests: gen_ai fallbacks ─────────────────────────────────────


def test_gen_ai_model_fallback(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xF030, span_id=0xF031, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "gen_ai.request.model": "claude-3-opus",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["request"]["model"] == "claude-3-opus"


def test_gen_ai_usage_fallback(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xF040, span_id=0xF041, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "gen_ai.usage.prompt_tokens": 25,
            "gen_ai.usage.completion_tokens": 10,
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["usage"]["prompt_tokens"] == 25
    assert gen["usage"]["completion_tokens"] == 10
    assert gen["usage"]["total_tokens"] == 35


# ── Tests: custom metadata ─────────────────────────────────────


def test_custom_metadata_extraction(mock_transport, enabled_config, processor):
    span = _fake_span(
        name="gen", trace_id=0xF050, span_id=0xF051, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.metadata": '{"team":"ml","priority":1}',
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metadata"]["custom"]["team"] == "ml"
    assert gen["metadata"]["custom"]["priority"] == "1"


def test_custom_metadata_flattened_keys(mock_transport, enabled_config, processor):
    """Langfuse Python v3 flattens metadata into individual OTel attributes like
    langfuse.observation.metadata.{key} and langfuse.trace.metadata.{key}."""
    span = _fake_span(
        name="gen", trace_id=0xF060, span_id=0xF061, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.metadata.source": "pinecone",
            "langfuse.observation.metadata.top_k": 2,
            "langfuse.trace.metadata.pipeline": "v2",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metadata"]["custom"]["source"] == "pinecone"
    assert gen["metadata"]["custom"]["top_k"] == "2"
    assert gen["metadata"]["custom"]["pipeline"] == "v2"


def test_custom_metadata_flattened_merged_with_blob(mock_transport, enabled_config, processor):
    """Flattened keys merge with a JSON blob if both are present."""
    span = _fake_span(
        name="gen", trace_id=0xF070, span_id=0xF071, parent_span_id=0xF000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.metadata": '{"team":"ml"}',
            "langfuse.observation.metadata.source": "pinecone",
        },
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    assert gen["metadata"]["custom"]["team"] == "ml"
    assert gen["metadata"]["custom"]["source"] == "pinecone"


# ── Tests: wrap_langfuse no-arg ────────────────────────────────


def test_wrap_langfuse_no_arg():
    result = wrap_langfuse()
    assert result is None


# ── Tests: _process_span helper ──────────────────────────────────


def test_process_span_returns_null_for_root_without_obs_type():
    span = _fake_span(trace_id=0xDDDD, span_id=0xD000)
    result = _process_span(span)
    assert result is None


def test_process_span_returns_event_for_non_root():
    span = _fake_span(trace_id=0xEEEE, span_id=0xE001, parent_span_id=0xE000,
                       attributes={"langfuse.observation.type": "span"})
    result = _process_span(span)
    assert result is not None
    assert result["span_type"] == "span"


# ── Tests: trace re-emission on userId upgrade ────────────────


def test_trace_reemitted_when_later_span_has_user_id(mock_transport, enabled_config, processor):
    """When child span ends first without userId, and parent ends later with userId,
    the trace event should be re-emitted with the userId."""
    tid = 0xAA00BB01
    parent_sid = 0xAA000000

    # Child ends FIRST — no userId
    child = _fake_span(
        name="chat-completion",
        trace_id=tid, span_id=0xAA000001, parent_span_id=parent_sid,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.input": '[{"role":"user","content":"Hello"}]',
            "langfuse.observation.output": '{"role":"assistant","content":"Hi"}',
        },
    )
    processor.on_end(child)

    # Parent ends SECOND — has userId
    parent = _fake_span(
        name="user-request",
        trace_id=tid, span_id=parent_sid,
        attributes={
            "user.id": "dr-chen",
            "session.id": "session-chen-001",
        },
    )
    processor.on_end(parent)

    trace_events = _find_events(mock_transport, span_type="trace")
    assert len(trace_events) == 2  # initial (no userId) + re-emitted (with userId)

    # First trace: no userId
    assert trace_events[0].get("metadata", {}).get("user_id") is None

    # Re-emitted trace: has userId
    assert trace_events[1]["metadata"]["user_id"] == "dr-chen"
    assert trace_events[1]["metadata"]["session_id"] == "session-chen-001"

    # Same event_id — ClickHouse dedup will keep the later one
    assert trace_events[0]["event_id"] == trace_events[1]["event_id"]


def test_trace_not_reemitted_when_first_already_has_user_id(mock_transport, enabled_config, processor):
    """If the first span to end already has userId, no re-emission needed."""
    tid = 0xAA00BB02

    # Span A with userId
    span_a = _fake_span(
        name="gen-a",
        trace_id=tid, span_id=0xAA000010, parent_span_id=0xAA000000,
        attributes={
            "langfuse.observation.type": "generation",
            "user.id": "first-user",
        },
    )
    processor.on_end(span_a)

    # Span B with different userId — should NOT re-emit
    span_b = _fake_span(
        name="gen-b",
        trace_id=tid, span_id=0xAA000011, parent_span_id=0xAA000000,
        attributes={
            "langfuse.observation.type": "generation",
            "user.id": "second-user",
        },
    )
    processor.on_end(span_b)

    trace_events = _find_events(mock_transport, span_type="trace")
    assert len(trace_events) == 1  # no re-emit needed
    assert trace_events[0]["metadata"]["user_id"] == "first-user"


def test_trace_not_reemitted_when_both_lack_user_id(mock_transport, enabled_config, processor):
    """Two spans without userId — only one trace event emitted."""
    tid = 0xAA00BB03

    span_a = _fake_span(
        name="span-a",
        trace_id=tid, span_id=0xAA000020, parent_span_id=0xAA000000,
        attributes={"langfuse.observation.type": "span"},
    )
    span_b = _fake_span(
        name="span-b",
        trace_id=tid, span_id=0xAA000021, parent_span_id=0xAA000000,
        attributes={"langfuse.observation.type": "span"},
    )
    processor.on_end(span_a)
    processor.on_end(span_b)

    trace_events = _find_events(mock_transport, span_type="trace")
    assert len(trace_events) == 1


def test_rag_pipeline_reemits_trace_with_user_id(mock_transport, enabled_config, processor):
    """RAG pipeline: retrieval → generation → root wrapper. Root has userId.
    Trace should be re-emitted when root ends."""
    tid = 0xCC00DD01
    root_sid = 0xCC000000

    retrieval = _fake_span(
        name="document-retrieval",
        trace_id=tid, span_id=0xCC000001, parent_span_id=root_sid,
        start_time_ns=1_050_000_000,
        end_time_ns=1_100_000_000,
        attributes={
            "langfuse.observation.type": "span",
            "langfuse.observation.input": '"query"',
            "langfuse.observation.output": '["doc1"]',
        },
    )

    generation = _fake_span(
        name="rag-completion",
        trace_id=tid, span_id=0xCC000002, parent_span_id=root_sid,
        start_time_ns=1_100_000_000,
        end_time_ns=2_800_000_000,
        attributes={
            "langfuse.observation.type": "generation",
            "langfuse.observation.model.name": "gpt-4o",
        },
    )

    root = _fake_span(
        name="rag-pipeline",
        trace_id=tid, span_id=root_sid,
        start_time_ns=1_000_000_000,
        end_time_ns=2_900_000_000,
        attributes={
            "user.id": "user-charlie",
            "session.id": "session-charlie-001",
            "langfuse.trace.tags": ("rag", "production"),
        },
    )

    # Real end order
    processor.on_end(retrieval)
    processor.on_end(generation)
    processor.on_end(root)

    trace_events = _find_events(mock_transport, span_type="trace")
    assert len(trace_events) == 2  # initial + re-emitted with userId

    last_trace = trace_events[-1]
    assert last_trace["metadata"]["user_id"] == "user-charlie"
    assert last_trace["metadata"]["session_id"] == "session-charlie-001"
    assert last_trace["metadata"]["tags"] == ["rag", "production"]


# ── Tests: parent-child ID resolution with OTel child-first onEnd ────


def test_child_onend_before_root_resolves_parent_id_correctly(mock_transport, enabled_config, processor):
    """OTel fires onEnd child-first, so the generation's onEnd runs before the root's.
    Without onStart pre-registration, the child would get a random UUID for its parent_id
    that doesn't match the trace's event_id. The onStart fix registers the root span's
    OTel spanId → traceId mapping before any child needs it."""
    TRACE_ID = 0xAAAA_0000_0000_0000_0000_0000_0000_BBBB
    ROOT_SPAN_ID = 0x1111_0000_0000_0001
    CHILD_SPAN_ID = 0x2222_0000_0000_0002

    root = _fake_span(
        name="root-wrapper", trace_id=TRACE_ID, span_id=ROOT_SPAN_ID,
        attributes={"langfuse.observation.type": "span"},
    )
    child = _fake_span(
        name="llm-call", trace_id=TRACE_ID, span_id=CHILD_SPAN_ID,
        parent_span_id=ROOT_SPAN_ID,
        attributes={"langfuse.observation.type": "generation"},
    )

    # Simulate OTel ordering: onStart fires parent-first, onEnd fires child-first
    processor.on_start(root)
    processor.on_start(child)
    processor.on_end(child)   # child ends first
    processor.on_end(root)    # root ends last

    gen = _find_event(mock_transport, span_type="generation")
    trace = _find_event(mock_transport, span_type="trace")

    # The generation's parent_id must equal the trace's event_id
    assert gen["parent_id"] == trace["event_id"]
    assert gen["trace_id"] == trace["trace_id"]


def test_child_onend_before_root_without_onstart_would_mismatch(mock_transport, enabled_config, processor):
    """Verify that calling only onEnd (skipping onStart) would produce mismatched IDs,
    confirming the onStart pre-registration is actually necessary."""
    TRACE_ID = 0xCCCC_0000_0000_0000_0000_0000_0000_DDDD
    ROOT_SPAN_ID = 0x3333_0000_0000_0003
    CHILD_SPAN_ID = 0x4444_0000_0000_0004

    root = _fake_span(
        name="root-wrapper", trace_id=TRACE_ID, span_id=ROOT_SPAN_ID,
        attributes={"langfuse.observation.type": "span"},
    )
    child = _fake_span(
        name="llm-call", trace_id=TRACE_ID, span_id=CHILD_SPAN_ID,
        parent_span_id=ROOT_SPAN_ID,
        attributes={"langfuse.observation.type": "generation"},
    )

    # Deliberately skip onStart — only call onEnd in child-first order
    processor.on_end(child)   # child ends first — no pre-registration
    processor.on_end(root)    # root ends last

    gen = _find_event(mock_transport, span_type="generation")
    trace = _find_event(mock_transport, span_type="trace")

    # Without onStart, the child's parent_id will be a random UUID that does NOT
    # match the trace's event_id — this proves the fix is necessary
    assert gen["parent_id"] != trace["event_id"]


# ── Tests: non-Langfuse span filtering ────────────────────────────


def test_non_langfuse_span_silently_skipped(mock_transport, enabled_config, processor):
    """Spans without any Langfuse attributes (HTTP, DB, etc.) should be silently skipped."""
    span = _fake_span(
        name="http-request",
        trace_id=0xFF00000000000001,
        span_id=0xFF00000000000001,
        parent_span_id=0xFF00000000000000,
        attributes={
            "http.method": "GET",
            "http.url": "https://example.com",
            "http.status_code": 200,
        },
    )
    processor.on_start(span)
    processor.on_end(span)

    assert len(mock_transport) == 0


def test_span_with_only_user_id_still_processed(mock_transport, enabled_config, processor):
    """A span with only user.id present (no langfuse.* attrs) should still be processed."""
    span = _fake_span(
        name="langfuse-span-with-user",
        trace_id=0xFF00000000000002,
        span_id=0xFF00000000000002,
        parent_span_id=0xFF00000000000000,
        attributes={
            "user.id": "test-user",
        },
    )
    processor.on_start(span)
    processor.on_end(span)

    assert len(mock_transport) > 0
    span_events = [e for e in mock_transport if e["span_type"] == "span"]
    assert len(span_events) >= 1
    assert span_events[0]["metadata"]["user_id"] == "test-user"


# ── Tests: deterministic trace UUID from OTel trace_id ────────────


def test_trace_uuid_deterministic_from_32hex(mock_transport, enabled_config, processor):
    """Same 32-hex OTel trace_id should always produce the same deterministic UUID."""
    span = _fake_span(
        name="gen",
        trace_id=0xAAAABBBBCCCCDDDDEEEEFFFF11112222,
        span_id=0x1111000000000001,
        parent_span_id=0x1111000000000000,
        attributes={"langfuse.observation.type": "generation"},
    )
    processor.on_end(span)

    gen = _find_event(mock_transport, span_type="generation")
    # 32-hex: 0000aaaabbbbccccddddeeeeffff11112222 → but format pads to 32, so:
    # format(0xAAAABBBBCCCCDDDDEEEEFFFF11112222, "032x") = "aaaabbbbccccddddeeeeffff11112222"
    # UUID: "aaaabbbb-cccc-dddd-eeee-ffff11112222"
    assert gen["trace_id"] == "aaaabbbb-cccc-dddd-eeee-ffff11112222"


# ── Tests: mapping pressure ──────────────────────────────────────


def test_long_lived_root_survives_many_unrelated_spans(mock_transport, enabled_config, processor):
    """A long-lived root trace should maintain correct parent-child linkage
    even after many unrelated traces pass through."""
    root_trace_id = 0xABCD0000000000000000000000000001
    root_span_id = 0xABCD000000000000

    root = _fake_span(
        name="long-root",
        trace_id=root_trace_id,
        span_id=root_span_id,
        attributes={"langfuse.observation.type": "span"},
    )
    processor.on_start(root)

    # Push 100 unrelated traces through
    for i in range(100):
        unrelated = _fake_span(
            name=f"unrelated-{i}",
            trace_id=i + 0x10000,
            span_id=i + 0x20000,
            attributes={"langfuse.observation.type": "generation"},
        )
        processor.on_start(unrelated)
        processor.on_end(unrelated)

    # Now process child of the long-lived root
    child = _fake_span(
        name="delayed-child",
        trace_id=root_trace_id,
        span_id=0xABCD000000000001,
        parent_span_id=root_span_id,
        attributes={"langfuse.observation.type": "generation"},
    )
    processor.on_start(child)
    processor.on_end(child)
    processor.on_end(root)

    # Find the child event
    child_event = next(e for e in mock_transport if e.get("span_name") == "delayed-child")
    trace_event = next(
        e for e in mock_transport
        if e["span_type"] == "trace" and e["trace_id"] == child_event["trace_id"]
    )

    # Child's parent_id should equal root's span event ID (= trace UUID)
    assert child_event["parent_id"] == trace_event["event_id"]


def test_nested_3level_trace_correct_links(mock_transport, enabled_config, processor):
    """A 3-level nested trace should have correct parent-child links."""
    trace_id = 0xDEED0000000000000000000000000001

    root = _fake_span(
        name="root",
        trace_id=trace_id,
        span_id=0xDEED000000000000,
        attributes={"langfuse.observation.type": "span", "user.id": "pressure-user"},
    )
    mid = _fake_span(
        name="middleware",
        trace_id=trace_id,
        span_id=0xDEED000000000001,
        parent_span_id=0xDEED000000000000,
        attributes={"langfuse.observation.type": "span"},
    )
    leaf = _fake_span(
        name="llm-call",
        trace_id=trace_id,
        span_id=0xDEED000000000002,
        parent_span_id=0xDEED000000000001,
        attributes={"langfuse.observation.type": "generation"},
    )

    # Full lifecycle
    processor.on_start(root)
    processor.on_start(mid)
    processor.on_start(leaf)
    processor.on_end(leaf)
    processor.on_end(mid)
    processor.on_end(root)

    leaf_event = next(e for e in mock_transport if e.get("span_name") == "llm-call")
    mid_event = next(e for e in mock_transport if e.get("span_name") == "middleware")
    trace_event = _find_event(mock_transport, span_type="trace")

    # All share the same deterministic trace_id
    expected_trace_id = "deed0000-0000-0000-0000-000000000001"
    assert leaf_event["trace_id"] == expected_trace_id
    assert mid_event["trace_id"] == expected_trace_id
    assert trace_event["trace_id"] == expected_trace_id

    # Parent chain: leaf → mid → root (trace)
    assert leaf_event["parent_id"] == mid_event["event_id"]
    assert mid_event["parent_id"] == trace_event["event_id"]


# ── Tests: forceFlush and shutdown ────────────────────────────────


def test_force_flush_calls_transport_flush(processor):
    """force_flush() should call flush_transport()."""
    with patch("llm_observatory.langfuse_patch.flush_transport") as mock_flush:
        processor.force_flush()
        mock_flush.assert_called_once()


def test_force_flush_does_not_raise_on_error(processor):
    """force_flush() should not raise even if flush fails."""
    with patch("llm_observatory.langfuse_patch.flush_transport", side_effect=RuntimeError("boom")):
        # Should not raise
        result = processor.force_flush()
        assert result is True


def test_shutdown_calls_flush_and_shutdown(processor):
    """shutdown() should call flush_transport() then shutdown_transport()."""
    with patch("llm_observatory.langfuse_patch.flush_transport") as mock_flush, \
         patch("llm_observatory.langfuse_patch.shutdown_transport") as mock_shutdown:
        processor.shutdown()
        mock_flush.assert_called_once()
        mock_shutdown.assert_called_once()


def test_shutdown_does_not_raise_on_error(processor):
    """shutdown() should not raise even if transport operations fail."""
    with patch("llm_observatory.langfuse_patch.flush_transport", side_effect=RuntimeError("flush boom")):
        # Should not raise
        processor.shutdown()
