"""
Tests for context propagation.

Tests cover:
- Context managers (session, agent, phase)
- Context accessors
- ThreadPoolExecutor patching
- asyncio.create_task patching
- Span registry for async generators
- W3C Trace Context headers
- TraceContext serialization
"""

import asyncio
import threading
import pytest
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from unittest.mock import MagicMock

from risicare_core import (
    # Context managers
    session_context,
    async_session_context,
    agent_context,
    async_agent_context,
    phase_context,
    restore_trace_context,
    # Accessors
    get_current_session,
    get_current_agent,
    get_current_span,
    get_current_phase,
    get_current_context,
    get_trace_context,
    get_current_parent_span_id,
    # Span registry
    register_span,
    get_span_by_id,
    unregister_span,
    # Streaming utilities
    traced_stream,
    traced_stream_sync,
    # W3C headers
    inject_trace_context,
    extract_trace_context,
    # Patching
    patch_executors,
    unpatch_executors,
    is_executors_patched,
    patch_process_executors,
    unpatch_process_executors,
    is_process_patched,
    patch_asyncio,
    unpatch_asyncio,
    is_asyncio_patched,
    patch_all,
    unpatch_all,
    # Types
    SessionContext,
    AgentContext,
    TraceContext,
    SemanticPhase,
    Span,
    # Internal
    set_current_span,
    reset_current_span,
    _reset_context_for_testing,
)
from risicare_core.context import _is_valid_hex_id


# =============================================================================
# Fixtures
# =============================================================================

@pytest.fixture(autouse=True)
def reset_context():
    """Reset all context state before and after each test."""
    _reset_context_for_testing()
    yield
    _reset_context_for_testing()


@pytest.fixture
def sample_span():
    """Create a sample span for testing."""
    return Span(
        trace_id="a" * 32,
        span_id="b" * 16,
        name="test_span",
    )


# =============================================================================
# Session Context Tests
# =============================================================================

class TestSessionContext:
    def test_session_context_sets_and_resets(self):
        """Session context sets current session and resets on exit."""
        assert get_current_session() is None

        with session_context("sess-123", user_id="user-456") as ctx:
            session = get_current_session()
            assert session is not None
            assert session.session_id == "sess-123"
            assert session.user_id == "user-456"
            assert ctx is session

        assert get_current_session() is None

    def test_session_context_with_metadata(self):
        """Session context accepts metadata."""
        with session_context("sess-123", metadata={"key": "value"}) as ctx:
            assert ctx.metadata == {"key": "value"}

    def test_session_context_nesting(self):
        """Nested session contexts work correctly."""
        with session_context("outer") as outer:
            assert get_current_session().session_id == "outer"

            with session_context("inner") as inner:
                assert get_current_session().session_id == "inner"

            assert get_current_session().session_id == "outer"

        assert get_current_session() is None

    @pytest.mark.asyncio
    async def test_async_session_context(self):
        """Async session context works correctly."""
        assert get_current_session() is None

        async with async_session_context("async-sess") as ctx:
            session = get_current_session()
            assert session.session_id == "async-sess"

        assert get_current_session() is None


# =============================================================================
# Agent Context Tests
# =============================================================================

class TestAgentContext:
    def test_agent_context_sets_and_resets(self):
        """Agent context sets current agent and resets on exit."""
        assert get_current_agent() is None

        with agent_context("agent-1", agent_role="worker") as ctx:
            agent = get_current_agent()
            assert agent is not None
            assert agent.agent_id == "agent-1"
            assert agent.agent_role == "worker"
            assert ctx is agent

        assert get_current_agent() is None

    def test_agent_context_tracks_parent(self):
        """Nested agent contexts track parent agent."""
        with agent_context("parent-agent") as parent:
            assert parent.parent_agent_id is None

            with agent_context("child-agent") as child:
                assert child.parent_agent_id == "parent-agent"

                with agent_context("grandchild-agent") as grandchild:
                    assert grandchild.parent_agent_id == "child-agent"

    def test_agent_context_name_defaults_to_id(self):
        """Agent name defaults to agent_id if not provided."""
        with agent_context("my-agent") as ctx:
            assert ctx.agent_name == "my-agent"

        with agent_context("my-agent", agent_name="Custom Name") as ctx:
            assert ctx.agent_name == "Custom Name"

    @pytest.mark.asyncio
    async def test_async_agent_context(self):
        """Async agent context works correctly."""
        async with async_agent_context("async-agent", agent_type="custom") as ctx:
            agent = get_current_agent()
            assert agent.agent_id == "async-agent"
            assert agent.agent_type == "custom"


# =============================================================================
# Phase Context Tests
# =============================================================================

class TestPhaseContext:
    def test_phase_context_sets_and_resets(self):
        """Phase context sets current phase and resets on exit."""
        assert get_current_phase() is None

        with phase_context(SemanticPhase.THINK):
            assert get_current_phase() == SemanticPhase.THINK

        assert get_current_phase() is None

    def test_phase_context_all_phases(self):
        """All semantic phases work correctly."""
        for phase in SemanticPhase:
            with phase_context(phase):
                assert get_current_phase() == phase


# =============================================================================
# Context Accessor Tests
# =============================================================================

class TestContextAccessors:
    def test_get_current_context_empty(self):
        """get_current_context returns None values when no context set."""
        ctx = get_current_context()
        assert ctx["session"] is None
        assert ctx["agent"] is None
        assert ctx["span"] is None
        assert ctx["phase"] is None

    def test_get_current_context_with_all(self, sample_span):
        """get_current_context returns all context values."""
        with session_context("sess-1", user_id="user-1"):
            with agent_context("agent-1", agent_name="Agent", agent_role="worker"):
                with phase_context(SemanticPhase.ACT):
                    token = set_current_span(sample_span)
                    try:
                        ctx = get_current_context()

                        assert ctx["session"]["session_id"] == "sess-1"
                        assert ctx["session"]["user_id"] == "user-1"
                        assert ctx["agent"]["agent_id"] == "agent-1"
                        assert ctx["agent"]["agent_name"] == "Agent"
                        assert ctx["span"]["span_id"] == "b" * 16
                        assert ctx["phase"] == "act"
                    finally:
                        reset_current_span(token)


# =============================================================================
# ThreadPoolExecutor Patching Tests
# =============================================================================

class TestExecutorPatching:
    def test_patch_unpatch_cycle(self):
        """Patching and unpatching executors works correctly."""
        assert not is_executors_patched()

        patch_executors()
        assert is_executors_patched()

        unpatch_executors()
        assert not is_executors_patched()

    def test_patch_is_idempotent(self):
        """Multiple patch calls are safe."""
        patch_executors()
        patch_executors()
        assert is_executors_patched()

        unpatch_executors()
        assert not is_executors_patched()

    def test_context_propagates_to_thread(self):
        """Context propagates to ThreadPoolExecutor workers when patched."""
        patch_executors()

        def get_session_in_thread():
            session = get_current_session()
            return session.session_id if session else None

        with session_context("thread-test-session"):
            with ThreadPoolExecutor(max_workers=1) as executor:
                future = executor.submit(get_session_in_thread)
                result = future.result()

        assert result == "thread-test-session"
        unpatch_executors()

    def test_context_not_propagated_without_patch(self):
        """Context does not propagate without patching."""
        unpatch_executors()

        def get_session_in_thread():
            session = get_current_session()
            return session.session_id if session else None

        with session_context("test-session"):
            with ThreadPoolExecutor(max_workers=1) as executor:
                future = executor.submit(get_session_in_thread)
                result = future.result()

        assert result is None  # Context didn't propagate


# =============================================================================
# asyncio Patching Tests
# =============================================================================

class TestAsyncioPatching:
    def test_patch_unpatch_cycle(self):
        """Patching and unpatching asyncio works correctly."""
        assert not is_asyncio_patched()

        patch_asyncio()
        assert is_asyncio_patched()

        unpatch_asyncio()
        assert not is_asyncio_patched()

    @pytest.mark.asyncio
    async def test_context_propagates_to_task(self):
        """Context propagates to asyncio tasks."""
        # Note: Python 3.7+ already copies contextvars automatically
        # This test verifies the behavior works regardless of patching
        patch_asyncio()

        async def get_session_in_task():
            session = get_current_session()
            return session.session_id if session else None

        async with async_session_context("task-test-session"):
            task = asyncio.create_task(get_session_in_task())
            result = await task

        assert result == "task-test-session"
        unpatch_asyncio()


# =============================================================================
# Span Registry Tests
# =============================================================================

class TestSpanRegistry:
    def test_register_and_get_span(self, sample_span):
        """Span can be registered and retrieved by ID."""
        register_span(sample_span)

        retrieved = get_span_by_id(sample_span.span_id)
        assert retrieved is sample_span

        unregister_span(sample_span.span_id)

    def test_get_nonexistent_span_returns_none(self):
        """Getting non-existent span returns None."""
        result = get_span_by_id("nonexistent")
        assert result is None

    def test_unregister_span(self, sample_span):
        """Span is removed after unregister."""
        register_span(sample_span)
        assert get_span_by_id(sample_span.span_id) is sample_span

        unregister_span(sample_span.span_id)
        assert get_span_by_id(sample_span.span_id) is None

    def test_unregister_nonexistent_is_safe(self):
        """Unregistering non-existent span doesn't raise."""
        unregister_span("nonexistent")  # Should not raise


# =============================================================================
# Streaming Utilities Tests
# =============================================================================

class TestStreamingUtilities:
    @pytest.mark.asyncio
    async def test_traced_stream_async(self, sample_span):
        """traced_stream wraps async iterator and tracks chunks."""
        register_span(sample_span)

        async def async_gen():
            for i in range(3):
                yield f"chunk-{i}"

        chunks = []
        async for chunk in traced_stream(sample_span.span_id, async_gen()):
            chunks.append(chunk)

        assert chunks == ["chunk-0", "chunk-1", "chunk-2"]
        assert sample_span.attributes.get("stream.total_chunks") == 3

        unregister_span(sample_span.span_id)

    def test_traced_stream_sync(self, sample_span):
        """traced_stream_sync wraps sync iterator and tracks chunks."""
        register_span(sample_span)

        def sync_gen():
            for i in range(3):
                yield f"chunk-{i}"

        chunks = list(traced_stream_sync(sample_span.span_id, sync_gen()))

        assert chunks == ["chunk-0", "chunk-1", "chunk-2"]
        assert sample_span.attributes.get("stream.total_chunks") == 3

        unregister_span(sample_span.span_id)


# =============================================================================
# W3C Trace Context Tests
# =============================================================================

class TestW3CTraceContext:
    def test_inject_trace_context(self, sample_span):
        """inject_trace_context adds traceparent and tracestate headers."""
        token = set_current_span(sample_span)

        with session_context("sess-abc"):
            with agent_context("agent-xyz"):
                headers = inject_trace_context({})

        reset_current_span(token)

        assert "traceparent" in headers
        assert headers["traceparent"] == f"00-{'a' * 32}-{'b' * 16}-01"

        assert "tracestate" in headers
        assert "risicare=" in headers["tracestate"]
        assert "session_id=sess-abc" in headers["tracestate"]
        assert "agent_id=agent-xyz" in headers["tracestate"]

    def test_inject_without_context(self):
        """inject_trace_context works with no active context."""
        headers = inject_trace_context({"existing": "header"})

        assert headers["existing"] == "header"
        assert "traceparent" not in headers

    def test_extract_trace_context(self):
        """extract_trace_context parses W3C headers."""
        headers = {
            "traceparent": "00-" + "a" * 32 + "-" + "b" * 16 + "-01",
            "tracestate": "risicare=session_id=sess-123;agent_id=agent-456",
        }

        ctx = extract_trace_context(headers)

        assert ctx["trace_id"] == "a" * 32
        assert ctx["parent_span_id"] == "b" * 16
        assert ctx["session_id"] == "sess-123"
        assert ctx["agent_id"] == "agent-456"

    def test_extract_handles_other_vendors(self):
        """extract_trace_context handles other vendor states."""
        headers = {
            "traceparent": "00-" + "a" * 32 + "-" + "b" * 16 + "-01",
            "tracestate": "other=foo,risicare=session_id=sess-123,another=bar",
        }

        ctx = extract_trace_context(headers)

        assert ctx["session_id"] == "sess-123"

    def test_extract_empty_headers(self):
        """extract_trace_context handles empty headers."""
        ctx = extract_trace_context({})

        assert "trace_id" not in ctx
        assert "session_id" not in ctx


# =============================================================================
# TraceContext Serialization Tests
# =============================================================================

class TestTraceContextSerialization:
    def test_get_trace_context_with_span(self, sample_span):
        """get_trace_context returns current context."""
        token = set_current_span(sample_span)

        with session_context("sess-1"):
            with agent_context("agent-1"):
                ctx = get_trace_context()

        reset_current_span(token)

        assert ctx.trace_id == sample_span.trace_id
        assert ctx.span_id == sample_span.span_id
        assert ctx.session_id == "sess-1"
        assert ctx.agent_id == "agent-1"

    def test_get_trace_context_without_span(self):
        """get_trace_context generates IDs if no span."""
        ctx = get_trace_context()

        assert len(ctx.trace_id) == 32
        assert len(ctx.span_id) == 16
        assert ctx.session_id is None
        assert ctx.agent_id is None

    def test_trace_context_to_dict(self):
        """TraceContext serializes to dict."""
        ctx = TraceContext(
            trace_id="a" * 32,
            span_id="b" * 16,
            session_id="sess-1",
            agent_id="agent-1",
        )

        d = ctx.to_dict()

        assert d["trace_id"] == "a" * 32
        assert d["span_id"] == "b" * 16
        assert d["session_id"] == "sess-1"
        assert d["agent_id"] == "agent-1"

    def test_trace_context_from_dict(self):
        """TraceContext deserializes from dict."""
        d = {
            "trace_id": "a" * 32,
            "span_id": "b" * 16,
            "session_id": "sess-1",
        }

        ctx = TraceContext.from_dict(d)

        assert ctx.trace_id == "a" * 32
        assert ctx.span_id == "b" * 16
        assert ctx.session_id == "sess-1"
        assert ctx.agent_id is None

    def test_restore_trace_context(self):
        """restore_trace_context restores session and agent."""
        ctx = TraceContext(
            trace_id="a" * 32,
            span_id="b" * 16,
            session_id="restored-session",
            agent_id="restored-agent",
        )

        with restore_trace_context(ctx) as span:
            session = get_current_session()
            agent = get_current_agent()
            current_span = get_current_span()

            assert session.session_id == "restored-session"
            assert agent.agent_id == "restored-agent"
            assert current_span is span
            assert span.trace_id == "a" * 32
            assert span.parent_span_id == "b" * 16


# =============================================================================
# patch_all / unpatch_all Tests
# =============================================================================

class TestPatchAll:
    def test_patch_all_patches_all(self):
        """patch_all patches executors, process executors, and asyncio."""
        unpatch_all()
        assert not is_executors_patched()
        assert not is_process_patched()
        assert not is_asyncio_patched()

        patch_all()
        assert is_executors_patched()
        assert is_process_patched()
        assert is_asyncio_patched()

        unpatch_all()
        assert not is_executors_patched()
        assert not is_process_patched()
        assert not is_asyncio_patched()


# =============================================================================
# Gap 1: Concurrent Patch/Unpatch Thread Safety Tests
# =============================================================================

class TestPatchThreadSafety:
    """Thread safety for patch/unpatch operations (Gap 1)."""

    def test_concurrent_patch_unpatch_no_crash(self):
        """10 threads alternating patch/unpatch should not crash."""
        unpatch_all()
        errors = []

        def worker(i):
            try:
                for _ in range(50):
                    if i % 2 == 0:
                        patch_executors()
                    else:
                        unpatch_executors()
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert errors == [], f"Concurrent patch/unpatch raised: {errors}"
        # Clean up to consistent state
        unpatch_all()

    def test_patch_idempotent_under_concurrency(self):
        """10 threads all calling patch_executors() — patched exactly once."""
        unpatch_all()
        assert not is_executors_patched()

        barrier = threading.Barrier(10)

        def worker():
            barrier.wait()
            patch_executors()

        threads = [threading.Thread(target=worker) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert is_executors_patched()
        # Verify unpatching works cleanly after concurrent patching
        unpatch_executors()
        assert not is_executors_patched()


# =============================================================================
# Gap 2: W3C Validation Tests
# =============================================================================

class TestW3CValidation:
    """W3C extract_trace_context validation (Gap 2)."""

    def test_extract_rejects_invalid_trace_id(self):
        """Non-hex trace_id should not be included in result."""
        headers = {
            "traceparent": "00-ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ-" + "b" * 16 + "-01",
        }
        ctx = extract_trace_context(headers)
        assert "trace_id" not in ctx
        # version and flags still parsed
        assert ctx.get("version") == "00"
        assert ctx.get("flags") == "01"

    def test_extract_rejects_short_trace_id(self):
        """Too-short trace_id should not be included in result."""
        headers = {
            "traceparent": "00-abcd-" + "b" * 16 + "-01",
        }
        ctx = extract_trace_context(headers)
        assert "trace_id" not in ctx

    def test_extract_rejects_invalid_span_id(self):
        """Non-hex span_id should not be included in result."""
        headers = {
            "traceparent": "00-" + "a" * 32 + "-GGGGGGGGGGGGGGGG-01",
        }
        ctx = extract_trace_context(headers)
        assert "trace_id" in ctx  # valid trace_id still parsed
        assert "parent_span_id" not in ctx

    def test_extract_normalizes_uppercase(self):
        """Uppercase hex should be normalized to lowercase."""
        headers = {
            "traceparent": "00-" + "A" * 32 + "-" + "B" * 16 + "-01",
        }
        ctx = extract_trace_context(headers)
        assert ctx["trace_id"] == "a" * 32
        assert ctx["parent_span_id"] == "b" * 16

    def test_extract_filters_empty_tracestate_kv(self):
        """Malformed tracestate with empty values should be filtered."""
        headers = {
            "traceparent": "00-" + "a" * 32 + "-" + "b" * 16 + "-01",
            "tracestate": "risicare=session_id=sess-123;=;agent_id=",
        }
        ctx = extract_trace_context(headers)
        assert ctx["session_id"] == "sess-123"
        # Empty key "=" should be filtered
        assert "" not in ctx
        # "agent_id=" has empty value — should be filtered
        assert "agent_id" not in ctx


# =============================================================================
# Gap 3: ProcessPoolExecutor Patching Tests
# =============================================================================

class TestProcessPoolPatching:
    """ProcessPoolExecutor context propagation (Gap 3)."""

    def test_process_patch_unpatch_cycle(self):
        """Patching and unpatching process executors works correctly."""
        assert not is_process_patched()

        patch_process_executors()
        assert is_process_patched()

        unpatch_process_executors()
        assert not is_process_patched()

    def test_process_patch_is_idempotent(self):
        """Multiple patch calls are safe."""
        patch_process_executors()
        patch_process_executors()
        assert is_process_patched()

        unpatch_process_executors()
        assert not is_process_patched()

    def test_process_context_propagation(self):
        """Context propagates to ProcessPoolExecutor workers when patched."""
        patch_process_executors()

        # Set a current span so the trace_id is deterministic
        # (without a span, get_trace_context() generates new IDs each call)
        parent_span = Span(
            trace_id="a" * 32,
            span_id="b" * 16,
            name="parent",
        )
        token = set_current_span(parent_span)

        with session_context("process-test-session"):
            with ProcessPoolExecutor(max_workers=1) as executor:
                future = executor.submit(_get_trace_in_process)
                result = future.result(timeout=10)

        reset_current_span(token)

        assert result is not None
        assert result["trace_id"] == "a" * 32
        assert result["session_id"] == "process-test-session"
        assert result["has_span"] is True
        unpatch_process_executors()

    def test_process_no_propagation_without_patch(self):
        """Context does not propagate without patching."""
        unpatch_process_executors()

        with session_context("test-session"):
            with ProcessPoolExecutor(max_workers=1) as executor:
                future = executor.submit(_get_trace_in_process)
                result = future.result(timeout=10)

        # Without patching, session is None in worker
        assert result["session_id"] is None


# =============================================================================
# Gap 4: Session Fragmentation Tests
# =============================================================================

class TestSessionFragmentation:
    """Session context propagation to threads (Gap 4 coverage)."""

    def test_thread_worker_sees_parent_session(self):
        """Worker in patched ThreadPoolExecutor sees parent session."""
        patch_executors()

        def get_session_in_thread():
            s = get_current_session()
            return s.session_id if s else None

        with session_context("parent-session"):
            with ThreadPoolExecutor(max_workers=1) as executor:
                future = executor.submit(get_session_in_thread)
                result = future.result()

        assert result == "parent-session"
        unpatch_executors()

    def test_different_sessions_dont_leak(self):
        """Two threads with different sessions don't cross-contaminate."""
        patch_executors()
        results = {}
        barrier = threading.Barrier(2)

        def worker(session_id, key):
            with session_context(session_id):
                barrier.wait()  # Synchronize to overlap sessions
                s = get_current_session()
                results[key] = s.session_id if s else None

        t1 = threading.Thread(target=worker, args=("session-A", "t1"))
        t2 = threading.Thread(target=worker, args=("session-B", "t2"))
        t1.start()
        t2.start()
        t1.join()
        t2.join()

        assert results["t1"] == "session-A"
        assert results["t2"] == "session-B"
        unpatch_executors()


# =============================================================================
# Gap 4: Context Detach Error Cases
# =============================================================================

class TestContextDetachErrors:
    """Context preservation during exceptions (Gap 4 coverage)."""

    def test_session_preserved_after_exception(self):
        """Session context is cleaned up correctly after exception."""
        with pytest.raises(ValueError):
            with session_context("error-session"):
                assert get_current_session().session_id == "error-session"
                raise ValueError("deliberate error")

        # After the exception, session should be cleaned up
        assert get_current_session() is None

    def test_nested_session_restore_on_inner_exception(self):
        """Inner session exception correctly restores outer session."""
        with session_context("outer-session"):
            assert get_current_session().session_id == "outer-session"

            with pytest.raises(RuntimeError):
                with session_context("inner-session"):
                    assert get_current_session().session_id == "inner-session"
                    raise RuntimeError("inner failure")

            # Outer session should be restored
            assert get_current_session().session_id == "outer-session"

        assert get_current_session() is None

    def test_agent_context_preserved_after_exception(self):
        """Agent context is cleaned up correctly after exception."""
        with session_context("sess"):
            with pytest.raises(ValueError):
                with agent_context("error-agent"):
                    assert get_current_agent().agent_id == "error-agent"
                    raise ValueError("deliberate error")

            # After exception, agent should be cleaned up
            assert get_current_agent() is None
            # Session should still be active
            assert get_current_session().session_id == "sess"

    def test_nested_agent_restore_on_inner_exception(self):
        """Inner agent exception correctly restores outer agent."""
        with session_context("sess"):
            with agent_context("outer-agent"):
                assert get_current_agent().agent_id == "outer-agent"

                with pytest.raises(RuntimeError):
                    with agent_context("inner-agent"):
                        assert get_current_agent().agent_id == "inner-agent"
                        raise RuntimeError("inner failure")

                # Outer agent should be restored
                assert get_current_agent().agent_id == "outer-agent"


# =============================================================================
# Concurrent Asyncio Patch/Unpatch Tests
# =============================================================================

class TestAsyncioPatchThreadSafety:
    """Thread safety for asyncio patch/unpatch operations."""

    def test_concurrent_asyncio_patch_unpatch_no_crash(self):
        """10 threads alternating asyncio patch/unpatch should not crash."""
        unpatch_asyncio()
        errors = []

        def worker(i):
            try:
                for _ in range(50):
                    if i % 2 == 0:
                        patch_asyncio()
                    else:
                        unpatch_asyncio()
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert errors == [], f"Concurrent asyncio patch/unpatch raised: {errors}"
        unpatch_asyncio()

    def test_concurrent_process_patch_unpatch_no_crash(self):
        """10 threads alternating process patch/unpatch should not crash."""
        unpatch_process_executors()
        errors = []

        def worker(i):
            try:
                for _ in range(50):
                    if i % 2 == 0:
                        patch_process_executors()
                    else:
                        unpatch_process_executors()
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert errors == [], f"Concurrent process patch/unpatch raised: {errors}"
        unpatch_process_executors()


# =============================================================================
# _is_valid_hex_id() Direct Unit Tests
# =============================================================================

class TestIsValidHexId:
    """Direct unit tests for the W3C hex validation helper."""

    def test_valid_32_char_hex(self):
        assert _is_valid_hex_id("a" * 32, 32) is True

    def test_valid_16_char_hex(self):
        assert _is_valid_hex_id("b" * 16, 16) is True

    def test_mixed_valid_hex(self):
        assert _is_valid_hex_id("0123456789abcdef" * 2, 32) is True

    def test_rejects_uppercase(self):
        """Uppercase is NOT valid — must be pre-lowered."""
        assert _is_valid_hex_id("A" * 32, 32) is False

    def test_rejects_wrong_length(self):
        assert _is_valid_hex_id("a" * 31, 32) is False
        assert _is_valid_hex_id("a" * 33, 32) is False

    def test_rejects_non_hex_chars(self):
        assert _is_valid_hex_id("g" * 32, 32) is False
        assert _is_valid_hex_id("z" * 16, 16) is False

    def test_rejects_empty(self):
        assert _is_valid_hex_id("", 32) is False
        assert _is_valid_hex_id("", 0) is True  # edge case: empty string is valid 0-length


# =============================================================================
# ProcessPoolExecutor Exception Handling Tests
# =============================================================================

class TestProcessPoolExceptionHandling:
    """ProcessPoolExecutor fail-open behavior when worker raises."""

    def test_worker_exception_propagates_normally(self):
        """Exceptions in user functions propagate even with context patching."""
        patch_process_executors()

        with session_context("sess"):
            with ProcessPoolExecutor(max_workers=1) as executor:
                future = executor.submit(_raise_in_process)
                with pytest.raises(ValueError, match="deliberate worker error"):
                    future.result(timeout=10)

        unpatch_process_executors()


# =============================================================================
# get_current_parent_span_id() Tests
# =============================================================================

class TestGetCurrentParentSpanId:
    """Tests for get_current_parent_span_id convenience accessor."""

    def test_returns_none_when_no_span(self):
        assert get_current_parent_span_id() is None

    def test_returns_none_when_span_has_no_parent(self, sample_span):
        """Span without parent_span_id returns None."""
        token = set_current_span(sample_span)
        assert get_current_parent_span_id() is None
        reset_current_span(token)

    def test_returns_parent_span_id(self):
        """Span with parent_span_id returns it."""
        span = Span(
            trace_id="a" * 32,
            span_id="b" * 16,
            parent_span_id="c" * 16,
            name="child_span",
        )
        token = set_current_span(span)
        assert get_current_parent_span_id() == "c" * 16
        reset_current_span(token)


# =============================================================================
# W3C Unicode / Special Character Tests
# =============================================================================

class TestW3CSpecialCharacters:
    """W3C tracestate with unicode and special characters."""

    def test_tracestate_with_unicode_session_id(self):
        """Unicode characters in session_id should be preserved."""
        headers = {
            "traceparent": "00-" + "a" * 32 + "-" + "b" * 16 + "-01",
            "tracestate": "risicare=session_id=\u00e9\u00e0\u00fc",
        }
        ctx = extract_trace_context(headers)
        assert ctx["session_id"] == "\u00e9\u00e0\u00fc"

    def test_tracestate_with_equals_in_value(self):
        """Values containing '=' should be preserved (split on first '=' only)."""
        headers = {
            "traceparent": "00-" + "a" * 32 + "-" + "b" * 16 + "-01",
            "tracestate": "risicare=session_id=key=value=extra",
        }
        ctx = extract_trace_context(headers)
        assert ctx["session_id"] == "key=value=extra"

    def test_tracestate_with_spaces(self):
        """Leading/trailing spaces in keys and values should be stripped."""
        headers = {
            "traceparent": "00-" + "a" * 32 + "-" + "b" * 16 + "-01",
            "tracestate": "risicare= session_id = my-session ",
        }
        ctx = extract_trace_context(headers)
        assert ctx["session_id"] == "my-session"


# =============================================================================
# Helper functions for ProcessPoolExecutor tests
# (must be at module level for pickling)
# =============================================================================

def _get_trace_in_process():
    """Get trace context info in a child process."""
    from risicare_core import get_current_session, get_current_span, get_trace_context
    session = get_current_session()
    span = get_current_span()
    ctx = get_trace_context()
    return {
        "trace_id": ctx.trace_id,
        "session_id": session.session_id if session else None,
        "has_span": span is not None,
    }


def _raise_in_process():
    """Raise an exception in a child process (for fail-open testing)."""
    raise ValueError("deliberate worker error")
