"""Tests for the Flowlines module-level API."""

import logging
import threading
from unittest.mock import MagicMock, call, patch

import pytest
from opentelemetry.sdk.trace import TracerProvider

import flowlines._init as _init_mod
from flowlines._context import FlowlinesSpanProcessor
from flowlines._init import (
    _reset,
    _validate_endpoint,
    aend_session,
    aget_memory,
    create_span_processor,
    end_session,
    get_instrumentors,
    get_memory,
    init,
    shutdown,
)


@pytest.fixture(autouse=True)
def _reset_singleton() -> None:
    """Reset the Flowlines singleton before each test."""
    _reset()


class TestValidateEndpoint:
    def test_https_is_valid(self) -> None:
        _validate_endpoint("https://api.flowlines.io")

    def test_http_localhost_is_valid(self) -> None:
        _validate_endpoint("http://localhost:4318")

    def test_http_127_0_0_1_is_valid(self) -> None:
        _validate_endpoint("http://127.0.0.1:4318")

    def test_http_non_localhost_raises(self) -> None:
        with pytest.raises(ValueError, match="must use HTTPS"):
            _validate_endpoint("http://api.flowlines.io")

    def test_no_scheme_raises(self) -> None:
        with pytest.raises(ValueError, match="must use HTTPS"):
            _validate_endpoint("api.flowlines.io")

    def test_https_localhost_is_valid(self) -> None:
        _validate_endpoint("https://localhost:4318")

    def test_http_ipv6_loopback_is_valid(self) -> None:
        _validate_endpoint("http://[::1]:4318")


class TestValidateApiKey:
    @patch("flowlines._init.atexit", autospec=True)
    def test_empty_api_key_raises(self, _mock_atexit: MagicMock) -> None:
        """An empty api_key raises ValueError."""
        with pytest.raises(ValueError, match="api_key must not be empty"):
            init(
                api_key="",
                ingest_endpoint="https://example.com",
                has_external_otel=True,
            )

    @patch("flowlines._init.atexit", autospec=True)
    def test_whitespace_api_key_raises(self, _mock_atexit: MagicMock) -> None:
        """A whitespace-only api_key raises ValueError."""
        with pytest.raises(ValueError, match="api_key must not be empty"):
            init(
                api_key="   ",
                ingest_endpoint="https://example.com",
                has_external_otel=True,
            )


class TestSingleton:
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_second_init_is_noop(
        self,
        mock_exporter_cls: MagicMock,
        _mock_trace: MagicMock,
    ) -> None:
        """Calling init() a second time is a silent no-op."""
        init(api_key="key", ingest_endpoint="https://example.com")
        assert mock_exporter_cls.call_count == 1

        init(api_key="key2", ingest_endpoint="https://example.com")
        # Exporter should NOT be created a second time.
        assert mock_exporter_cls.call_count == 1

    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_can_init_after_reset(
        self,
        _mock_exporter_cls: MagicMock,
        _mock_trace: MagicMock,
    ) -> None:
        """After _reset(), init() can be called again."""
        init(api_key="key", ingest_endpoint="https://example.com")
        _reset()
        init(api_key="key2", ingest_endpoint="https://example.com")

    def test_mutually_exclusive_flags(self) -> None:
        """Setting both has_external_otel and has_traceloop raises ValueError."""
        with pytest.raises(ValueError, match="mutually exclusive"):
            init(
                api_key="key",
                ingest_endpoint="https://example.com",
                has_external_otel=True,
                has_traceloop=True,
            )

    @patch("flowlines._init.atexit", autospec=True)
    def test_concurrent_init_all_succeed(
        self,
        _mock_atexit: MagicMock,
    ) -> None:
        """All threads succeed when multiple threads init concurrently."""
        results: list[None | Exception] = []
        barrier = threading.Barrier(4)

        def _init() -> None:
            barrier.wait()
            try:
                init(
                    api_key="key",
                    ingest_endpoint="https://example.com",
                    has_external_otel=True,
                )
                results.append(None)
            except Exception as exc:
                results.append(exc)

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

        assert len(results) == 4
        assert all(r is None for r in results)


class TestModeA:
    """Mode A: no existing OTEL setup."""

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_creates_tracer_provider_and_sets_global(
        self,
        mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode A creates a TracerProvider and sets it as global."""
        init(api_key="my-key", ingest_endpoint="https://api.flowlines.io")

        mock_exporter_cls.assert_called_once_with(
            api_key="my-key",
            endpoint="https://api.flowlines.io",
        )
        mock_bsp_cls.assert_called_once_with(mock_exporter_cls.return_value)
        mock_trace.set_tracer_provider.assert_called_once()
        assert _init_mod._instance is not None
        assert _init_mod._instance._provider is not None

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    @patch("flowlines._init.get_instrumentors")
    def test_registers_instrumentors(
        self,
        mock_get_instrumentors: MagicMock,
        mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode A calls instrument() on all discovered instrumentors."""
        mock_inst1 = MagicMock()
        mock_inst2 = MagicMock()
        mock_get_instrumentors.return_value = [mock_inst1, mock_inst2]

        init(api_key="key", ingest_endpoint="https://example.com")

        assert _init_mod._instance is not None
        provider = _init_mod._instance._provider
        mock_inst1.instrument.assert_called_once_with(tracer_provider=provider)
        mock_inst2.instrument.assert_called_once_with(tracer_provider=provider)

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_validates_endpoint(
        self,
        _mock_exporter_cls: MagicMock,
        _mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode A raises ValueError for non-HTTPS non-localhost endpoints."""
        with pytest.raises(ValueError, match="must use HTTPS"):
            init(api_key="key", ingest_endpoint="http://api.flowlines.io")

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_allows_localhost(
        self,
        _mock_exporter_cls: MagicMock,
        _mock_bsp_cls: MagicMock,
        _mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode A allows HTTP for localhost endpoints."""
        init(api_key="key", ingest_endpoint="http://localhost:4318")

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_registers_atexit_handler(
        self,
        _mock_exporter_cls: MagicMock,
        _mock_bsp_cls: MagicMock,
        _mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode A registers shutdown as an atexit handler."""
        init(api_key="key", ingest_endpoint="https://example.com")
        mock_atexit.register.assert_called_once_with(shutdown)

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    @patch("flowlines._init.get_instrumentors")
    def test_resets_singleton_on_instrumentor_failure(
        self,
        mock_get_instrumentors: MagicMock,
        _mock_exporter_cls: MagicMock,
        _mock_bsp_cls: MagicMock,
        _mock_trace: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """Mode A resets the singleton if an instrumentor raises."""
        failing_instrumentor = MagicMock()
        failing_instrumentor.instrument.side_effect = RuntimeError("instrument failed")
        mock_get_instrumentors.return_value = [failing_instrumentor]

        with pytest.raises(RuntimeError, match="instrument failed"):
            init(api_key="key", ingest_endpoint="https://example.com")

        assert _init_mod._instance is None


class TestModeB1:
    """Mode B1: has_external_otel=True — user manages everything."""

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_does_not_create_provider_or_instrument(
        self,
        mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode B1 skips TracerProvider creation and instrumentor registration."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )

        assert _init_mod._instance is not None
        assert _init_mod._instance._provider is None
        assert _init_mod._instance._processor is None
        mock_trace.set_tracer_provider.assert_not_called()
        # No exporter or processor created during construction.
        mock_exporter_cls.assert_not_called()
        mock_bsp_cls.assert_not_called()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_create_span_processor_returns_processor(
        self,
        mock_exporter_cls: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """User can call create_span_processor() in Mode B1."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        processor = create_span_processor()

        assert isinstance(processor, FlowlinesSpanProcessor)
        mock_exporter_cls.assert_called_once_with(
            api_key="key",
            endpoint="https://example.com",
        )

    @patch("flowlines._init.atexit", autospec=True)
    def test_get_instrumentors_returns_list(
        self,
        mock_atexit: MagicMock,
    ) -> None:
        """User can call get_instrumentors() in Mode B1."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        result = get_instrumentors()

        assert isinstance(result, list)

    @patch("flowlines._init.atexit", autospec=True)
    def test_registers_atexit_handler(
        self,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode B1 still registers shutdown as an atexit handler."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        mock_atexit.register.assert_called_once_with(shutdown)


class TestModeB2:
    """Mode B2: has_traceloop=True — Traceloop already initialized."""

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_adds_processor_to_existing_provider(
        self,
        mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode B2 adds a span processor to the existing TracerProvider."""
        existing_provider = MagicMock(spec=TracerProvider)
        mock_trace.get_tracer_provider.return_value = existing_provider

        init(
            api_key="key",
            ingest_endpoint="https://api.flowlines.io",
            has_traceloop=True,
        )

        mock_exporter_cls.assert_called_once_with(
            api_key="key",
            endpoint="https://api.flowlines.io",
        )
        mock_bsp_cls.assert_called_once_with(mock_exporter_cls.return_value)
        existing_provider.add_span_processor.assert_called_once()
        added_processor = existing_provider.add_span_processor.call_args[0][0]
        assert isinstance(added_processor, FlowlinesSpanProcessor)
        # Does NOT create a new provider or set global.
        mock_trace.set_tracer_provider.assert_not_called()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    def test_raises_if_no_sdk_provider(
        self,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode B2 raises RuntimeError if no SDK TracerProvider is found."""
        # Return a non-SDK provider (e.g., ProxyTracerProvider).
        mock_trace.get_tracer_provider.return_value = MagicMock(spec=[])

        with pytest.raises(RuntimeError, match="no SDK TracerProvider found"):
            init(
                api_key="key",
                ingest_endpoint="https://example.com",
                has_traceloop=True,
            )

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    def test_resets_singleton_on_failure(
        self,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode B2 resets the singleton if initialization fails."""
        mock_trace.get_tracer_provider.return_value = MagicMock(spec=[])

        with pytest.raises(RuntimeError):
            init(
                api_key="key",
                ingest_endpoint="https://example.com",
                has_traceloop=True,
            )

        # Should be able to init again after failure.
        assert _init_mod._instance is None

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_validates_endpoint(
        self,
        _mock_exporter_cls: MagicMock,
        _mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Mode B2 still validates the endpoint."""
        with pytest.raises(ValueError, match="must use HTTPS"):
            init(
                api_key="key",
                ingest_endpoint="http://api.flowlines.io",
                has_traceloop=True,
            )


class TestGetInstrumentors:
    @patch("flowlines._init._load_instrumentor")
    @patch("flowlines._init.importlib.util")
    def test_returns_instrumentors_for_installed_libs(
        self,
        mock_importlib_util: MagicMock,
        mock_load: MagicMock,
    ) -> None:
        """get_instrumentors() returns instrumentors for installed libraries."""

        # Simulate: openai installed, anthropic not installed, rest not installed.
        def find_spec_side_effect(name: str) -> object | None:
            if name == "openai":
                return MagicMock()
            return None

        mock_importlib_util.find_spec.side_effect = find_spec_side_effect
        mock_instrumentor = MagicMock()
        mock_load.return_value = mock_instrumentor

        result = get_instrumentors()

        assert len(result) == 1
        assert result[0] is mock_instrumentor
        mock_load.assert_called_once_with(
            "opentelemetry.instrumentation.openai",
            "OpenAIInstrumentor",
        )

    @patch("flowlines._init.importlib.util")
    def test_returns_empty_list_when_no_libs_installed(
        self,
        mock_importlib_util: MagicMock,
    ) -> None:
        """get_instrumentors() returns empty list when no libraries are installed."""
        mock_importlib_util.find_spec.return_value = None

        result = get_instrumentors()

        assert result == []

    @patch("flowlines._init._load_instrumentor")
    @patch("flowlines._init.importlib.util")
    def test_skips_instrumentor_on_import_error(
        self,
        mock_importlib_util: MagicMock,
        mock_load: MagicMock,
    ) -> None:
        """get_instrumentors() skips instrumentors that fail to import."""
        mock_importlib_util.find_spec.return_value = MagicMock()
        mock_load.side_effect = ImportError("missing module")

        result = get_instrumentors()

        assert result == []

    def test_get_instrumentors_works_without_init(self) -> None:
        """get_instrumentors() works even before initialization."""
        result = get_instrumentors()
        assert isinstance(result, list)


class TestCreateSpanProcessor:
    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_returns_flowlines_span_processor(
        self,
        mock_exporter_cls: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """create_span_processor() returns a FlowlinesSpanProcessor."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        result = create_span_processor()

        assert isinstance(result, FlowlinesSpanProcessor)
        mock_exporter_cls.assert_called_once_with(
            api_key="key",
            endpoint="https://example.com",
        )

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_tracks_batch_processor_for_shutdown(
        self,
        mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """create_span_processor() stores the inner BatchSpanProcessor for shutdown."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        processor = create_span_processor()

        assert isinstance(processor, FlowlinesSpanProcessor)
        assert _init_mod._instance is not None
        assert _init_mod._instance._processor is mock_bsp_cls.return_value

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_raises_on_second_call(
        self,
        _mock_exporter_cls: MagicMock,
        _mock_bsp_cls: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """create_span_processor() raises RuntimeError when called twice."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        create_span_processor()

        with pytest.raises(RuntimeError, match="already been called"):
            create_span_processor()

    def test_create_span_processor_raises_when_not_initialized(self) -> None:
        """create_span_processor() raises when not initialized."""
        with pytest.raises(RuntimeError, match="has not been initialized"):
            create_span_processor()


class TestShutdown:
    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_mode_a_flushes_and_shuts_down_provider(
        self,
        _mock_exporter_cls: MagicMock,
        _mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """In Mode A, shutdown() flushes and shuts down the TracerProvider."""
        init(api_key="key", ingest_endpoint="https://example.com")

        # Replace provider with mock to verify calls.
        mock_provider = MagicMock(spec=TracerProvider)
        assert _init_mod._instance is not None
        _init_mod._instance._provider = mock_provider
        shutdown()

        mock_provider.force_flush.assert_called_once()
        mock_provider.shutdown.assert_called_once()
        mock_provider.assert_has_calls(
            [call.force_flush(), call.shutdown()],
        )

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_mode_b2_flushes_and_shuts_down_processor(
        self,
        _mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """In Mode B2, shutdown() flushes and shuts down the span processor."""
        existing_provider = MagicMock(spec=TracerProvider)
        mock_trace.get_tracer_provider.return_value = existing_provider
        mock_processor = mock_bsp_cls.return_value

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_traceloop=True,
        )
        shutdown()

        mock_processor.force_flush.assert_called_once()
        mock_processor.shutdown.assert_called_once()
        mock_processor.assert_has_calls(
            [call.force_flush(), call.shutdown()],
        )

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_mode_b1_shutdown_after_create_processor(
        self,
        _mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """In Mode B1, shutdown() flushes the user-created processor."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        create_span_processor()
        mock_processor = mock_bsp_cls.return_value

        shutdown()

        mock_processor.force_flush.assert_called_once()
        mock_processor.shutdown.assert_called_once()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_shutdown_is_idempotent(
        self,
        _mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        mock_atexit: MagicMock,
    ) -> None:
        """Calling shutdown() multiple times only flushes/shuts down once."""
        existing_provider = MagicMock(spec=TracerProvider)
        mock_trace.get_tracer_provider.return_value = existing_provider
        mock_processor = mock_bsp_cls.return_value

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_traceloop=True,
        )
        shutdown()
        shutdown()
        shutdown()

        assert mock_processor.force_flush.call_count == 1
        assert mock_processor.shutdown.call_count == 1

    @patch("flowlines._init.atexit", autospec=True)
    def test_mode_b1_shutdown_without_processor_is_safe(
        self,
        mock_atexit: MagicMock,
    ) -> None:
        """In Mode B1, shutdown() without create_span_processor() is safe."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        shutdown()  # Should not raise

    def test_shutdown_is_noop_when_not_initialized(self) -> None:
        """shutdown() is a no-op when not initialized."""
        shutdown()  # Should not raise

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_force_flush_error_is_caught_and_logged(
        self,
        _mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """shutdown() catches force_flush errors and logs them."""
        existing_provider = MagicMock(spec=TracerProvider)
        mock_trace.get_tracer_provider.return_value = existing_provider
        mock_processor = mock_bsp_cls.return_value
        mock_processor.force_flush.side_effect = RuntimeError("flush failed")

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_traceloop=True,
        )

        with patch("flowlines._init.logger") as mock_logger:
            shutdown()  # Should not raise

        mock_logger.error.assert_called_once()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.trace", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_processor_shutdown_error_is_caught_and_logged(
        self,
        _mock_exporter_cls: MagicMock,
        mock_bsp_cls: MagicMock,
        mock_trace: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """shutdown() catches processor shutdown errors and logs them."""
        existing_provider = MagicMock(spec=TracerProvider)
        mock_trace.get_tracer_provider.return_value = existing_provider
        mock_processor = mock_bsp_cls.return_value
        mock_processor.shutdown.side_effect = OSError("connection reset")

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_traceloop=True,
        )

        with patch("flowlines._init.logger") as mock_logger:
            shutdown()  # Should not raise

        mock_logger.error.assert_called_once()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._init.TracerProvider", autospec=True)
    @patch("flowlines._init.BatchSpanProcessor", autospec=True)
    @patch("flowlines._init.FlowlinesExporter", autospec=True)
    def test_mode_a_provider_error_is_caught_and_logged(
        self,
        _mock_exporter_cls: MagicMock,
        _mock_bsp_cls: MagicMock,
        mock_tp_cls: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """shutdown() catches TracerProvider errors in Mode A and logs them."""
        mock_provider = mock_tp_cls.return_value
        mock_provider.force_flush.side_effect = RuntimeError("export failed")

        with patch("flowlines._init.trace", autospec=True):
            init(
                api_key="key",
                ingest_endpoint="https://example.com",
            )

        with patch("flowlines._init.logger") as mock_logger:
            shutdown()  # Should not raise

        mock_logger.error.assert_called_once()


class TestContextManager:
    def test_context_works_without_init(self) -> None:
        """context() works even before initialization."""
        from flowlines._init import context

        with context(user_id="u1", session_id="s1", agent_id="a1"):
            pass  # Should not raise

    @patch("flowlines._init.atexit", autospec=True)
    def test_context_works_after_init(self, _mock_atexit: MagicMock) -> None:
        """context() works after init."""
        from flowlines._init import context

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        with context(user_id="u1", session_id="s1"):
            pass  # Should not raise


class TestVerboseMode:
    """Tests for verbose logging mode."""

    @pytest.fixture(autouse=True)
    def _cleanup_logger(self) -> None:
        """Remove handlers added by verbose mode after each test."""
        yield  # type: ignore[misc]
        flowlines_logger = logging.getLogger("flowlines")
        flowlines_logger.handlers.clear()
        flowlines_logger.setLevel(logging.WARNING)

    @patch("flowlines._init.atexit", autospec=True)
    def test_verbose_enables_debug_logging(
        self,
        _mock_atexit: MagicMock,
    ) -> None:
        """verbose=True sets the flowlines logger to DEBUG with a handler."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            verbose=True,
        )

        flowlines_logger = logging.getLogger("flowlines")
        assert flowlines_logger.level == logging.DEBUG
        assert len(flowlines_logger.handlers) >= 1

    @patch("flowlines._init.atexit", autospec=True)
    def test_no_verbose_does_not_add_handler(
        self,
        _mock_atexit: MagicMock,
    ) -> None:
        """Without verbose, the flowlines logger has no handlers added."""
        flowlines_logger = logging.getLogger("flowlines")
        handlers_before = len(flowlines_logger.handlers)

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )

        assert len(flowlines_logger.handlers) == handlers_before

    @patch("flowlines._init.atexit", autospec=True)
    def test_verbose_emits_init_messages(
        self,
        _mock_atexit: MagicMock,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        """verbose=True emits initialization log messages."""
        with caplog.at_level(logging.DEBUG, logger="flowlines"):
            init(
                api_key="key",
                ingest_endpoint="https://example.com",
                has_external_otel=True,
                verbose=True,
            )

        assert any("Initializing Flowlines SDK" in r.message for r in caplog.records)
        assert any("initialized successfully" in r.message for r in caplog.records)


class TestGetMemory:
    """Tests for the module-level get_memory() function."""

    def test_raises_when_not_initialized(self) -> None:
        """get_memory() raises RuntimeError when not initialized."""
        with pytest.raises(RuntimeError, match="has not been initialized"):
            get_memory("u1")

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    def test_delegates_to_instance(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """get_memory() delegates to _instance.get_memory()."""
        import json

        mock_response = MagicMock()
        mock_response.read.return_value = json.dumps({"memory": {"k": "v"}}).encode()
        mock_urlopen.return_value = mock_response

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )
        result = get_memory("u1", session_id="s1", agent_id="a1", view="summary")

        assert json.loads(result) == {"k": "v"}
        request = mock_urlopen.call_args[0][0]
        url = request.full_url
        assert "user_id=u1" in url
        assert "session_id=s1" in url
        assert "agent_id=a1" in url
        assert "view=summary" in url
        assert url.startswith("https://api.example.com/v1/get-memory?")

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    def test_uses_default_api_endpoint(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """get_memory() uses the default api_endpoint when none is specified."""
        import json

        mock_response = MagicMock()
        mock_response.read.return_value = json.dumps({"memory": {}}).encode()
        mock_urlopen.return_value = mock_response

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        get_memory("u1")

        request = mock_urlopen.call_args[0][0]
        assert request.full_url.startswith("https://api.flowlines.ai/v1/get-memory?")


class TestAgetMemory:
    """Tests for the module-level aget_memory() function."""

    async def test_raises_when_not_initialized(self) -> None:
        """aget_memory() raises RuntimeError when not initialized."""
        with pytest.raises(RuntimeError, match="has not been initialized"):
            await aget_memory("u1")

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    async def test_delegates_to_instance(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """aget_memory() delegates to _instance.aget_memory()."""
        import json

        mock_response = MagicMock()
        mock_response.read.return_value = json.dumps({"memory": {"k": "v"}}).encode()
        mock_urlopen.return_value = mock_response

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )
        result = await aget_memory("u1", session_id="s1")

        assert json.loads(result) == {"k": "v"}


class TestGetMemoryErrorHandling:
    """Tests for error handling in get_memory() / aget_memory()."""

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    def test_http_5xx_logs_error_and_returns_none(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """get_memory() logs ERROR and returns None on HTTP 5xx."""
        from io import BytesIO
        from urllib.error import HTTPError

        mock_urlopen.side_effect = HTTPError(
            url="https://api.example.com/v1/get-memory",
            code=500,
            msg="Internal Server Error",
            hdrs=MagicMock(),
            fp=BytesIO(b"Server error"),
        )
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )

        with patch("flowlines._init.logger") as mock_logger:
            result = get_memory("u1")

        assert result is None
        mock_logger.error.assert_called_once()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    def test_http_429_logs_error_and_returns_none(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """get_memory() logs ERROR and returns None on HTTP 429."""
        from io import BytesIO
        from urllib.error import HTTPError

        mock_urlopen.side_effect = HTTPError(
            url="https://api.example.com/v1/get-memory",
            code=429,
            msg="Too Many Requests",
            hdrs=MagicMock(),
            fp=BytesIO(b"Rate limited"),
        )
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )

        with patch("flowlines._init.logger") as mock_logger:
            result = get_memory("u1")

        assert result is None
        mock_logger.error.assert_called_once()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    def test_network_error_logs_error_and_returns_none(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """get_memory() logs ERROR and returns None on network failure."""
        from urllib.error import URLError

        mock_urlopen.side_effect = URLError("Connection refused")
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )

        with patch("flowlines._init.logger") as mock_logger:
            result = get_memory("u1")

        assert result is None
        mock_logger.error.assert_called_once()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    def test_http_4xx_logs_warning_and_returns_none(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """get_memory() logs WARNING and returns None on HTTP 4xx (not 429)."""
        from io import BytesIO
        from urllib.error import HTTPError

        mock_urlopen.side_effect = HTTPError(
            url="https://api.example.com/v1/get-memory",
            code=401,
            msg="Unauthorized",
            hdrs=MagicMock(),
            fp=BytesIO(b'{"error": "Unauthorized"}'),
        )
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )

        with patch("flowlines._init.logger") as mock_logger:
            result = get_memory("u1")

        assert result is None
        mock_logger.warning.assert_called_once()
        mock_logger.error.assert_not_called()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    async def test_aget_memory_http_5xx_logs_error_and_returns_none(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """aget_memory() logs ERROR and returns None on HTTP 5xx."""
        from io import BytesIO
        from urllib.error import HTTPError

        mock_urlopen.side_effect = HTTPError(
            url="https://api.example.com/v1/get-memory",
            code=500,
            msg="Internal Server Error",
            hdrs=MagicMock(),
            fp=BytesIO(b"Server error"),
        )
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )

        with patch("flowlines._init.logger") as mock_logger:
            result = await aget_memory("u1")

        assert result is None
        mock_logger.error.assert_called_once()

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._memory.urlopen")
    async def test_aget_memory_network_error_logs_error_and_returns_none(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """aget_memory() logs ERROR and returns None on network failure."""
        from urllib.error import URLError

        mock_urlopen.side_effect = URLError("Connection refused")
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )

        with patch("flowlines._init.logger") as mock_logger:
            result = await aget_memory("u1")

        assert result is None
        mock_logger.error.assert_called_once()


class TestMemoryEndpoint:
    """Tests for api_endpoint parameter in init()."""

    @patch("flowlines._init.atexit", autospec=True)
    def test_default_api_endpoint(self, _mock_atexit: MagicMock) -> None:
        """Default api_endpoint is stored on the instance."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
        )
        assert _init_mod._instance is not None
        assert _init_mod._instance._api_endpoint == "https://api.flowlines.ai"

    @patch("flowlines._init.atexit", autospec=True)
    def test_custom_api_endpoint(self, _mock_atexit: MagicMock) -> None:
        """Custom api_endpoint is stored on the instance."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://custom-api.example.com",
        )
        assert _init_mod._instance is not None
        assert _init_mod._instance._api_endpoint == "https://custom-api.example.com"

    @patch("flowlines._init.atexit", autospec=True)
    def test_invalid_api_endpoint_raises(self, _mock_atexit: MagicMock) -> None:
        """Invalid api_endpoint raises ValueError."""
        with pytest.raises(ValueError, match="must use HTTPS"):
            init(
                api_key="key",
                ingest_endpoint="https://example.com",
                has_external_otel=True,
                api_endpoint="http://api.example.com",
            )

    @patch("flowlines._init.atexit", autospec=True)
    def test_localhost_api_endpoint_is_valid(self, _mock_atexit: MagicMock) -> None:
        """Localhost api_endpoint is allowed."""
        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="http://localhost:3000",
        )
        assert _init_mod._instance is not None
        assert _init_mod._instance._api_endpoint == "http://localhost:3000"


class TestEndSession:
    """Tests for the module-level end_session() function."""

    def test_raises_when_not_initialized(self) -> None:
        """end_session() raises RuntimeError when not initialized."""
        with pytest.raises(RuntimeError, match="has not been initialized"):
            end_session("u1", session_id="s1")

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._session.urlopen")
    def test_flushes_before_calling_backend(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """end_session() flushes the span processor before the HTTP call."""
        mock_urlopen.return_value = MagicMock()

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )

        assert _init_mod._instance is not None
        processor = _init_mod._instance.create_span_processor()

        with patch.object(processor._inner, "force_flush") as mock_flush:
            end_session("u1", session_id="s1")

            mock_flush.assert_called_once()
            # force_flush must have been called before urlopen
            flush_order = mock_flush.call_args_list
            urlopen_order = mock_urlopen.call_args_list
            assert len(flush_order) == 1
            assert len(urlopen_order) == 1


class TestAendSession:
    """Tests for the module-level aend_session() function."""

    async def test_raises_when_not_initialized(self) -> None:
        """aend_session() raises RuntimeError when not initialized."""
        with pytest.raises(RuntimeError, match="has not been initialized"):
            await aend_session("u1", session_id="s1")

    @patch("flowlines._init.atexit", autospec=True)
    @patch("flowlines._session.urlopen")
    async def test_flushes_before_calling_backend(
        self,
        mock_urlopen: MagicMock,
        _mock_atexit: MagicMock,
    ) -> None:
        """aend_session() flushes the span processor before the HTTP call."""
        mock_urlopen.return_value = MagicMock()

        init(
            api_key="key",
            ingest_endpoint="https://example.com",
            has_external_otel=True,
            api_endpoint="https://api.example.com",
        )

        assert _init_mod._instance is not None
        processor = _init_mod._instance.create_span_processor()

        with patch.object(processor._inner, "force_flush") as mock_flush:
            await aend_session("u1", session_id="s1")

            mock_flush.assert_called_once()
            mock_urlopen.assert_called_once()
