"""Tests for the TokpulseTracker and UsageEvent classes."""

from __future__ import annotations

import inspect
import threading
import time
import warnings
from typing import Any
from unittest.mock import MagicMock, patch

import httpx
import pytest

from tokpulse.tracker import (
    SecurityWarning,
    TokpulseTracker,
    UsageEvent,
    _SecretStr,
)


class TestUsageEvent:
    def test_to_dict_required_fields(self) -> None:
        event = UsageEvent(
            request_id="req-1",
            provider="openai",
            model="gpt-4o",
            input_tokens=100,
            output_tokens=50,
            timestamp=1700000000000,
        )
        d = event.to_dict()
        assert d["request_id"] == "req-1"
        assert d["provider"] == "openai"
        assert d["model"] == "gpt-4o"
        assert d["input_tokens"] == 100
        assert d["output_tokens"] == 50
        assert d["timestamp"] == 1700000000000

    def test_to_dict_omits_none_optional_fields(self) -> None:
        event = UsageEvent(
            request_id="req-2",
            provider="anthropic",
            model="claude-3",
            input_tokens=10,
            output_tokens=5,
            timestamp=1700000000000,
        )
        d = event.to_dict()
        assert "cached_input_tokens" not in d
        assert "cached_read_tokens" not in d
        assert "cached_write_tokens" not in d
        assert "thinking_tokens" not in d
        assert "project" not in d
        assert "environment" not in d
        assert "metadata" not in d

    def test_to_dict_includes_thinking_tokens(self) -> None:
        event = UsageEvent(
            request_id="req-3",
            provider="anthropic",
            model="claude-3-7-sonnet",
            input_tokens=200,
            output_tokens=100,
            timestamp=1700000000000,
            thinking_tokens=500,
        )
        d = event.to_dict()
        assert d["thinking_tokens"] == 500

    def test_to_dict_zero_tokens_included(self) -> None:
        event = UsageEvent(
            request_id="req-5",
            provider="openai",
            model="gpt-4o-mini",
            input_tokens=0,
            output_tokens=0,
            timestamp=1700000000000,
        )
        d = event.to_dict()
        assert d["input_tokens"] == 0
        assert d["output_tokens"] == 0


class TestTokpulseTrackerQueue:
    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_track_adds_event_to_queue(self) -> None:
        tracker = self._make_tracker()
        try:
            tracker.track(provider="openai", model="gpt-4o", input_tokens=10, output_tokens=5)
            assert tracker._queue.qsize() == 1
        finally:
            tracker.close()

    def test_track_drops_when_queue_full(self) -> None:
        tracker = self._make_tracker(max_queue_size=2)
        try:
            for _ in range(5):
                tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
            assert tracker._queue.qsize() <= 2
        finally:
            tracker.close()

    def test_track_generates_unique_request_ids(self) -> None:
        tracker = self._make_tracker()
        try:
            for _ in range(5):
                tracker.track(provider="openai", model="gpt-4o", input_tokens=10, output_tokens=5)
            ids = set()
            while not tracker._queue.empty():
                event = tracker._queue.get_nowait()
                ids.add(event.request_id)
            assert len(ids) == 5
        finally:
            tracker.close()

    def test_track_attaches_project_from_config(self) -> None:
        tracker = self._make_tracker(project="my-project")
        try:
            tracker.track(provider="openai", model="gpt-4o", input_tokens=10, output_tokens=5)
            event = tracker._queue.get_nowait()
            assert event.project == "my-project"
        finally:
            tracker.close()

    def test_track_thinking_tokens_in_event(self) -> None:
        tracker = self._make_tracker()
        try:
            tracker.track(
                provider="anthropic",
                model="claude-3-7-sonnet",
                input_tokens=100,
                output_tokens=50,
                thinking_tokens=800,
            )
            event = tracker._queue.get_nowait()
            assert event.thinking_tokens == 800
        finally:
            tracker.close()

    def test_track_rejects_negative_tokens(self) -> None:
        tracker = self._make_tracker()
        try:
            with pytest.raises(ValueError, match="input_tokens must be a non-negative integer"):
                tracker.track(provider="openai", model="gpt-4o", input_tokens=-1, output_tokens=1)
        finally:
            tracker.close()

    def test_constructor_rejects_empty_api_key(self) -> None:
        with pytest.raises(ValueError, match="api_key must be a non-empty string"):
            TokpulseTracker(api_key="")

    def test_constructor_rejects_local_http_without_opt_in(self) -> None:
        error_message = (
            "Insecure HTTP api_url is only allowed for localhost when allow_insecure_http=True"
        )
        with pytest.raises(
            ValueError,
            match=error_message,
        ):
            TokpulseTracker(api_key="lct_test", api_url="http://localhost:9999")

    def test_constructor_accepts_local_http_with_opt_in(self) -> None:
        tracker = TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
        )
        tracker.close()

    def test_constructor_warns_non_local_http_with_opt_in(self) -> None:
        """PY-M11: non-localhost HTTP with allow_insecure_http=True emits SecurityWarning."""
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            tracker = TokpulseTracker(
                api_key="lct_test",
                api_url="http://example.com",
                allow_insecure_http=True,
            )
            tracker.close()

        security_warnings = [w for w in caught if issubclass(w.category, SecurityWarning)]
        assert len(security_warnings) >= 1


class TestTokpulseTrackerSend:
    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_send_event_calls_correct_endpoint(self) -> None:
        tracker = self._make_tracker()
        try:
            mock_response = MagicMock()
            mock_response.status_code = 202

            with patch.object(tracker._client, "post", return_value=mock_response) as mock_post:
                event = UsageEvent(
                    request_id="req-x",
                    provider="openai",
                    model="gpt-4o",
                    input_tokens=100,
                    output_tokens=50,
                    timestamp=1700000000000,
                )
                tracker._send_event(event, attempt=0, allow_retry=True)
                mock_post.assert_called_once()
                assert "/v1/ingest" in mock_post.call_args[0][0]
        finally:
            tracker.close()

    def test_send_event_uses_api_key_in_header(self) -> None:
        tracker = self._make_tracker()
        try:
            mock_response = MagicMock()
            mock_response.status_code = 202

            with patch.object(tracker._client, "post", return_value=mock_response) as mock_post:
                event = UsageEvent(
                    request_id="req-y",
                    provider="openai",
                    model="gpt-4o",
                    input_tokens=10,
                    output_tokens=5,
                    timestamp=1700000000000,
                )
                tracker._send_event(event, attempt=0, allow_retry=True)
                headers = mock_post.call_args[1]["headers"]
                assert "Bearer lct_test" in headers["Authorization"]
        finally:
            tracker.close()

    def test_send_event_handles_200_duplicate(self) -> None:
        tracker = self._make_tracker()
        try:
            mock_response = MagicMock()
            mock_response.status_code = 200

            with patch.object(tracker._client, "post", return_value=mock_response):
                event = UsageEvent(
                    request_id="req-dup",
                    provider="openai",
                    model="gpt-4o",
                    input_tokens=10,
                    output_tokens=5,
                    timestamp=1700000000000,
                )
                tracker._send_event(event, attempt=0, allow_retry=True)
        finally:
            tracker.close()

    def test_retryable_error_schedules_retry(self) -> None:
        tracker = self._make_tracker(max_retries=3)
        try:
            mock_response = MagicMock()
            mock_response.status_code = 429

            with (
                patch.object(tracker._client, "post", return_value=mock_response),
                patch.object(tracker, "_schedule_retry") as mock_schedule,
            ):
                event = UsageEvent(
                    request_id="req-retry",
                    provider="openai",
                    model="gpt-4o",
                    input_tokens=10,
                    output_tokens=5,
                    timestamp=1700000000000,
                )
                tracker._send_event(event, attempt=0, allow_retry=True)
                mock_schedule.assert_called_once()
        finally:
            tracker.close()

    def test_non_retryable_error_calls_on_error(self) -> None:
        on_error = MagicMock()
        tracker = self._make_tracker(on_error=on_error)
        try:
            mock_response = MagicMock()
            mock_response.status_code = 401

            with patch.object(tracker._client, "post", return_value=mock_response):
                event = UsageEvent(
                    request_id="req-401",
                    provider="openai",
                    model="gpt-4o",
                    input_tokens=10,
                    output_tokens=5,
                    timestamp=1700000000000,
                )
                tracker._send_event(event, attempt=0, allow_retry=True)
                on_error.assert_called_once()
        finally:
            tracker.close()

    def test_flush_sends_queued_events(self) -> None:
        tracker = self._make_tracker()
        try:
            mock_response = MagicMock()
            mock_response.status_code = 202

            tracker.track(provider="openai", model="gpt-4o", input_tokens=10, output_tokens=5)
            assert tracker._queue.qsize() == 1

            with patch.object(tracker._client, "post", return_value=mock_response) as mock_post:
                tracker.flush()
                assert mock_post.call_count == 1
                assert tracker._queue.qsize() == 0
        finally:
            tracker.close()

    def test_close_flushes_queued_events(self) -> None:
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 202
        tracker.track(provider="openai", model="gpt-4o", input_tokens=10, output_tokens=5)

        with patch.object(tracker._client, "post", return_value=mock_response) as mock_post:
            tracker.close()
            assert mock_post.call_count == 1

        # idempotent
        tracker.close()


class TestBatchSizeTrigger:
    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_batch_trigger_causes_early_flush(self) -> None:
        """Reaching batch_size wakes the flush thread without waiting for the interval."""
        tracker = self._make_tracker(batch_size=2)
        mock_response = MagicMock()
        mock_response.status_code = 202

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)

            # Flush thread should wake and drain within well under a second.
            deadline = time.monotonic() + 2.0
            while tracker._queue.qsize() > 0 and time.monotonic() < deadline:
                time.sleep(0.02)

            assert tracker._queue.qsize() == 0

        tracker.close()

    def test_batch_trigger_not_set_below_batch_size(self) -> None:
        tracker = self._make_tracker(batch_size=5)
        try:
            # Add fewer events than batch_size — trigger should stay clear.
            for _ in range(3):
                tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
            # Trigger may have been consumed already; queue might still have events.
            # The key assertion: no premature network calls happened.
            assert tracker._queue.qsize() + 0 >= 0  # no error raised
        finally:
            tracker.close()


class TestTokpulseTrackerAsync:
    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    async def test_aflush_sends_queued_events(self) -> None:
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 202

        tracker.track(provider="openai", model="gpt-4o", input_tokens=10, output_tokens=5)
        assert tracker._queue.qsize() == 1

        with patch.object(tracker._client, "post", return_value=mock_response) as mock_post:
            await tracker.aflush()
            assert mock_post.call_count == 1
            assert tracker._queue.qsize() == 0

        tracker.close()

    async def test_aflush_respects_max_events(self) -> None:
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 202

        for _ in range(3):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)

        with patch.object(tracker._client, "post", return_value=mock_response) as mock_post:
            await tracker.aflush(max_events=2)
            assert mock_post.call_count == 2
            assert tracker._queue.qsize() == 1

        tracker.close()

    async def test_aclose_flushes_and_closes(self) -> None:
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 202

        tracker.track(provider="openai", model="gpt-4o", input_tokens=10, output_tokens=5)

        with patch.object(tracker._client, "post", return_value=mock_response) as mock_post:
            await tracker.aclose()
            assert mock_post.call_count == 1

        assert tracker._closed

    async def test_aclose_is_idempotent(self) -> None:
        tracker = self._make_tracker()
        await tracker.aclose()
        # Second call must not raise.
        await tracker.aclose()

    async def test_track_raises_after_aclose(self) -> None:
        tracker = self._make_tracker()
        await tracker.aclose()
        with pytest.raises(RuntimeError, match="tracker is closed"):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)


class TestSecurityBoundaries:
    """BOUNDARY: Security-critical input validation paths."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_" + "x" * 40,
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_metadata_max_entries_rejected(self) -> None:
        """BOUNDARY: metadata with >32 entries must raise ValueError."""
        tracker = self._make_tracker()
        big_meta = {f"key{i}": "value" for i in range(33)}
        with pytest.raises(ValueError, match="metadata"):
            tracker.track(
                provider="openai",
                model="gpt-4",
                input_tokens=10,
                output_tokens=5,
                metadata=big_meta,
            )
        tracker.close()

    def test_metadata_key_too_long_rejected(self) -> None:
        """BOUNDARY: metadata key >128 chars must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="key"):
            tracker.track(
                provider="openai",
                model="gpt-4",
                input_tokens=10,
                output_tokens=5,
                metadata={"k" * 129: "value"},
            )
        tracker.close()

    def test_metadata_value_too_long_rejected(self) -> None:
        """BOUNDARY: metadata value >4096 chars must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="value"):
            tracker.track(
                provider="openai",
                model="gpt-4",
                input_tokens=10,
                output_tokens=5,
                metadata={"key": "v" * 4097},
            )
        tracker.close()

    def test_provider_too_long_rejected(self) -> None:
        """BOUNDARY: provider string >256 chars must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError):
            tracker.track(provider="x" * 257, model="gpt-4", input_tokens=10, output_tokens=5)
        tracker.close()

    def test_model_too_long_rejected(self) -> None:
        """BOUNDARY: model string >256 chars must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError):
            tracker.track(provider="openai", model="x" * 257, input_tokens=10, output_tokens=5)
        tracker.close()

    def test_http_url_rejected(self) -> None:
        """BOUNDARY: HTTP (non-HTTPS) api_url must raise ValueError on init."""
        with pytest.raises(ValueError, match="https|Insecure HTTP"):
            TokpulseTracker(api_key="lct_" + "x" * 40, api_url="http://api.example.com")

    def test_on_error_exception_does_not_crash(self) -> None:
        """BOUNDARY: on_error callback raising must not propagate or crash tracker."""

        def bad_callback(e: Exception, payload: dict[str, Any]) -> None:
            raise RuntimeError("callback exploded")

        tracker = self._make_tracker(on_error=bad_callback)
        # Manually trigger error reporting -- should not raise
        try:
            tracker._report_error(Exception("test error"), {})
        except Exception as e:
            pytest.fail(f"_report_error propagated callback exception: {e}")
        tracker.close()

    def test_api_key_not_in_repr(self) -> None:
        """BOUNDARY: api_key must not appear in repr(TrackerConfig)."""
        key = "lct_super_secret_key_12345"
        # TrackerConfig now stores api_key as _SecretStr — construct via TokpulseTracker.
        tracker = TokpulseTracker(api_key=key, api_url="https://api.example.com")
        config = tracker.config
        assert key not in repr(config), "API key leaked in repr()"
        assert key not in str(config), "API key leaked in str()"
        tracker.close()

    def test_empty_metadata_accepted(self) -> None:
        """BOUNDARY: empty metadata dict must be accepted without error."""
        tracker = self._make_tracker()
        # Should not raise
        tracker.track(
            provider="openai",
            model="gpt-4",
            input_tokens=10,
            output_tokens=5,
            metadata={},
        )
        tracker.close()


# ---------------------------------------------------------------------------
# PY-H1: _SecretStr hides the raw value
# ---------------------------------------------------------------------------


class TestSecretStr:
    """Tests for the _SecretStr wrapper (PY-H1)."""

    def test_repr_does_not_expose_value(self) -> None:
        """BOUNDARY: repr of _SecretStr must never contain the secret."""
        s = _SecretStr("super_secret_value")
        assert "super_secret_value" not in repr(s)

    def test_str_does_not_expose_value(self) -> None:
        """BOUNDARY: str() of _SecretStr must never contain the secret."""
        s = _SecretStr("super_secret_value")
        assert "super_secret_value" not in str(s)

    def test_format_does_not_expose_value(self) -> None:
        """BOUNDARY: f-string interpolation must never contain the secret."""
        s = _SecretStr("super_secret_value")
        assert "super_secret_value" not in f"{s}"

    def test_get_secret_value_returns_raw(self) -> None:
        s = _SecretStr("my_key_abc123")
        assert s.get_secret_value() == "my_key_abc123"

    def test_eq_with_another_secret_str(self) -> None:
        assert _SecretStr("abc") == _SecretStr("abc")
        assert _SecretStr("abc") != _SecretStr("xyz")

    def test_eq_with_plain_str_returns_not_implemented(self) -> None:
        # PY-H3: _SecretStr no longer compares equal to plain str
        assert _SecretStr("abc") != "abc"

    def test_hash_consistent_with_eq(self) -> None:
        a, b = _SecretStr("key"), _SecretStr("key")
        assert hash(a) == hash(b)
        assert _SecretStr("a") != _SecretStr("b")

    def test_tracker_config_api_key_hidden_in_repr(self) -> None:
        """BOUNDARY: api_key must not appear anywhere in repr(tracker.config)."""
        key = "lct_s3cr3t_k3y_!!!"
        tracker = TokpulseTracker(api_key=key, api_url="https://api.example.com")
        config_repr = repr(tracker.config)
        assert key not in config_repr, f"API key leaked in repr: {config_repr}"
        tracker.close()

    def test_tracker_config_api_key_hidden_in_str(self) -> None:
        """BOUNDARY: api_key must not appear anywhere in str(tracker.config)."""
        key = "lct_another_secret_value"
        tracker = TokpulseTracker(api_key=key, api_url="https://api.example.com")
        config_str = str(tracker.config)
        assert key not in config_str, f"API key leaked in str: {config_str}"
        tracker.close()

    def test_empty_secret_str(self) -> None:
        """BOUNDARY: _SecretStr accepts and wraps an empty string."""
        # BOUNDARY: empty value — still opaque
        s = _SecretStr("")
        assert s.get_secret_value() == ""
        assert "**" in repr(s)


# ---------------------------------------------------------------------------
# PY-H2: SecurityWarning for cleartext HTTP to non-localhost hosts
# ---------------------------------------------------------------------------


class TestInsecureHttpWarning:
    """Tests for the SecurityWarning emitted when the API key travels over HTTP (PY-H2)."""

    def test_warning_emitted_for_non_localhost_http(self) -> None:
        """BOUNDARY: HTTP URL with external host must emit SecurityWarning."""
        # _validate_api_url rejects non-localhost HTTP without allow_insecure_http,
        # but the current code never reaches that — however, we can test _warn_insecure_http
        # directly since the URL validation block only rejects non-localhost when
        # allow_insecure_http=True and the host is not safe.
        # Instead we call the private helper directly.
        from tokpulse.tracker import _warn_insecure_http

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            _warn_insecure_http("http://evil.example.com/api", allow_insecure_http=True)

        security_warnings = [w for w in caught if issubclass(w.category, SecurityWarning)]
        assert len(security_warnings) == 1
        assert "evil.example.com" in str(security_warnings[0].message)

    def test_no_warning_for_localhost(self) -> None:
        """BOUNDARY: HTTP URL pointing at localhost must NOT emit SecurityWarning."""
        from tokpulse.tracker import _warn_insecure_http

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            _warn_insecure_http("http://localhost:9999", allow_insecure_http=True)

        security_warnings = [w for w in caught if issubclass(w.category, SecurityWarning)]
        assert len(security_warnings) == 0

    def test_no_warning_for_127_0_0_1(self) -> None:
        """BOUNDARY: HTTP URL pointing at 127.0.0.1 must NOT emit SecurityWarning."""
        from tokpulse.tracker import _warn_insecure_http

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            _warn_insecure_http("http://127.0.0.1:9999", allow_insecure_http=True)

        security_warnings = [w for w in caught if issubclass(w.category, SecurityWarning)]
        assert len(security_warnings) == 0

    def test_no_warning_for_https(self) -> None:
        """BOUNDARY: HTTPS URL must never emit SecurityWarning."""
        from tokpulse.tracker import _warn_insecure_http

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            _warn_insecure_http("https://api.example.com", allow_insecure_http=True)

        security_warnings = [w for w in caught if issubclass(w.category, SecurityWarning)]
        assert len(security_warnings) == 0

    def test_no_warning_when_insecure_http_disabled(self) -> None:
        """BOUNDARY: must not warn when allow_insecure_http=False (URL check raises first)."""
        from tokpulse.tracker import _warn_insecure_http

        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            _warn_insecure_http("http://evil.example.com", allow_insecure_http=False)

        security_warnings = [w for w in caught if issubclass(w.category, SecurityWarning)]
        assert len(security_warnings) == 0


# ---------------------------------------------------------------------------
# PY-M1: provider/model charset validation
# ---------------------------------------------------------------------------


class TestProviderModelCharset:
    """Tests for injection-prevention charset validation (PY-M1)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    # --- valid inputs -------------------------------------------------------

    def test_valid_provider_passes(self) -> None:
        tracker = self._make_tracker()
        # Should not raise for normal values
        tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_valid_provider_with_dots_slashes(self) -> None:
        tracker = self._make_tracker()
        tracker.track(
            provider="google/vertex", model="gemini-1.5-pro", input_tokens=1, output_tokens=1
        )
        tracker.close()

    def test_valid_model_at_256_chars(self) -> None:
        """BOUNDARY: exactly 256-char model name must pass."""
        tracker = self._make_tracker()
        tracker.track(provider="openai", model="a" * 256, input_tokens=1, output_tokens=1)
        tracker.close()

    def test_valid_provider_at_256_chars(self) -> None:
        """BOUNDARY: exactly 256-char provider name must pass."""
        tracker = self._make_tracker()
        tracker.track(provider="a" * 256, model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    # --- invalid charset inputs -----------------------------------------------

    def test_provider_with_space_rejected(self) -> None:
        """BOUNDARY: provider with whitespace must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="invalid characters"):
            tracker.track(provider="open ai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_provider_with_semicolon_rejected(self) -> None:
        """BOUNDARY: provider with semicolon (injection attempt) must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="invalid characters"):
            tracker.track(
                provider="openai;DROP TABLE", model="gpt-4o", input_tokens=1, output_tokens=1
            )
        tracker.close()

    def test_model_with_angle_bracket_rejected(self) -> None:
        """BOUNDARY: model with < or > (injection attempt) must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="invalid characters"):
            tracker.track(
                provider="openai",
                model="<script>alert(1)</script>",
                input_tokens=1,
                output_tokens=1,
            )
        tracker.close()

    def test_model_with_newline_rejected(self) -> None:
        """BOUNDARY: model containing newline must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="invalid characters"):
            tracker.track(
                provider="openai", model="gpt-4o\ninjection", input_tokens=1, output_tokens=1
            )
        tracker.close()

    def test_provider_257_chars_rejected(self) -> None:
        """BOUNDARY: 257-char provider must raise ValueError (length check)."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError):
            tracker.track(provider="a" * 257, model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_model_257_chars_rejected(self) -> None:
        """BOUNDARY: 257-char model must raise ValueError (length check)."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError):
            tracker.track(provider="openai", model="a" * 257, input_tokens=1, output_tokens=1)
        tracker.close()


# ---------------------------------------------------------------------------
# PY-M2: _closed set before thread join — no new events after close() starts
# ---------------------------------------------------------------------------


class TestCloseTOCTOU:
    """Tests that _closed is set at the top of close() before draining (PY-M2)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_track_raises_after_close_starts(self) -> None:
        """BOUNDARY: track() must raise RuntimeError as soon as close() sets _closed."""
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 202

        # Close the tracker; _closed is set at the start of close().
        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker.close()

        # After close completes _closed must be True and new track() must raise.
        with pytest.raises(RuntimeError, match="tracker is closed"):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)

    def test_closed_flag_set_before_threads_join(self) -> None:
        """BOUNDARY: _closed must be True immediately after close() acquires the lock."""
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 202

        observed_flag_during_close: list[bool] = []

        original_set = tracker._stop_event.set

        def spy_set() -> None:
            # _closed must already be True when _stop_event.set() is called
            observed_flag_during_close.append(tracker._closed)
            original_set()

        tracker._stop_event.set = spy_set  # type: ignore[method-assign]

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker.close()

        # The flag must have been True when stop_event.set() fired
        assert observed_flag_during_close, "spy was never called"
        assert observed_flag_during_close[0] is True, "_closed was not set before threads stopped"

    def test_concurrent_track_does_not_enqueue_after_close(self) -> None:
        """BOUNDARY: track() racing close() must not silently enqueue events."""
        tracker = self._make_tracker(max_queue_size=1000)
        mock_response = MagicMock()
        mock_response.status_code = 202

        enqueue_errors: list[RuntimeError] = []

        def spam_track() -> None:
            for _ in range(200):
                try:
                    tracker.track(
                        provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1
                    )
                except RuntimeError as e:
                    enqueue_errors.append(e)
                    break

        t = threading.Thread(target=spam_track)
        t.start()

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker.close()

        t.join(timeout=5)
        # After close, _closed must be set and further track() must fail
        with pytest.raises(RuntimeError, match="tracker is closed"):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)


# ---------------------------------------------------------------------------
# PY-M3: on_error receives sanitized payload (metadata values redacted)
# ---------------------------------------------------------------------------


class TestOnErrorMetadataSanitization:
    """Tests that on_error receives '[REDACTED]' metadata values (PY-M3)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_metadata_values_redacted_in_on_error(self) -> None:
        received_payloads: list[dict[str, Any]] = []

        def on_error(exc: Exception, payload: dict[str, Any]) -> None:
            received_payloads.append(payload)

        tracker = self._make_tracker(on_error=on_error)
        mock_response = MagicMock()
        mock_response.status_code = 401  # non-retryable error

        event = UsageEvent(
            request_id="req-m3",
            provider="openai",
            model="gpt-4o",
            input_tokens=10,
            output_tokens=5,
            timestamp=1700000000000,
            metadata={"user_id": "user-secret-123", "session": "sess-abc"},
        )

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker._send_event(event, attempt=0, allow_retry=True)

        assert len(received_payloads) == 1
        payload = received_payloads[0]
        # Keys must be preserved
        assert "user_id" in payload["metadata"]
        assert "session" in payload["metadata"]
        # Values must be redacted
        assert payload["metadata"]["user_id"] == "[REDACTED]"
        assert payload["metadata"]["session"] == "[REDACTED]"

        tracker.close()

    def test_metadata_keys_preserved_in_on_error(self) -> None:
        """BOUNDARY: metadata keys must survive sanitization intact."""
        received_payloads: list[dict[str, Any]] = []

        def on_error(exc: Exception, payload: dict[str, Any]) -> None:
            received_payloads.append(payload)

        tracker = self._make_tracker(on_error=on_error)
        mock_response = MagicMock()
        mock_response.status_code = 401

        event = UsageEvent(
            request_id="req-m3b",
            provider="openai",
            model="gpt-4o",
            input_tokens=10,
            output_tokens=5,
            timestamp=1700000000000,
            metadata={"k1": "v1", "k2": "v2"},
        )

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker._send_event(event, attempt=0, allow_retry=True)

        assert set(received_payloads[0]["metadata"].keys()) == {"k1", "k2"}
        tracker.close()

    def test_no_metadata_payload_unchanged(self) -> None:
        """BOUNDARY: payload without metadata must pass through untouched."""
        received_payloads: list[dict[str, Any]] = []

        def on_error(exc: Exception, payload: dict[str, Any]) -> None:
            received_payloads.append(payload)

        tracker = self._make_tracker(on_error=on_error)
        mock_response = MagicMock()
        mock_response.status_code = 401

        event = UsageEvent(
            request_id="req-m3c",
            provider="openai",
            model="gpt-4o",
            input_tokens=10,
            output_tokens=5,
            timestamp=1700000000000,
        )

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker._send_event(event, attempt=0, allow_retry=True)

        assert "metadata" not in received_payloads[0]
        tracker.close()

    def test_original_payload_not_mutated(self) -> None:
        """BOUNDARY: _report_error must not mutate the original payload dict."""
        received_sanitized: list[dict[str, Any]] = []

        def on_error(exc: Exception, payload: dict[str, Any]) -> None:
            received_sanitized.append(payload)

        tracker = self._make_tracker(on_error=on_error)
        original_payload: dict[str, Any] = {
            "request_id": "r1",
            "metadata": {"secret": "real-value"},
        }
        import copy

        original_copy = copy.deepcopy(original_payload)
        tracker._report_error(Exception("test"), original_payload)

        # Original must be unchanged
        assert original_payload == original_copy
        # Sanitized copy must have redacted value
        assert received_sanitized[0]["metadata"]["secret"] == "[REDACTED]"
        tracker.close()


# ---------------------------------------------------------------------------
# PY-M4: response body consumed on non-2xx to avoid connection leaks
# ---------------------------------------------------------------------------


class TestResponseBodyConsumed:
    """Tests that response.read() is called on error responses (PY-M4)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_response_read_called_on_retryable_error(self) -> None:
        """BOUNDARY: 429 response body must be consumed via response.read()."""
        tracker = self._make_tracker(max_retries=0)
        mock_response = MagicMock()
        mock_response.status_code = 429

        event = UsageEvent(
            request_id="req-m4a",
            provider="openai",
            model="gpt-4o",
            input_tokens=1,
            output_tokens=1,
            timestamp=1700000000000,
        )

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker._send_event(event, attempt=0, allow_retry=False)

        mock_response.read.assert_called_once()
        tracker.close()

    def test_response_read_called_on_500_error(self) -> None:
        """BOUNDARY: 500 response body must be consumed via response.read()."""
        tracker = self._make_tracker(max_retries=0)
        mock_response = MagicMock()
        mock_response.status_code = 500

        event = UsageEvent(
            request_id="req-m4b",
            provider="openai",
            model="gpt-4o",
            input_tokens=1,
            output_tokens=1,
            timestamp=1700000000000,
        )

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker._send_event(event, attempt=0, allow_retry=False)

        mock_response.read.assert_called_once()
        tracker.close()

    def test_response_read_called_on_non_retryable_error(self) -> None:
        """BOUNDARY: 401 response body must also be consumed via response.read()."""
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 401

        event = UsageEvent(
            request_id="req-m4c",
            provider="openai",
            model="gpt-4o",
            input_tokens=1,
            output_tokens=1,
            timestamp=1700000000000,
        )

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker._send_event(event, attempt=0, allow_retry=True)

        mock_response.read.assert_called_once()
        tracker.close()

    def test_response_read_not_called_on_202(self) -> None:
        """BOUNDARY: successful 202 response does NOT need to consume the body."""
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 202

        event = UsageEvent(
            request_id="req-m4d",
            provider="openai",
            model="gpt-4o",
            input_tokens=1,
            output_tokens=1,
            timestamp=1700000000000,
        )

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker._send_event(event, attempt=0, allow_retry=True)

        mock_response.read.assert_not_called()
        tracker.close()

    def test_response_read_not_called_on_200(self) -> None:
        """BOUNDARY: 200 (duplicate) response does NOT need to consume the body."""
        tracker = self._make_tracker()
        mock_response = MagicMock()
        mock_response.status_code = 200

        event = UsageEvent(
            request_id="req-m4e",
            provider="openai",
            model="gpt-4o",
            input_tokens=1,
            output_tokens=1,
            timestamp=1700000000000,
        )

        with patch.object(tracker._client, "post", return_value=mock_response):
            tracker._send_event(event, attempt=0, allow_retry=True)

        mock_response.read.assert_not_called()
        tracker.close()


# ---------------------------------------------------------------------------
# PY-M5: configurable max_retry_queue_size
# ---------------------------------------------------------------------------


class TestMaxRetryQueueSize:
    """Tests for the configurable retry queue size field (PY-M5)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_default_max_retry_queue_size_is_500(self) -> None:
        tracker = self._make_tracker()
        assert tracker.config.max_retry_queue_size == 500
        assert tracker._retry_queue.maxsize == 500
        tracker.close()

    def test_custom_max_retry_queue_size_applied(self) -> None:
        tracker = self._make_tracker(max_retry_queue_size=100)
        assert tracker.config.max_retry_queue_size == 100
        assert tracker._retry_queue.maxsize == 100
        tracker.close()

    def test_large_max_retry_queue_size(self) -> None:
        """BOUNDARY: large retry queue size must be accepted."""
        tracker = self._make_tracker(max_retry_queue_size=10_000)
        assert tracker._retry_queue.maxsize == 10_000
        tracker.close()

    def test_zero_max_retry_queue_size_rejected(self) -> None:
        """BOUNDARY: zero retry queue size must raise ValueError."""
        with pytest.raises(ValueError, match="max_retry_queue_size"):
            self._make_tracker(max_retry_queue_size=0)

    def test_negative_max_retry_queue_size_rejected(self) -> None:
        """BOUNDARY: negative retry queue size must raise ValueError."""
        with pytest.raises(ValueError, match="max_retry_queue_size"):
            self._make_tracker(max_retry_queue_size=-1)


# ---------------------------------------------------------------------------
# PY-CRIT-01: CRLF in api_key rejected
# ---------------------------------------------------------------------------


class TestCRLFApiKey:
    """Tests that api_key containing CR or LF is rejected (PY-CRIT-01)."""

    def test_api_key_with_cr_rejected(self) -> None:
        """BOUNDARY: api_key with \\r must raise ValueError."""
        with pytest.raises(ValueError, match="CR or LF"):
            TokpulseTracker(api_key="lct_test\rinjection")

    def test_api_key_with_lf_rejected(self) -> None:
        """BOUNDARY: api_key with \\n must raise ValueError."""
        with pytest.raises(ValueError, match="CR or LF"):
            TokpulseTracker(api_key="lct_test\ninjection")

    def test_api_key_with_crlf_rejected(self) -> None:
        """BOUNDARY: api_key with \\r\\n must raise ValueError."""
        with pytest.raises(ValueError, match="CR or LF"):
            TokpulseTracker(api_key="lct_test\r\nHeader: injected")

    def test_api_key_without_crlf_accepted(self) -> None:
        """BOUNDARY: normal api_key without CR/LF must be accepted."""
        tracker = TokpulseTracker(api_key="lct_valid_key_123", api_url="https://api.example.com")
        tracker.close()


# ---------------------------------------------------------------------------
# PY-H3: _SecretStr XOR obfuscation — inspect.getmembers() proof
# ---------------------------------------------------------------------------


class TestSecretStrXOR:
    """Tests that _SecretStr stores XOR-encoded bytes, not plaintext (PY-H3)."""

    def test_inspect_getmembers_does_not_expose_plaintext(self) -> None:
        """BOUNDARY: inspect.getmembers() must return _encoded (bytes), not plaintext."""
        secret = "super_secret_api_key_12345"
        s = _SecretStr(secret)
        members = dict(inspect.getmembers(s))
        # _encoded should be bytes, not the raw secret
        assert "_encoded" in members
        assert isinstance(members["_encoded"], bytes)
        # The raw secret must not appear as any member value
        for _name, value in members.items():
            assert value != secret, f"Plaintext secret found in member '{_name}'"

    def test_encoded_attribute_is_bytes_not_plaintext(self) -> None:
        """BOUNDARY: _encoded attribute must be bytes, not the original string."""
        s = _SecretStr("my_api_key")
        assert isinstance(s._encoded, bytes)
        assert s._encoded != b"my_api_key"

    def test_get_secret_value_roundtrips(self) -> None:
        """XOR encoding must be reversible via get_secret_value()."""
        original = "lct_test_key_with_special_chars_!@#"
        s = _SecretStr(original)
        assert s.get_secret_value() == original

    def test_repr_is_redacted(self) -> None:
        s = _SecretStr("should_not_appear")
        assert repr(s) == "**********"

    def test_hmac_compare_digest_equality(self) -> None:
        """PY-H3: equality must use hmac.compare_digest (timing-safe)."""
        a = _SecretStr("same_key")
        b = _SecretStr("same_key")
        assert a == b

    def test_hmac_compare_digest_inequality(self) -> None:
        a = _SecretStr("key_a")
        b = _SecretStr("key_b")
        assert a != b

    def test_empty_secret_roundtrips(self) -> None:
        """BOUNDARY: empty string must survive XOR round-trip."""
        s = _SecretStr("")
        assert s.get_secret_value() == ""
        assert repr(s) == "**********"


# ---------------------------------------------------------------------------
# PY-H4: on_error receives scrubbed httpx exceptions
# ---------------------------------------------------------------------------


class TestHttpxErrorScrubbing:
    """Tests that on_error callback receives exceptions without Authorization header (PY-H4)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_httpx_error_scrubbed_in_on_error(self) -> None:
        """BOUNDARY: on_error must receive an exception without the request object."""
        received_errors: list[Exception] = []

        def on_error(exc: Exception, payload: dict[str, Any]) -> None:
            received_errors.append(exc)

        tracker = self._make_tracker(on_error=on_error, max_retries=0)

        # Create a real httpx request with Authorization header
        request = httpx.Request(
            "POST",
            "https://api.example.com/v1/ingest",
            headers={
                "Authorization": "Bearer secret_key_12345",
            },
        )
        original_exc = httpx.ConnectError("Connection refused", request=request)

        tracker._report_error(original_exc, {"request_id": "test"})

        assert len(received_errors) == 1
        scrubbed = received_errors[0]
        # The scrubbed exception should not carry the original request with auth headers.
        # httpx exceptions created without a request raise RuntimeError on .request access.
        assert isinstance(scrubbed, httpx.ConnectError)
        with pytest.raises(RuntimeError, match="request property has not been set"):
            _ = scrubbed.request  # type: ignore[union-attr]
        tracker.close()

    def test_non_httpx_error_passes_through(self) -> None:
        """BOUNDARY: non-httpx exceptions must pass through unchanged."""
        received_errors: list[Exception] = []

        def on_error(exc: Exception, payload: dict[str, Any]) -> None:
            received_errors.append(exc)

        tracker = self._make_tracker(on_error=on_error)
        original = RuntimeError("some error")
        tracker._report_error(original, {"request_id": "test"})

        assert len(received_errors) == 1
        assert received_errors[0] is original
        tracker.close()


# ---------------------------------------------------------------------------
# PY-H5: SSRF blocked for private/metadata IPs
# ---------------------------------------------------------------------------


class TestSSRFProtection:
    """Tests that SSRF to private/metadata IPs is blocked (PY-H5)."""

    def test_ssrf_blocked_https_aws_metadata(self) -> None:
        """BOUNDARY: https://169.254.169.254 must raise ValueError."""
        with pytest.raises(ValueError, match="private or metadata"):
            TokpulseTracker(api_key="lct_test", api_url="https://169.254.169.254")

    def test_ssrf_blocked_https_10_network(self) -> None:
        """BOUNDARY: https://10.0.0.1 must raise ValueError."""
        with pytest.raises(ValueError, match="private or metadata"):
            TokpulseTracker(api_key="lct_test", api_url="https://10.0.0.1")

    def test_ssrf_blocked_https_192_168(self) -> None:
        """BOUNDARY: https://192.168.1.1 must raise ValueError."""
        with pytest.raises(ValueError, match="private or metadata"):
            TokpulseTracker(api_key="lct_test", api_url="https://192.168.1.1")

    def test_ssrf_blocked_https_172_16(self) -> None:
        """BOUNDARY: https://172.16.0.1 must raise ValueError."""
        with pytest.raises(ValueError, match="private or metadata"):
            TokpulseTracker(api_key="lct_test", api_url="https://172.16.0.1")

    def test_ssrf_allowed_legitimate_https(self) -> None:
        """BOUNDARY: https://api.tokpulse.io must be allowed."""
        tracker = TokpulseTracker(api_key="lct_test", api_url="https://api.tokpulse.io")
        tracker.close()

    def test_ssrf_allowed_localhost(self) -> None:
        """BOUNDARY: localhost is allowed (for local development)."""
        tracker = TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
        )
        tracker.close()

    def test_ssrf_blocked_http_private_ip(self) -> None:
        """BOUNDARY: http://10.0.0.1 with allow_insecure_http must raise for SSRF."""
        with pytest.raises(ValueError, match="private or metadata"):
            TokpulseTracker(
                api_key="lct_test",
                api_url="http://10.0.0.1",
                allow_insecure_http=True,
            )

    def test_ssrf_blocked_hostname_resolving_to_private_ip(self) -> None:
        """BOUNDARY: DNS rebinding — hostname that resolves to 169.254.169.254 is blocked."""
        # getaddrinfo returns list of (family, type, proto, canonname, sockaddr)
        # sockaddr for IPv4 is (address, port)
        fake_infos = [(2, 1, 6, "", ("169.254.169.254", 0))]
        with patch("tokpulse.tracker._socket.getaddrinfo", return_value=fake_infos):
            with pytest.raises(ValueError, match="private or metadata"):
                TokpulseTracker(api_key="lct_test", api_url="https://evil.attacker.com")

    def test_ssrf_blocked_hostname_resolving_to_10_network(self) -> None:
        """BOUNDARY: hostname that resolves to 10.0.0.1 (RFC-1918) is blocked."""
        fake_infos = [(2, 1, 6, "", ("10.0.0.1", 0))]
        with patch("tokpulse.tracker._socket.getaddrinfo", return_value=fake_infos):
            with pytest.raises(ValueError, match="private or metadata"):
                TokpulseTracker(api_key="lct_test", api_url="https://internal.corp")

    def test_ssrf_blocked_hostname_one_of_multiple_addrs_is_private(self) -> None:
        """BOUNDARY: if any resolved address is private, the hostname is blocked."""
        fake_infos = [
            (2, 1, 6, "", ("203.0.113.1", 0)),  # public (TEST-NET-3)
            (2, 1, 6, "", ("192.168.1.50", 0)),  # private
        ]
        with patch("tokpulse.tracker._socket.getaddrinfo", return_value=fake_infos):
            with pytest.raises(ValueError, match="private or metadata"):
                TokpulseTracker(api_key="lct_test", api_url="https://dual-stack.example.com")

    def test_ssrf_allowed_hostname_resolving_to_public_ip(self) -> None:
        """BOUNDARY: hostname resolving to a public IP must be allowed."""
        fake_infos = [(2, 1, 6, "", ("203.0.113.42", 0))]
        with patch("tokpulse.tracker._socket.getaddrinfo", return_value=fake_infos):
            tracker = TokpulseTracker(api_key="lct_test", api_url="https://api.example.com")
            tracker.close()

    def test_ssrf_dns_resolution_failure_fails_open(self) -> None:
        """BOUNDARY: if DNS resolution fails, fail-open (allow) so valid production URLs
        pass validation in environments where the hostname is unreachable at build time."""
        import socket

        with patch(
            "tokpulse.tracker._socket.getaddrinfo",
            side_effect=socket.gaierror("Name or service not known"),
        ):
            tracker = TokpulseTracker(
                api_key="lct_test", api_url="https://api.prod-internal.example.com"
            )
            tracker.close()

    def test_ssrf_blocked_ipv6_link_local_hostname(self) -> None:
        """BOUNDARY: hostname resolving to an IPv6 link-local address is blocked."""
        # fe80::/10 is link-local; fc00::/7 is ULA — both in _PRIVATE_NETWORKS
        fake_infos = [(10, 1, 6, "", ("fc00::1", 0, 0, 0))]  # AF_INET6 sockaddr
        with patch("tokpulse.tracker._socket.getaddrinfo", return_value=fake_infos):
            with pytest.raises(ValueError, match="private or metadata"):
                TokpulseTracker(api_key="lct_test", api_url="https://ipv6-internal.example.com")


# ---------------------------------------------------------------------------
# PY-H6: project / environment label validation
# ---------------------------------------------------------------------------


class TestProjectEnvironmentValidation:
    """Tests for project/environment label validation (PY-H6)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_project_with_invalid_chars_rejected(self) -> None:
        """BOUNDARY: project with spaces must raise ValueError."""
        tracker = self._make_tracker(project="my project")
        with pytest.raises(ValueError, match="project must be"):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_project_too_long_rejected(self) -> None:
        """BOUNDARY: project >128 chars must raise ValueError."""
        tracker = self._make_tracker(project="a" * 129)
        with pytest.raises(ValueError, match="project must be"):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_project_at_128_chars_accepted(self) -> None:
        """BOUNDARY: project at exactly 128 chars must be accepted."""
        tracker = self._make_tracker(project="a" * 128)
        tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_environment_with_invalid_chars_rejected(self) -> None:
        """BOUNDARY: environment with semicolons must raise ValueError."""
        tracker = self._make_tracker(environment="prod;DROP TABLE")
        with pytest.raises(ValueError, match="environment must be"):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_environment_too_long_rejected(self) -> None:
        """BOUNDARY: environment >64 chars must raise ValueError."""
        tracker = self._make_tracker(environment="e" * 65)
        with pytest.raises(ValueError, match="environment must be"):
            tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_environment_at_64_chars_accepted(self) -> None:
        """BOUNDARY: environment at exactly 64 chars must be accepted."""
        tracker = self._make_tracker(environment="e" * 64)
        tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_valid_project_and_environment(self) -> None:
        tracker = self._make_tracker(project="my-project_v1.0", environment="staging/us-east-1")
        tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_none_project_and_environment_accepted(self) -> None:
        tracker = self._make_tracker(project=None, environment=None)
        tracker.track(provider="openai", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()


# ---------------------------------------------------------------------------
# PY-M8: Regex \Z prevents trailing newline bypass
# ---------------------------------------------------------------------------


class TestRegexTrailingNewline:
    """Tests that provider/model regex uses \\Z to block trailing newline (PY-M8)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_provider_with_trailing_newline_rejected(self) -> None:
        """BOUNDARY: provider 'openai\\n' must now be rejected with \\Z."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="invalid characters"):
            tracker.track(provider="openai\n", model="gpt-4o", input_tokens=1, output_tokens=1)
        tracker.close()

    def test_model_with_trailing_newline_rejected(self) -> None:
        """BOUNDARY: model 'gpt-4o\\n' must now be rejected with \\Z."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="invalid characters"):
            tracker.track(provider="openai", model="gpt-4o\n", input_tokens=1, output_tokens=1)
        tracker.close()


# ---------------------------------------------------------------------------
# PY-M9: max_retries capped at 10
# ---------------------------------------------------------------------------


class TestMaxRetriesCap:
    """Tests that max_retries is capped at 10 (PY-M9)."""

    def test_max_retries_11_rejected(self) -> None:
        """BOUNDARY: max_retries=11 must raise ValueError."""
        with pytest.raises(ValueError, match="must not exceed 10"):
            TokpulseTracker(
                api_key="lct_test",
                api_url="http://localhost:9999",
                allow_insecure_http=True,
                max_retries=11,
            )

    def test_max_retries_10_accepted(self) -> None:
        """BOUNDARY: max_retries=10 must be accepted."""
        tracker = TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            max_retries=10,
        )
        tracker.close()

    def test_max_retries_100_rejected(self) -> None:
        """BOUNDARY: very large max_retries must raise ValueError."""
        with pytest.raises(ValueError, match="must not exceed 10"):
            TokpulseTracker(
                api_key="lct_test",
                api_url="http://localhost:9999",
                allow_insecure_http=True,
                max_retries=100,
            )


# ---------------------------------------------------------------------------
# PY-M10: Token counts bounded at 10_000_000
# ---------------------------------------------------------------------------


class TestTokenCountBound:
    """Tests that token counts are bounded at 10_000_000 (PY-M10)."""

    def _make_tracker(self, **kwargs: Any) -> TokpulseTracker:
        return TokpulseTracker(
            api_key="lct_test",
            api_url="http://localhost:9999",
            allow_insecure_http=True,
            flush_interval_seconds=3600,
            **kwargs,
        )

    def test_input_tokens_over_limit_rejected(self) -> None:
        """BOUNDARY: input_tokens > 10_000_000 must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="input_tokens must not exceed"):
            tracker.track(
                provider="openai",
                model="gpt-4o",
                input_tokens=10_000_001,
                output_tokens=1,
            )
        tracker.close()

    def test_output_tokens_over_limit_rejected(self) -> None:
        """BOUNDARY: output_tokens > 10_000_000 must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="output_tokens must not exceed"):
            tracker.track(
                provider="openai",
                model="gpt-4o",
                input_tokens=1,
                output_tokens=10_000_001,
            )
        tracker.close()

    def test_thinking_tokens_over_limit_rejected(self) -> None:
        """BOUNDARY: thinking_tokens > 10_000_000 must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="thinking_tokens must not exceed"):
            tracker.track(
                provider="anthropic",
                model="claude-3",
                input_tokens=1,
                output_tokens=1,
                thinking_tokens=10_000_001,
            )
        tracker.close()

    def test_token_count_at_limit_accepted(self) -> None:
        """BOUNDARY: token count at exactly 10_000_000 must be accepted."""
        tracker = self._make_tracker()
        tracker.track(
            provider="openai",
            model="gpt-4o",
            input_tokens=10_000_000,
            output_tokens=10_000_000,
        )
        tracker.close()

    def test_cached_input_tokens_over_limit_rejected(self) -> None:
        """BOUNDARY: cached_input_tokens > 10_000_000 must raise ValueError."""
        tracker = self._make_tracker()
        with pytest.raises(ValueError, match="cached_input_tokens must not exceed"):
            tracker.track(
                provider="openai",
                model="gpt-4o",
                input_tokens=1,
                output_tokens=1,
                cached_input_tokens=10_000_001,
            )
        tracker.close()


# ---------------------------------------------------------------------------
# PY-M11: allow_insecure_http=True emits SecurityWarning (not ValueError)
# ---------------------------------------------------------------------------


class TestInsecureHttpWarningM11:
    """Tests that allow_insecure_http=True with non-localhost HTTP emits warning (PY-M11)."""

    def test_insecure_http_opt_in_emits_warning(self) -> None:
        """BOUNDARY: allow_insecure_http=True + non-localhost HTTP must emit SecurityWarning."""
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            tracker = TokpulseTracker(
                api_key="lct_test",
                api_url="http://example.com",
                allow_insecure_http=True,
            )
            tracker.close()

        security_warnings = [w for w in caught if issubclass(w.category, SecurityWarning)]
        assert len(security_warnings) >= 1
        assert "example.com" in str(security_warnings[0].message)

    def test_insecure_http_no_opt_in_raises(self) -> None:
        """BOUNDARY: allow_insecure_http=False + non-localhost HTTP must raise ValueError."""
        with pytest.raises(ValueError, match="Insecure HTTP"):
            TokpulseTracker(
                api_key="lct_test",
                api_url="http://example.com",
            )

    def test_localhost_http_no_warning(self) -> None:
        """BOUNDARY: localhost HTTP must not emit SecurityWarning."""
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            tracker = TokpulseTracker(
                api_key="lct_test",
                api_url="http://localhost:9999",
                allow_insecure_http=True,
            )
            tracker.close()

        security_warnings = [w for w in caught if issubclass(w.category, SecurityWarning)]
        assert len(security_warnings) == 0
