"""Main tracker class for LLM cost tracking."""

from __future__ import annotations

import asyncio
import atexit
import hmac as _hmac
import ipaddress as _ipaddress
import itertools
import logging
import os as _os
import queue
import re
import socket as _socket
import threading
import time
import uuid
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable, overload
from urllib.parse import urlparse

import httpx

if TYPE_CHECKING:
    from anthropic import Anthropic as _Anthropic
    from anthropic import AsyncAnthropic as _AsyncAnthropic
    from google.generativeai.generative_models import GenerativeModel as _GenerativeModel
    from openai import AsyncOpenAI as _AsyncOpenAI
    from openai import OpenAI as _OpenAI

from tokpulse.wrappers import (
    AnthropicWrapper,
    AsyncAnthropicWrapper,
    AsyncOpenAIWrapper,
    GoogleWrapper,
    OpenAIWrapper,
)

logger = logging.getLogger(__name__)


class SecurityWarning(UserWarning):
    """Warning raised when a security risk is detected (e.g. plaintext HTTP)."""


# ---------------------------------------------------------------------------
# _SecretStr — PY-H1: wraps API key so it never leaks in repr/str/tracebacks
# ---------------------------------------------------------------------------

_PROVIDER_MODEL_RE = re.compile(r"^[a-zA-Z0-9._/\-]+\Z")
_LABEL_RE = re.compile(r"^[a-zA-Z0-9._/\-]+\Z")
_PROVIDER_MODEL_MAX_LEN = 256
_MAX_TOKEN_COUNT = 10_000_000


_MASK_KEY = _os.urandom(32)


class _SecretStr:
    """Opaque wrapper for sensitive string values.

    The raw value is XOR-encoded in memory so that ``inspect.getmembers()``
    cannot recover the plaintext.  The value is intentionally hidden from
    ``repr``, ``str``, ``format``, and therefore from tracebacks, log lines,
    and debugger output.  Call ``.get_secret_value()`` when you need the
    actual string.
    """

    __slots__ = ("_encoded",)

    def __init__(self, value: str) -> None:
        raw = value.encode()
        key = (_MASK_KEY * ((len(raw) // 32) + 1))[: len(raw)]
        self._encoded = bytes(a ^ b for a, b in zip(raw, key))

    def get_secret_value(self) -> str:
        """Return the raw secret string."""
        key = (_MASK_KEY * ((len(self._encoded) // 32) + 1))[: len(self._encoded)]
        return bytes(a ^ b for a, b in zip(self._encoded, key)).decode()

    def __repr__(self) -> str:
        return "**********"

    def __str__(self) -> str:
        return "**********"

    def __format__(self, format_spec: str) -> str:
        return "**********"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, _SecretStr):
            return NotImplemented
        return _hmac.compare_digest(self._encoded, other._encoded)

    def __hash__(self) -> int:
        return hash(self._encoded)


@dataclass
class TrackerConfig:
    """Configuration for the LLM cost tracker."""

    # PY-H1: stored as _SecretStr so the raw value never leaks in repr/str.
    api_key: _SecretStr = field(repr=False)
    api_url: str = "https://api.tokpulse.io"
    allow_insecure_http: bool = False
    batch_size: int = 10
    flush_interval_seconds: float = 5.0
    max_queue_size: int = 1000
    # PY-M5: configurable retry queue size (default 500).
    max_retry_queue_size: int = 500
    project: str | None = None
    environment: str | None = None
    debug: bool = False
    on_error: Callable[[Exception, dict[str, Any]], None] | None = None
    timeout_seconds: float = 5.0
    max_retries: int = 3


@dataclass
class UsageEvent:
    """A single usage event to be sent to the API."""

    request_id: str
    provider: str
    model: str
    input_tokens: int
    output_tokens: int
    timestamp: int
    cached_input_tokens: int | None = None
    cached_read_tokens: int | None = None
    cached_write_tokens: int | None = None
    thinking_tokens: int | None = None
    project: str | None = None
    environment: str | None = None
    metadata: dict[str, str] | None = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for API submission."""
        data: dict[str, Any] = {
            "request_id": self.request_id,
            "provider": self.provider,
            "model": self.model,
            "input_tokens": self.input_tokens,
            "output_tokens": self.output_tokens,
            "timestamp": self.timestamp,
        }

        if self.cached_input_tokens is not None:
            data["cached_input_tokens"] = self.cached_input_tokens
        if self.cached_read_tokens is not None:
            data["cached_read_tokens"] = self.cached_read_tokens
        if self.cached_write_tokens is not None:
            data["cached_write_tokens"] = self.cached_write_tokens
        if self.thinking_tokens is not None:
            data["thinking_tokens"] = self.thinking_tokens
        if self.project:
            data["project"] = self.project
        if self.environment:
            data["environment"] = self.environment
        if self.metadata:
            data["metadata"] = self.metadata

        return data


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

_SAFE_HOSTS = {"localhost", "127.0.0.1", "::1"}

_BLOCKED_HOSTNAMES = frozenset(
    {"169.254.169.254", "metadata.google.internal", "instance-data", "metadata.internal"}
)
_PRIVATE_NETWORKS = [
    _ipaddress.ip_network("10.0.0.0/8"),
    _ipaddress.ip_network("172.16.0.0/12"),
    _ipaddress.ip_network("192.168.0.0/16"),
    _ipaddress.ip_network("127.0.0.0/8"),
    _ipaddress.ip_network("169.254.0.0/16"),
    _ipaddress.ip_network("::1/128"),
    _ipaddress.ip_network("fc00::/7"),
]


def _is_private_host(hostname: str) -> bool:
    """Return True if *hostname* is or resolves to a private/metadata IP range.

    For literal IP addresses the check is purely in-process.  For hostnames,
    DNS is resolved at validation time to prevent DNS-rebinding attacks where a
    public-looking hostname points to an internal address (e.g. IMDS at
    169.254.169.254).  If DNS resolution fails the check fails-open (returns
    False) so that URLs valid only inside the deployment environment still pass
    construction-time validation.
    """
    if hostname in _BLOCKED_HOSTNAMES:
        return True
    try:
        addr = _ipaddress.ip_address(hostname)
        return any(addr in net for net in _PRIVATE_NETWORKS)
    except ValueError:
        pass  # not a literal IP — fall through to DNS resolution

    # Resolve hostname and check every returned address.
    try:
        infos = _socket.getaddrinfo(hostname, None)
    except _socket.gaierror:
        # DNS resolution failed; fail-open so valid production URLs aren't
        # rejected in environments where the hostname is unreachable at
        # construction time (e.g. CI, local dev without the service running).
        return False

    for info in infos:
        # sockaddr[0] is always the address string; str() satisfies the type-checker
        # since the socket stubs type sockaddr as a union that includes int fields.
        ip_str = str(info[4][0])
        # Strip IPv6 zone-id if present (e.g. "fe80::1%eth0" → "fe80::1")
        ip_str = ip_str.split("%")[0]
        try:
            addr = _ipaddress.ip_address(ip_str)
            if any(addr in net for net in _PRIVATE_NETWORKS):
                return True
        except ValueError:
            continue

    return False


def _warn_insecure_http(api_url: str, allow_insecure_http: bool) -> None:
    """Emit a SecurityWarning when the API key would travel over cleartext HTTP."""
    if not allow_insecure_http:
        return
    parsed = urlparse(api_url)
    if parsed.scheme != "http":
        return
    host = (parsed.hostname or "").lower()
    if host not in _SAFE_HOSTS:
        warnings.warn(
            f"API key will be sent over unencrypted HTTP to '{host}'. "
            "This is a security risk. Use HTTPS in production.",
            SecurityWarning,
            stacklevel=3,
        )


class TokpulseTracker:
    """
    Main tracker class for capturing and sending LLM usage data.

    Example:
        tracker = TokpulseTracker(api_key="lct_xxx")
        openai_client = tracker.openai(OpenAI())
        response = openai_client.chat.completions.create(...)
        tracker.flush()  # Ensure all events are sent

    Async example:
        tracker = TokpulseTracker(api_key="lct_xxx")
        async_client = tracker.openai_async(AsyncOpenAI())
        response = await async_client.chat.completions.create(...)
        await tracker.aflush()
    """

    def __init__(
        self,
        api_key: str,
        api_url: str = "https://api.tokpulse.io",
        allow_insecure_http: bool = False,
        batch_size: int = 10,
        flush_interval_seconds: float = 5.0,
        max_queue_size: int = 1000,
        max_retry_queue_size: int = 500,
        project: str | None = None,
        environment: str | None = None,
        debug: bool = False,
        on_error: Callable[[Exception, dict[str, Any]], None] | None = None,
        timeout_seconds: float = 5.0,
        max_retries: int = 3,
    ):
        self._validate_constructor_config(
            api_key=api_key,
            api_url=api_url,
            allow_insecure_http=allow_insecure_http,
            batch_size=batch_size,
            flush_interval_seconds=flush_interval_seconds,
            max_queue_size=max_queue_size,
            max_retry_queue_size=max_retry_queue_size,
            timeout_seconds=timeout_seconds,
            max_retries=max_retries,
        )

        # PY-H2: warn when the API key is about to be sent over plaintext HTTP
        # to a non-localhost host, even if the caller opted in.
        _warn_insecure_http(api_url, allow_insecure_http)

        # PY-H1: wrap api_key in _SecretStr before storing it in config.
        self.config = TrackerConfig(
            api_key=_SecretStr(api_key),
            api_url=api_url.rstrip("/"),
            allow_insecure_http=allow_insecure_http,
            batch_size=batch_size,
            flush_interval_seconds=flush_interval_seconds,
            max_queue_size=max_queue_size,
            max_retry_queue_size=max_retry_queue_size,
            project=project,
            environment=environment,
            debug=debug,
            on_error=on_error,
            timeout_seconds=timeout_seconds,
            max_retries=max_retries,
        )

        if debug:
            logger.setLevel(logging.DEBUG)
            if not logger.handlers:
                logger.addHandler(logging.StreamHandler())

        self._closed = False
        self._close_lock = threading.Lock()

        self._queue: queue.Queue[UsageEvent] = queue.Queue(maxsize=max_queue_size)
        # due_time (monotonic), sequence, attempt, event
        # PY-M5: use config.max_retry_queue_size instead of a hardcoded value.
        self._retry_queue: queue.PriorityQueue[tuple[float, int, int, UsageEvent]] = (
            queue.PriorityQueue(maxsize=self.config.max_retry_queue_size)
        )
        self._retry_sequence = itertools.count()

        self._stop_event = threading.Event()
        # Set by track() when batch_size is reached; wakes _flush_loop early.
        self._flush_trigger = threading.Event()
        self._flush_thread: threading.Thread | None = None
        self._retry_thread: threading.Thread | None = None
        self._client = httpx.Client(follow_redirects=False, timeout=timeout_seconds)

        self._start_flush_thread()
        self._start_retry_thread()

        atexit.register(self.close)

    def _start_flush_thread(self) -> None:
        """Start the background thread that periodically flushes events."""
        self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
        self._flush_thread.start()

    def _start_retry_thread(self) -> None:
        """Start the background thread that handles delayed retries."""
        self._retry_thread = threading.Thread(target=self._retry_loop, daemon=True)
        self._retry_thread.start()

    def _flush_loop(self) -> None:
        """Background loop that flushes events periodically or when batch_size is reached."""
        while True:
            # Wait for flush_interval; wakes early if batch_size reached or close() called.
            self._flush_trigger.wait(timeout=self.config.flush_interval_seconds)
            if self._stop_event.is_set():
                break
            self._flush_trigger.clear()
            self._flush_batch(allow_retry=True)

    def _retry_loop(self) -> None:
        """Background loop that sends retry events when their delay expires."""
        while not self._stop_event.is_set():
            try:
                due_time, sequence, attempt, event = self._retry_queue.get(timeout=0.5)
            except queue.Empty:
                continue

            now = time.monotonic()
            if due_time > now:
                # Not due yet; put it back and wait briefly.
                self._retry_queue.put((due_time, sequence, attempt, event))
                self._stop_event.wait(timeout=min(due_time - now, 0.5))
                continue

            self._send_event(event, attempt=attempt, allow_retry=True)

    def _flush_batch(self, allow_retry: bool, max_events: int | None = None) -> int:
        """Drain pending events from the main queue and send each one."""
        events: list[UsageEvent] = []

        while max_events is None or len(events) < max_events:
            try:
                events.append(self._queue.get_nowait())
            except queue.Empty:
                break

        for event in events:
            self._send_event(event, attempt=0, allow_retry=allow_retry)

        return len(events)

    def _schedule_retry(self, event: UsageEvent, next_attempt: int, delay_seconds: int) -> None:
        due_time = time.monotonic() + delay_seconds
        sequence = next(self._retry_sequence)
        try:
            self._retry_queue.put_nowait((due_time, sequence, next_attempt, event))
        except queue.Full:
            logger.warning("retry queue full, dropping event %s", event.request_id)

    def _send_event(self, event: UsageEvent, attempt: int, allow_retry: bool) -> None:
        """Send a single event and optionally schedule retry attempts."""
        payload = event.to_dict()

        try:
            response = self._client.post(
                f"{self.config.api_url}/v1/ingest",
                json=payload,
                headers={
                    # PY-H1: retrieve raw secret via get_secret_value() — never use str(api_key).
                    "Authorization": f"Bearer {self.config.api_key.get_secret_value()}",
                    "Content-Type": "application/json",
                    "User-Agent": "tokpulse-python/0.1.0",
                },
            )

            if response.status_code == 202:
                logger.debug("Event sent successfully: %s", event.request_id)
                return

            if response.status_code == 200:
                logger.debug("Duplicate event (already ingested): %s", event.request_id)
                return

            # PY-M4: consume the response body on all non-2xx paths so the
            # underlying connection is returned to the pool and not leaked.
            response.read()

            if response.status_code == 429 or response.status_code >= 500:
                logger.warning(
                    "Retryable error %s for event %s", response.status_code, event.request_id
                )
                self._retry_or_report(
                    event=event,
                    payload=payload,
                    attempt=attempt,
                    allow_retry=allow_retry,
                    error=Exception(f"HTTP {response.status_code}: max retries exhausted"),
                )
                return

            logger.warning(
                "Non-retryable error %s for event %s", response.status_code, event.request_id
            )
            self._report_error(Exception(f"HTTP {response.status_code}"), payload)

        except Exception as exc:  # noqa: BLE001 — catch all httpx errors (network, timeout, etc.)
            logger.error("Failed to send event: %s", exc)
            self._retry_or_report(
                event=event,
                payload=payload,
                attempt=attempt,
                allow_retry=allow_retry,
                error=exc,
            )

    def _retry_or_report(
        self,
        event: UsageEvent,
        payload: dict[str, Any],
        attempt: int,
        allow_retry: bool,
        error: Exception,
    ) -> None:
        if allow_retry and not self._stop_event.is_set() and attempt < self.config.max_retries:
            self._schedule_retry(event, next_attempt=attempt + 1, delay_seconds=2**attempt)
            return

        self._report_error(error, payload)

    @staticmethod
    def _scrub_httpx_error(exc: Exception) -> Exception:
        """Strip the request object (which carries auth headers) from httpx errors."""
        if isinstance(exc, httpx.RequestError):
            return type(exc)(str(exc))
        return exc

    def _report_error(self, error: Exception, payload: dict[str, Any]) -> None:
        if self.config.on_error:
            try:
                # PY-H4: strip Authorization headers from httpx exceptions.
                scrubbed = self._scrub_httpx_error(error)
                # PY-M3: sanitize metadata before passing payload to on_error
                # so user-supplied values are not leaked to error handlers.
                sanitized = payload.copy()
                if "metadata" in sanitized and isinstance(sanitized["metadata"], dict):
                    sanitized["metadata"] = {k: "[REDACTED]" for k in sanitized["metadata"]}
                self.config.on_error(scrubbed, sanitized)
            except Exception:
                logger.exception("on_error callback raised an exception; ignoring")

    def track(
        self,
        provider: str,
        model: str,
        input_tokens: int,
        output_tokens: int,
        cached_input_tokens: int | None = None,
        cached_read_tokens: int | None = None,
        cached_write_tokens: int | None = None,
        thinking_tokens: int | None = None,
        metadata: dict[str, str] | None = None,
    ) -> None:
        """
        Track a single LLM usage event.

        Args:
            provider: The LLM provider (openai, anthropic, google)
            model: The model name
            input_tokens: Number of input tokens
            output_tokens: Number of output tokens
            cached_input_tokens: OpenAI/Google cached input tokens
            cached_read_tokens: Anthropic cache read tokens
            cached_write_tokens: Anthropic cache write tokens
            thinking_tokens: Anthropic/Google thinking tokens
            metadata: Additional metadata (string keys and values only)

        Raises:
            RuntimeError: If the tracker has been closed.

        Note:
            If the queue is full (maxQueueSize reached), the event is silently
            dropped with a warning log. This is non-blocking by design.
        """
        if self._closed:
            raise RuntimeError("tracker is closed")

        self._validate_track_inputs(
            provider=provider,
            model=model,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            cached_input_tokens=cached_input_tokens,
            cached_read_tokens=cached_read_tokens,
            cached_write_tokens=cached_write_tokens,
            thinking_tokens=thinking_tokens,
            metadata=metadata,
        )

        event = UsageEvent(
            request_id=str(uuid.uuid4()),
            provider=provider,
            model=model,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            timestamp=int(time.time() * 1000),
            cached_input_tokens=cached_input_tokens,
            cached_read_tokens=cached_read_tokens,
            cached_write_tokens=cached_write_tokens,
            thinking_tokens=thinking_tokens,
            project=self.config.project,
            environment=self.config.environment,
            metadata=metadata,
        )

        try:
            self._queue.put_nowait(event)
        except queue.Full:
            logger.warning("Event queue full, dropping event")
            return

        # Wake the flush thread immediately once the batch_size threshold is reached.
        # Only signal early flush if not already shutting down
        if not self._closed and self._queue.qsize() >= self.config.batch_size:
            self._flush_trigger.set()

    def flush(self, max_events: int | None = None) -> None:
        """
        Flush pending events immediately without blocking on retry backoff.

        Args:
            max_events: Maximum number of events to flush. Defaults to all queued events.

        Note:
            This method blocks the calling thread for the duration of the HTTP sends
            (bounded by timeout_seconds per event). Use aflush() from async code.
        """
        if max_events is not None and max_events < 0:
            raise ValueError("max_events must be non-negative")

        self._flush_batch(allow_retry=True, max_events=max_events)

    async def aflush(self, max_events: int | None = None) -> None:
        """
        Async-compatible flush. Runs flush() in a thread pool so it does not block
        the event loop.

        Args:
            max_events: Maximum number of events to flush. Defaults to all queued events.
        """
        await asyncio.to_thread(self.flush, max_events)

    async def aclose(self) -> None:
        """
        Async-compatible close. Runs close() in a thread pool so it does not block
        the event loop.
        """
        await asyncio.to_thread(self.close)

    def _drain_retry_queue_for_close(self) -> None:
        """Drain scheduled retries and attempt to send each once during shutdown."""
        while True:
            try:
                _, _, _, event = self._retry_queue.get_nowait()
            except queue.Empty:
                break
            self._send_event(event, attempt=self.config.max_retries, allow_retry=False)

    def close(self) -> None:
        """
        Close the tracker and flush remaining events.

        This method stops background threads, flushes all queued events once
        (without scheduling new retries), and closes the HTTP client.
        Subsequent calls return immediately (idempotent).

        Note:
            This method blocks the calling thread. The blocking duration is bounded
            by timeout_seconds × number of queued events. Use aclose() from async code.
        """
        with self._close_lock:
            if self._closed:
                return

            # PY-M2: set _closed FIRST so that any concurrent track() calls
            # that are racing close() see the flag and refuse to enqueue new
            # events before we start draining the queue.
            self._closed = True

            self._stop_event.set()
            self._flush_trigger.set()  # Wake _flush_loop immediately

            if self._flush_thread is not None:
                self._flush_thread.join()
            if self._retry_thread is not None:
                self._retry_thread.join()

            # Drain everything one final time without scheduling delayed retries.
            self._flush_batch(allow_retry=False)
            self._drain_retry_queue_for_close()
            self._client.close()

            try:
                atexit.unregister(self.close)
            except Exception:  # pragma: no cover - not critical and platform-dependent
                pass

    def __enter__(self) -> TokpulseTracker:
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        self.close()

    @overload
    def openai(self, client: _OpenAI) -> _OpenAI: ...

    @overload
    def openai(self, client: Any) -> Any: ...

    def openai(self, client: Any) -> Any:
        """Wrap a synchronous OpenAI client for automatic cost tracking."""
        return OpenAIWrapper(client, self)

    @overload
    def openai_async(self, client: _AsyncOpenAI) -> _AsyncOpenAI: ...

    @overload
    def openai_async(self, client: Any) -> Any: ...

    def openai_async(self, client: Any) -> Any:
        """Wrap an asynchronous OpenAI client for automatic cost tracking."""
        return AsyncOpenAIWrapper(client, self)

    @overload
    def anthropic(self, client: _Anthropic) -> _Anthropic: ...

    @overload
    def anthropic(self, client: Any) -> Any: ...

    def anthropic(self, client: Any) -> Any:
        """Wrap a synchronous Anthropic client for automatic cost tracking."""
        return AnthropicWrapper(client, self)

    @overload
    def anthropic_async(self, client: _AsyncAnthropic) -> _AsyncAnthropic: ...

    @overload
    def anthropic_async(self, client: Any) -> Any: ...

    def anthropic_async(self, client: Any) -> Any:
        """Wrap an asynchronous Anthropic client for automatic cost tracking."""
        return AsyncAnthropicWrapper(client, self)

    @overload
    def google(self, model: _GenerativeModel) -> _GenerativeModel: ...

    @overload
    def google(self, model: Any) -> Any: ...

    def google(self, model: Any) -> Any:
        """Wrap a Google GenerativeAI model for automatic cost tracking."""
        return GoogleWrapper(model, self)

    def _validate_constructor_config(
        self,
        api_key: str,
        api_url: str,
        allow_insecure_http: bool,
        batch_size: int,
        flush_interval_seconds: float,
        max_queue_size: int,
        max_retry_queue_size: int,
        timeout_seconds: float,
        max_retries: int,
    ) -> None:
        if not isinstance(api_key, str) or api_key.strip() == "":
            raise ValueError("api_key must be a non-empty string")
        if "\r" in api_key or "\n" in api_key:
            raise ValueError("api_key must not contain CR or LF characters")
        if not isinstance(allow_insecure_http, bool):
            raise ValueError("allow_insecure_http must be a boolean")
        self._validate_api_url(api_url, allow_insecure_http)

        if isinstance(batch_size, bool) or not isinstance(batch_size, int) or batch_size <= 0:
            raise ValueError("batch_size must be positive")
        if (
            isinstance(flush_interval_seconds, bool)
            or not isinstance(flush_interval_seconds, (int, float))
            or flush_interval_seconds <= 0
        ):
            raise ValueError("flush_interval_seconds must be positive")
        if (
            isinstance(max_queue_size, bool)
            or not isinstance(max_queue_size, int)
            or max_queue_size <= 0
        ):
            raise ValueError("max_queue_size must be positive")
        # PY-M5: validate the configurable retry queue size.
        if (
            isinstance(max_retry_queue_size, bool)
            or not isinstance(max_retry_queue_size, int)
            or max_retry_queue_size <= 0
        ):
            raise ValueError("max_retry_queue_size must be positive")
        if (
            isinstance(timeout_seconds, bool)
            or not isinstance(timeout_seconds, (int, float))
            or timeout_seconds <= 0
        ):
            raise ValueError("timeout_seconds must be positive")
        if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
            raise ValueError("max_retries must be non-negative")
        if max_retries > 10:
            raise ValueError("max_retries must not exceed 10")

    @staticmethod
    def _is_localhost(hostname: str) -> bool:
        """Return True if *hostname* is a recognised localhost alias."""
        return hostname in {"localhost", "127.0.0.1", "::1"}

    def _validate_api_url(self, api_url: str, allow_insecure_http: bool) -> None:
        parsed = urlparse(api_url)
        if parsed.scheme not in {"http", "https"} or not parsed.netloc:
            raise ValueError("api_url must be a valid absolute URL")

        hostname = (parsed.hostname or "").lower()

        # Block SSRF to private/metadata IPs regardless of scheme,
        # unless the target is genuine localhost (used for local dev).
        if not self._is_localhost(hostname) and _is_private_host(hostname):
            raise ValueError("api_url must not point to private or metadata IP addresses")

        if parsed.scheme == "http":
            if self._is_localhost(hostname):
                if not allow_insecure_http:
                    raise ValueError(
                        "Insecure HTTP api_url is only allowed for localhost "
                        "when allow_insecure_http=True"
                    )
            else:
                if allow_insecure_http:
                    warnings.warn(
                        f"api_url uses insecure HTTP to non-localhost host '{hostname}'",
                        SecurityWarning,
                        stacklevel=4,
                    )
                else:
                    raise ValueError(
                        "Insecure HTTP api_url is only allowed for localhost "
                        "when allow_insecure_http=True"
                    )

    def _validate_track_inputs(
        self,
        provider: str,
        model: str,
        input_tokens: int,
        output_tokens: int,
        cached_input_tokens: int | None,
        cached_read_tokens: int | None,
        cached_write_tokens: int | None,
        thinking_tokens: int | None,
        metadata: dict[str, str] | None,
    ) -> None:
        if not isinstance(provider, str) or provider.strip() == "":
            raise ValueError("provider must be a non-empty string")
        if len(provider) > _PROVIDER_MODEL_MAX_LEN:
            raise ValueError("provider must be 256 characters or fewer")
        # PY-M1: reject injection-style characters in provider.
        if not _PROVIDER_MODEL_RE.match(provider):
            raise ValueError(
                "provider contains invalid characters; "
                "only alphanumerics, '.', '_', '/', and '-' are allowed"
            )
        if not isinstance(model, str) or model.strip() == "":
            raise ValueError("model must be a non-empty string")
        if len(model) > _PROVIDER_MODEL_MAX_LEN:
            raise ValueError("model must be 256 characters or fewer")
        # PY-M1: reject injection-style characters in model.
        if not _PROVIDER_MODEL_RE.match(model):
            raise ValueError(
                "model contains invalid characters; "
                "only alphanumerics, '.', '_', '/', and '-' are allowed"
            )

        self._validate_non_negative_int("input_tokens", input_tokens)
        self._validate_non_negative_int("output_tokens", output_tokens)
        self._validate_optional_non_negative_int("cached_input_tokens", cached_input_tokens)
        self._validate_optional_non_negative_int("cached_read_tokens", cached_read_tokens)
        self._validate_optional_non_negative_int("cached_write_tokens", cached_write_tokens)
        self._validate_optional_non_negative_int("thinking_tokens", thinking_tokens)

        # PY-M10: reject unreasonably large token counts.
        for name, val in [
            ("input_tokens", input_tokens),
            ("output_tokens", output_tokens),
            ("cached_input_tokens", cached_input_tokens),
            ("cached_read_tokens", cached_read_tokens),
            ("cached_write_tokens", cached_write_tokens),
            ("thinking_tokens", thinking_tokens),
        ]:
            if val is not None and val > _MAX_TOKEN_COUNT:
                raise ValueError(f"{name} must not exceed {_MAX_TOKEN_COUNT}")

        # PY-H6: validate project / environment labels.
        project = self.config.project
        if project is not None:
            if len(project) > 128 or not _LABEL_RE.match(project):
                raise ValueError("project must be 1-128 chars, alphanumeric/._/-")
        environment = self.config.environment
        if environment is not None:
            if len(environment) > 64 or not _LABEL_RE.match(environment):
                raise ValueError("environment must be 1-64 chars, alphanumeric/._/-")

        if metadata is not None:
            if len(metadata) > 32:
                raise ValueError("metadata must have 32 or fewer entries")
            for k, v in metadata.items():
                if not isinstance(k, str):
                    raise ValueError("metadata keys must be strings")
                if len(k) > 128:
                    raise ValueError(f"metadata key '{k[:20]}...' exceeds 128 characters")
                if not isinstance(v, str):
                    raise ValueError("metadata values must be strings")
                if len(v) > 4096:
                    raise ValueError(f"metadata value for key '{k[:20]}' exceeds 4096 characters")

    def _validate_optional_non_negative_int(self, field: str, value: int | None) -> None:
        if value is not None:
            self._validate_non_negative_int(field, value)

    def _validate_non_negative_int(self, field: str, value: int) -> None:
        if isinstance(value, bool) or not isinstance(value, int) or value < 0:
            raise ValueError(f"{field} must be a non-negative integer")
