"""Tests for kernel tracing protocol — stdlib only (ADR-007)."""

from __future__ import annotations

from typing import Any

import pytest

from orxaq.kernel.tracing import (
    ConsoleTracer,
    NullTracer,
    SpanContext,
    TracingBackend,
    get_tracer,
    set_tracer,
    trace_span,
    traced,
)

# ---------------------------------------------------------------------------
# SpanContext
# ---------------------------------------------------------------------------


class TestSpanContext:
    """Tests for SpanContext dataclass."""

    def test_creation_defaults(self) -> None:
        span = SpanContext(name="test-span")
        assert span.name == "test-span"
        assert span.start_time > 0
        assert span.end_time == 0.0
        assert span.attributes == {}
        assert span.status == "ok"
        assert span.error == ""

    def test_creation_with_attributes(self) -> None:
        attrs = {"key": "value", "count": 42}
        span = SpanContext(name="attr-span", attributes=attrs)
        assert span.attributes == {"key": "value", "count": 42}

    def test_duration_ms_zero_when_not_ended(self) -> None:
        span = SpanContext(name="open-span")
        assert span.duration_ms == 0.0

    def test_duration_ms_calculated(self) -> None:
        span = SpanContext(name="timed-span", start_time=1000.0, end_time=1000.05)
        assert span.duration_ms == pytest.approx(50.0, abs=0.01)

    def test_duration_ms_zero_for_negative_end(self) -> None:
        span = SpanContext(name="neg-span", start_time=1000.0, end_time=-1.0)
        assert span.duration_ms == 0.0


# ---------------------------------------------------------------------------
# NullTracer
# ---------------------------------------------------------------------------


class TestNullTracer:
    """Tests for NullTracer no-op implementation."""

    def test_start_span_returns_span_context(self) -> None:
        tracer = NullTracer()
        span = tracer.start_span("null-op")
        assert isinstance(span, SpanContext)
        assert span.name == "null-op"

    def test_start_span_with_attributes(self) -> None:
        tracer = NullTracer()
        span = tracer.start_span("null-op", attributes={"a": 1})
        assert span.attributes == {"a": 1}

    def test_end_span_sets_end_time(self) -> None:
        tracer = NullTracer()
        span = tracer.start_span("ending")
        assert span.end_time == 0.0
        tracer.end_span(span)
        assert span.end_time > 0

    def test_record_exception_sets_error_state(self) -> None:
        tracer = NullTracer()
        span = tracer.start_span("failing")
        tracer.record_exception(span, ValueError("boom"))
        assert span.status == "error"
        assert span.error == "boom"


# ---------------------------------------------------------------------------
# ConsoleTracer
# ---------------------------------------------------------------------------


class TestConsoleTracer:
    """Tests for ConsoleTracer development implementation."""

    def test_start_span_returns_span(self) -> None:
        tracer = ConsoleTracer()
        span = tracer.start_span("console-op")
        assert isinstance(span, SpanContext)
        assert span.name == "console-op"

    def test_end_span_records_span(self) -> None:
        tracer = ConsoleTracer()
        span = tracer.start_span("recorded")
        tracer.end_span(span)
        assert len(tracer.spans) == 1
        assert tracer.spans[0].name == "recorded"
        assert tracer.spans[0].end_time > 0

    def test_record_exception_records_span(self) -> None:
        tracer = ConsoleTracer()
        span = tracer.start_span("error-span")
        tracer.record_exception(span, RuntimeError("fail"))
        assert len(tracer.spans) == 1
        assert tracer.spans[0].status == "error"
        assert tracer.spans[0].error == "fail"
        assert tracer.spans[0].end_time > 0

    def test_spans_returns_copy(self) -> None:
        tracer = ConsoleTracer()
        span = tracer.start_span("copy-test")
        tracer.end_span(span)
        spans_a = tracer.spans
        spans_b = tracer.spans
        assert spans_a == spans_b
        assert spans_a is not spans_b

    def test_multiple_spans_collected(self) -> None:
        tracer = ConsoleTracer()
        for i in range(5):
            span = tracer.start_span(f"span-{i}")
            tracer.end_span(span)
        assert len(tracer.spans) == 5


# ---------------------------------------------------------------------------
# TracingBackend protocol
# ---------------------------------------------------------------------------


class TestTracingBackendProtocol:
    """Tests for TracingBackend protocol compliance."""

    def test_null_tracer_is_tracing_backend(self) -> None:
        assert isinstance(NullTracer(), TracingBackend)

    def test_console_tracer_is_tracing_backend(self) -> None:
        assert isinstance(ConsoleTracer(), TracingBackend)

    def test_custom_implementation_matches_protocol(self) -> None:
        class CustomTracer:
            def start_span(
                self, name: str, attributes: dict[str, Any] | None = None
            ) -> SpanContext:
                return SpanContext(name=name)

            def end_span(self, span: SpanContext) -> None:
                pass

            def record_exception(self, span: SpanContext, error: Exception) -> None:
                pass

        assert isinstance(CustomTracer(), TracingBackend)


# ---------------------------------------------------------------------------
# Global tracer registry
# ---------------------------------------------------------------------------


class TestGlobalRegistry:
    """Tests for set_tracer / get_tracer globals."""

    def setup_method(self) -> None:
        """Reset to NullTracer before each test."""
        set_tracer(NullTracer())

    def test_default_tracer_is_null(self) -> None:
        tracer = get_tracer()
        assert isinstance(tracer, NullTracer)

    def test_set_and_get_tracer(self) -> None:
        console = ConsoleTracer()
        set_tracer(console)
        assert get_tracer() is console

    def test_replace_tracer(self) -> None:
        first = ConsoleTracer()
        second = ConsoleTracer()
        set_tracer(first)
        set_tracer(second)
        assert get_tracer() is second

    def teardown_method(self) -> None:
        """Restore NullTracer after each test."""
        set_tracer(NullTracer())


# ---------------------------------------------------------------------------
# trace_span context manager
# ---------------------------------------------------------------------------


class TestTraceSpan:
    """Tests for trace_span context manager."""

    def setup_method(self) -> None:
        self.tracer = ConsoleTracer()
        set_tracer(self.tracer)

    def test_normal_span(self) -> None:
        with trace_span("ctx-normal") as span:
            assert span.name == "ctx-normal"
        assert len(self.tracer.spans) == 1
        assert self.tracer.spans[0].status == "ok"
        assert self.tracer.spans[0].end_time > 0

    def test_span_with_attributes(self) -> None:
        with trace_span("ctx-attrs", region="us-east", retries=3) as span:
            assert span.attributes == {"region": "us-east", "retries": 3}

    def test_span_on_exception(self) -> None:
        with pytest.raises(ValueError, match="test-error"):
            with trace_span("ctx-error") as _span:
                raise ValueError("test-error")
        assert len(self.tracer.spans) == 1
        assert self.tracer.spans[0].status == "error"
        assert self.tracer.spans[0].error == "test-error"

    def test_exception_propagates(self) -> None:
        with pytest.raises(RuntimeError):
            with trace_span("propagate"):
                raise RuntimeError("must propagate")

    def teardown_method(self) -> None:
        set_tracer(NullTracer())


# ---------------------------------------------------------------------------
# traced decorator
# ---------------------------------------------------------------------------


class TestTracedDecorator:
    """Tests for @traced decorator."""

    def setup_method(self) -> None:
        self.tracer = ConsoleTracer()
        set_tracer(self.tracer)

    def test_traced_function_returns_result(self) -> None:
        @traced()
        def add(a: int, b: int) -> int:
            return a + b

        assert add(2, 3) == 5

    def test_traced_records_span(self) -> None:
        @traced()
        def noop() -> None:
            pass

        noop()
        assert len(self.tracer.spans) == 1
        assert "noop" in self.tracer.spans[0].name

    def test_traced_custom_name(self) -> None:
        @traced(name="custom.op")
        def my_func() -> None:
            pass

        my_func()
        assert self.tracer.spans[0].name == "custom.op"

    def test_traced_on_exception(self) -> None:
        @traced(name="failing.op")
        def fail() -> None:
            msg = "decorated fail"
            raise ValueError(msg)

        with pytest.raises(ValueError, match="decorated fail"):
            fail()
        assert len(self.tracer.spans) == 1
        assert self.tracer.spans[0].status == "error"

    def test_traced_preserves_function_name(self) -> None:
        @traced()
        def original_name() -> None:
            """Original docstring."""

        assert original_name.__name__ == "original_name"
        assert original_name.__doc__ == "Original docstring."

    def teardown_method(self) -> None:
        set_tracer(NullTracer())


# ---------------------------------------------------------------------------
# Multiple tracers in sequence
# ---------------------------------------------------------------------------


class TestMultipleTracers:
    """Tests for switching tracers mid-session."""

    def setup_method(self) -> None:
        set_tracer(NullTracer())

    def test_switch_tracer_mid_session(self) -> None:
        tracer_a = ConsoleTracer()
        tracer_b = ConsoleTracer()

        set_tracer(tracer_a)
        with trace_span("span-a"):
            pass

        set_tracer(tracer_b)
        with trace_span("span-b"):
            pass

        assert len(tracer_a.spans) == 1
        assert tracer_a.spans[0].name == "span-a"
        assert len(tracer_b.spans) == 1
        assert tracer_b.spans[0].name == "span-b"

    def test_null_tracer_absorbs_spans_silently(self) -> None:
        set_tracer(NullTracer())
        with trace_span("absorbed") as span:
            span.attributes["key"] = "value"
        # NullTracer does not collect, just verify no error
        assert span.status == "ok"

    def teardown_method(self) -> None:
        set_tracer(NullTracer())
