"""
HTTP transport implementation for Noveum Trace SDK.

This module handles HTTP communication with the Noveum platform,
including request formatting, authentication, and error handling.
"""

import base64
import json
import time
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path

# Import for type hints
from typing import TYPE_CHECKING, Any, NoReturn, Optional
from urllib.parse import urljoin
from uuid import UUID

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

from noveum_trace import __version__
from noveum_trace.core.config import DEFAULT_DEV_TRACES_DIR, get_config

if TYPE_CHECKING:
    from noveum_trace.core.config import Config

from noveum_trace.core.trace import Trace
from noveum_trace.transport.batch_processor import BatchProcessor
from noveum_trace.utils import otel_compat
from noveum_trace.utils.exceptions import TransportError
from noveum_trace.utils.logging import (
    get_sdk_logger,
    log_debug_enabled,
    log_error_always,
    log_http_request,
    log_http_response,
    log_trace_flow,
)
from noveum_trace.utils.pii_redaction import PiiPseudonymizer

_MOCK_TYPES: tuple[type[Any], ...]

try:
    from unittest.mock import (
        AsyncMock,
        MagicMock,
        Mock,
        NonCallableMagicMock,
        NonCallableMock,
    )

    _MOCK_TYPES = (Mock, MagicMock, AsyncMock, NonCallableMagicMock, NonCallableMock)
except ImportError:
    _MOCK_TYPES = ()

logger = get_sdk_logger("transport.http_transport")


class HttpTransport:
    """
    HTTP transport for sending traces to the Noveum platform.

    This class handles HTTP communication including authentication,
    request formatting, batching, retries, and error handling.
    """

    def __init__(self, config: Optional["Config"] = None) -> None:
        """Initialize the HTTP transport.

        Args:
            config: Optional configuration instance. If not provided, uses global config.
        """
        self.config = config if config is not None else get_config()
        self.session = self._create_session()
        self.batch_processor = BatchProcessor(self._send_batch, self.config)
        self._shutdown = False
        if self.config.security.pii_enabled:
            salt = self.config.security.pii_salt
            assert salt is not None and str(salt).strip()
            self._pii_pseudonymizer: Optional[PiiPseudonymizer] = PiiPseudonymizer(salt)
        else:
            self._pii_pseudonymizer = None

        logger.info(
            f"HTTP transport initialized for endpoint: {self.config.transport.endpoint}"
        )

        if log_debug_enabled():
            logger.debug("🔧 Transport configuration:")
            logger.debug(f"    endpoint: {self.config.transport.endpoint}")
            logger.debug(f"    timeout: {self.config.transport.timeout}s")
            logger.debug(f"    retry_attempts: {self.config.transport.retry_attempts}")
            logger.debug(f"    batch_size: {self.config.transport.batch_size}")
            logger.debug(f"    batch_timeout: {self.config.transport.batch_timeout}s")
            logger.debug(f"    compression: {self.config.transport.compression}")
            logger.debug(f"    ssl_verify: {self.config.transport.ssl_verify}")
            logger.debug(
                f"    ca_bundle: {self.config.transport.ca_bundle or 'default (certifi)'}"
            )

    def _get_sdk_version(self) -> str:
        """Get the SDK version."""
        return __version__

    def _build_api_url(self, path: str) -> str:
        """
        Build API URL preserving the full endpoint path.

        Args:
            path: API path (e.g., "/v1/traces")

        Returns:
            Complete URL with preserved endpoint path
        """
        endpoint = self.config.transport.endpoint.rstrip("/")
        path = path.lstrip("/")
        url = f"{endpoint}/{path}"

        if log_debug_enabled():
            logger.debug(f"🔗 Built API URL: {url}")

        return url

    def _contains_sensitive_data(self, text: str) -> bool:
        """
        Check if response text contains potentially sensitive data.

        Args:
            text: Response text to check

        Returns:
            True if sensitive patterns are detected
        """
        if not text:
            return False

        # Convert to lowercase for case-insensitive matching
        text_lower = text.lower()

        # Common sensitive data patterns
        sensitive_patterns = [
            "password",
            "token",
            "secret",
            "key",
            "credential",
            "authorization",
            "bearer",
            "api_key",
            "access_token",
            "private_key",
            "certificate",
            "ssn",
            "social_security",
            "credit_card",
            "card_number",
            "cvv",
            "pin",
            "account_number",
        ]

        # Check for sensitive patterns
        for pattern in sensitive_patterns:
            if pattern in text_lower:
                return True

        return False

    def _get_safe_response_preview(
        self, response: requests.Response, max_length: Optional[int] = None
    ) -> Optional[str]:
        """
        Get a safe preview of response text for logging.

        Args:
            response: HTTP response object
            max_length: Maximum length for preview (default: from config or 1000)

        Returns:
            Safe preview string or None if no response text
        """
        if not response.text:
            return None

        # Use provided max_length or get from config, with fallback to 1000
        if max_length is None:
            max_length = getattr(self.config.transport, "max_response_preview", 1000)

        # Check if response contains sensitive patterns
        if self._contains_sensitive_data(response.text):
            return f"<Response contains sensitive data, length: {len(response.text)} chars>"

        # Return truncated version if too long
        if max_length is not None and len(response.text) > max_length:
            return f"{response.text[:max_length]}... (truncated, total length: {len(response.text)} chars)"

        return response.text

    def _handle_ssl_error(
        self,
        error: requests.exceptions.SSLError,
        url: str,
        **context: Any,
    ) -> NoReturn:
        """
        Handle SSL errors with helpful troubleshooting information.

        Args:
            error: The SSL error that occurred
            url: The URL that was being accessed
            **context: Additional context for logging (e.g., trace_count)

        Raises:
            TransportError: Always raised with SSL error details
        """
        ssl_error_msg = str(error)
        help_msg = (
            f"SSL error: {ssl_error_msg}\n\n"
            "Possible solutions:\n"
            "1. Upgrade certifi: pip install --upgrade certifi\n"
            "2. If behind corporate proxy (Netskope, Zscaler, etc.): "
            "pip install pip-system-certs\n"
            "3. Set custom CA bundle: export NOVEUM_CA_BUNDLE=/path/to/ca.crt\n"
            "4. Disable SSL verification (debugging only): "
            "export NOVEUM_SSL_VERIFY=false"
        )
        log_error_always(logger, help_msg, exc_info=True, url=url, **context)
        raise TransportError(f"SSL error: {ssl_error_msg}") from error

    def export_trace(self, trace: Trace) -> None:
        """
        Export a trace to the Noveum platform.

        Args:
            trace: Trace to export

        Raises:
            TransportError: If transport is shutdown or export fails
        """
        if self._shutdown:
            log_error_always(
                logger,
                f"Cannot export trace {trace.trace_id} - transport has been shutdown",
                trace_id=trace.trace_id,
            )
            raise TransportError("Transport has been shutdown")

        # Skip no-op traces
        if hasattr(trace, "_noop") and trace._noop:
            logger.debug(f"⏭️  Skipping no-op trace {trace.trace_id}")
            return

        # Log trace export details
        span_count = len(trace.spans) if hasattr(trace, "spans") else 0
        logger.info(
            f"📤 EXPORTING TRACE: {trace.name} (ID: {trace.trace_id}) - {span_count} spans"
        )

        if log_debug_enabled():
            log_trace_flow(
                logger,
                "Exporting trace to transport",
                trace_id=trace.trace_id,
                trace_name=trace.name,
                span_count=span_count,
                trace_status=getattr(trace, "status", "unknown"),
                trace_finished=getattr(trace, "_finished", "unknown"),
            )

        # Convert trace to export format
        try:
            trace_data = self._format_trace_for_export(trace)
        except Exception as e:
            log_error_always(
                logger,
                f"Failed to format trace {trace.trace_id} for export",
                exc_info=True,
                trace_id=trace.trace_id,
                error=str(e),
            )
            raise TransportError(f"Failed to format trace: {e}") from e

        # Log formatted trace details
        if log_debug_enabled():
            formatted_span_count = len(trace_data.get("spans", []))
            logger.debug("📋 Formatted trace details:")
            logger.debug(f"    trace_id: {trace_data.get('trace_id')}")
            logger.debug(f"    name: {trace_data.get('name')}")
            logger.debug(f"    spans: {formatted_span_count}")
            logger.debug(f"    keys: {list(trace_data.keys())}")
            logger.debug(f"    sdk_info: {trace_data.get('sdk', {})}")
            logger.debug(f"    project: {trace_data.get('project', 'None')}")
            logger.debug(f"    environment: {trace_data.get('environment', 'None')}")

        # Add to batch processor
        try:
            self.batch_processor.add_trace(trace_data)
            logger.info(f"✅ Trace {trace.trace_id} successfully queued for export")
        except Exception as e:
            log_error_always(
                logger,
                f"Failed to queue trace {trace.trace_id} for export",
                exc_info=True,
                trace_id=trace.trace_id,
                error=str(e),
            )
            raise

    def export_audio(
        self,
        audio_data: bytes,
        trace_id: str,
        span_id: str,
        audio_uuid: str,
        metadata: Optional[dict[str, Any]] = None,
    ) -> None:
        """
        Export audio to the Noveum platform.

        Args:
            audio_data: Audio file bytes
            trace_id: Associated trace ID
            span_id: Associated span ID
            audio_uuid: Unique identifier for this audio file
            metadata: Additional metadata (duration, format, etc.)

        Raises:
            TransportError: If transport is shutdown

        Note:
            Export failures (e.g., queue full, missing audio_uuid) are logged but not raised.
        """
        if self._shutdown:
            log_error_always(
                logger,
                f"Cannot export audio {audio_uuid} - transport has been shutdown",
                audio_uuid=audio_uuid,
            )
            raise TransportError("Transport has been shutdown")

        logger.info(f"📤 EXPORTING AUDIO: {audio_uuid} for trace {trace_id}")

        # Format audio for export
        audio_item = {
            "audio_data": audio_data,
            "trace_id": trace_id,
            "span_id": span_id,
            "audio_uuid": audio_uuid,
            "metadata": metadata or {},
            "timestamp": time.time(),
        }

        # Add to batch processor
        try:
            self.batch_processor.add_audio(audio_item)
            logger.info(f"✅ Audio {audio_uuid} successfully queued for export")
        except Exception as e:
            log_error_always(
                logger,
                f"Failed to queue audio {audio_uuid} for export",
                exc_info=True,
                audio_uuid=audio_uuid,
                error=str(e),
            )
            # Exception swallowed - audio export fails silently with error log

    def send_audio_now(
        self,
        audio_data: bytes,
        trace_id: str,
        span_id: str,
        audio_uuid: str,
        metadata: Optional[dict[str, Any]] = None,
    ) -> None:
        """
        Send one audio file synchronously and report the outcome.

        Unlike :meth:`export_audio` (fire-and-forget via the batch queue, whose
        failures are only logged), this performs the multipart POST inline and
        raises on failure — for batch/completion callers that need per-item
        delivery results and must not depend on queue drain timing.

        Raises:
            TransportError: If the transport is shutdown or the upload fails.
        """
        if self._shutdown:
            raise TransportError("Transport has been shutdown")

        audio_item = {
            "audio_data": audio_data,
            "trace_id": trace_id,
            "span_id": span_id,
            "audio_uuid": audio_uuid,
            "metadata": metadata or {},
            "timestamp": time.time(),
        }
        self._send_single_audio(audio_item)

    def send_trace_now(self, trace: Trace) -> dict[str, Any]:
        """
        Format and send one trace synchronously; return the API response.

        The wire enrichment (:meth:`_format_trace_for_export` — project,
        environment, sdk, otel blocks) runs here with THIS transport's config,
        exactly as in the batched path. Raises on failure instead of queueing —
        the delivery-observed counterpart of :meth:`export_trace`.

        Raises:
            TransportError: If the transport is shutdown or the send fails.
        """
        if self._shutdown:
            raise TransportError("Transport has been shutdown")

        trace_data = self._format_trace_for_export(trace)
        return self._send_request(trace_data)

    def export_image(
        self,
        image_data: bytes,
        trace_id: str,
        span_id: str,
        image_uuid: str,
        metadata: Optional[dict[str, Any]] = None,
    ) -> None:
        """
        Export image to the Noveum platform.

        Args:
            image_data: Image file bytes
            trace_id: Associated trace ID
            span_id: Associated span ID
            image_uuid: Unique identifier for this image file
            metadata: Additional metadata (format, size, etc.)

        Raises:
            TransportError: If transport is shutdown

        Note:
            Export failures (e.g., queue full, missing image_uuid) are logged but not raised.
        """
        if self._shutdown:
            log_error_always(
                logger,
                f"Cannot export image {image_uuid} - transport has been shutdown",
                image_uuid=image_uuid,
            )
            raise TransportError("Transport has been shutdown")

        logger.info(f"📤 EXPORTING IMAGE: {image_uuid} for trace {trace_id}")

        # Format image for export
        image_item = {
            "image_data": image_data,
            "trace_id": trace_id,
            "span_id": span_id,
            "image_uuid": image_uuid,
            "metadata": metadata or {},
            "timestamp": time.time(),
        }

        # Add to batch processor
        try:
            self.batch_processor.add_image(image_item)
            logger.info(f"✅ Image {image_uuid} successfully queued for export")
        except Exception as e:
            log_error_always(
                logger,
                f"Failed to queue image {image_uuid} for export",
                exc_info=True,
                image_uuid=image_uuid,
                error=str(e),
            )
            # Exception swallowed - image export fails silently with error log

    def flush(self, timeout: Optional[float] = None) -> None:
        """
        Flush all pending traces.

        Args:
            timeout: Maximum time to wait for flush completion
        """
        if self._shutdown:
            logger.debug("Transport already shutdown, skipping flush")
            return

        log_trace_flow(logger, "Starting transport flush", timeout=timeout)

        try:
            self.batch_processor.flush(timeout)
            try:
                logger.info("HTTP transport flush completed")
            except (ValueError, OSError, RuntimeError, Exception):
                # Logger may be closed during shutdown
                pass
        except Exception as e:
            log_error_always(
                logger,
                "Transport flush failed",
                exc_info=True,
                timeout=timeout,
                error=str(e),
            )
            raise

    def shutdown(self) -> None:
        """Shutdown the transport and flush pending data."""
        if self._shutdown:
            try:
                logger.debug("Transport already shutdown")
            except (ValueError, OSError, RuntimeError, Exception):
                pass
            return

        try:
            logger.info("Shutting down HTTP transport")
        except (ValueError, OSError, RuntimeError, Exception):
            # Logger may be closed during shutdown
            pass
        self._shutdown = True

        try:
            # Flush pending data
            self.flush(timeout=30.0)

            # Shutdown batch processor
            self.batch_processor.shutdown()

            # Close session
            self.session.close()

            try:
                logger.info("HTTP transport shutdown completed")
            except (ValueError, OSError, RuntimeError, Exception):
                # Logger may be closed during shutdown
                pass
        except Exception as e:
            log_error_always(
                logger, "Error during transport shutdown", exc_info=True, error=str(e)
            )

    def _create_session(self) -> requests.Session:
        """Create and configure HTTP session."""
        session = requests.Session()

        # Set headers (excluding Content-Type to allow proper multipart/form-data for file uploads)
        headers = {
            "User-Agent": f"noveum-trace-sdk/{self._get_sdk_version()}",
        }

        # Add authentication
        if self.config.api_key:
            headers["Authorization"] = f"Bearer {self.config.api_key}"
            if log_debug_enabled():
                # Don't log the actual key, just indicate it's present
                logger.debug(
                    f"🔐 API key configured (length: {len(self.config.api_key)})"
                )
        else:
            logger.warning("⚠️  No API key configured - requests may fail")

        session.headers.update(headers)

        # Configure SSL verification
        ssl_verify = getattr(self.config.transport, "ssl_verify", True)
        ca_bundle = getattr(self.config.transport, "ca_bundle", None)

        if ca_bundle:
            # Use custom CA bundle (for corporate proxies)
            session.verify = ca_bundle
            logger.info(f"🔒 Using custom CA bundle: {ca_bundle}")
        elif not ssl_verify:
            # Disable SSL verification (NOT recommended for production!)
            session.verify = False
            logger.warning(
                "⚠️  SSL verification DISABLED - this is insecure and should only "
                "be used for debugging. Set ssl_verify=True for production."
            )
            # Suppress InsecureRequestWarning
            import urllib3
            import urllib3.exceptions

            urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
        else:
            # Default: use certifi bundle
            session.verify = True
            if log_debug_enabled():
                try:
                    import certifi

                    logger.debug(f"🔒 Using certifi CA bundle: {certifi.where()}")
                    logger.debug(
                        f"    certifi version: {getattr(certifi, '__version__', 'unknown')}"
                    )
                except ImportError:
                    logger.debug("🔒 Using system CA bundle (certifi not installed)")

        # Configure retries
        retry_strategy = Retry(
            total=self.config.transport.retry_attempts,
            backoff_factor=self.config.transport.retry_backoff,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=["POST"],
            # Ensure we receive the final response (e.g., 429) after retries,
            # so higher-level handlers can inspect status_code and log properly.
            raise_on_status=False,
        )

        adapter = HTTPAdapter(max_retries=retry_strategy)
        session.mount("http://", adapter)
        session.mount("https://", adapter)

        if log_debug_enabled():
            logger.debug("🔄 HTTP session configured:")
            logger.debug(f"    retry_attempts: {self.config.transport.retry_attempts}")
            logger.debug(f"    retry_backoff: {self.config.transport.retry_backoff}")
            logger.debug(f"    ssl_verify: {ssl_verify}")
            logger.debug(f"    ca_bundle: {ca_bundle or 'default'}")
            logger.debug(f"    headers: {dict(session.headers)}")

        return session

    def trace_to_dict(self, obj: Any, depth: int = 0, max_depth: int = 20) -> Any:
        """
        Recursively convert objects to JSON-serializable dictionaries.

        This method provides graceful handling of non-serializable objects,
        preserving as much data as possible even when some attributes fail.

        Args:
            obj: Object to convert
            depth: Current recursion depth (for preventing infinite loops)
            max_depth: Maximum recursion depth allowed

        Returns:
            JSON-serializable representation of the object
        """
        # Prevent infinite recursion
        if depth > max_depth:
            logger.warning(
                f"Max recursion depth ({max_depth}) reached in trace_to_dict"
            )
            return f"<Max depth exceeded: {type(obj).__name__}>"

        # Safeguard: Detect Mock objects to prevent infinite recursion
        if _MOCK_TYPES and isinstance(obj, _MOCK_TYPES):
            return "<Mock object>"

        # Handle None and primitives
        if obj is None:
            return None
        if isinstance(obj, (str, int, float, bool)):
            return obj

        # Handle common types explicitly
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, UUID):
            return str(obj)
        if isinstance(obj, Enum):
            return self.trace_to_dict(obj.value, depth + 1, max_depth)
        if isinstance(obj, bytes):
            # For bytes, return base64 if small, otherwise truncated repr
            if len(obj) <= 1000:
                try:
                    return base64.b64encode(obj).decode("utf-8")
                except Exception:
                    return f"<bytes: {len(obj)} bytes>"
            else:
                return f"<bytes: {len(obj)} bytes>"

        # Handle dictionaries with per-key error handling
        if isinstance(obj, dict):
            result = {}
            for key, value in obj.items():
                try:
                    # Ensure key is string
                    str_key = str(key) if not isinstance(key, str) else key
                    result[str_key] = self.trace_to_dict(value, depth + 1, max_depth)
                except Exception as e:
                    # Safely convert key to string for logging and result
                    try:
                        safe_key = str(key) if not isinstance(key, str) else key
                    except Exception:
                        safe_key = f"<Unstringable key: {type(key).__name__}>"

                    logger.warning(
                        f"Failed to serialize dict key '{safe_key}': {e}",
                        extra={"key": safe_key, "value_type": type(value).__name__},
                    )
                    result[safe_key] = f"<Non-serializable: {type(value).__name__}>"
            return result

        # Handle lists and tuples with per-item error handling
        if isinstance(obj, (list, tuple)):
            result_list: list[Any] = []
            for i, item in enumerate(obj):
                try:
                    result_list.append(self.trace_to_dict(item, depth + 1, max_depth))
                except Exception as e:
                    logger.warning(
                        f"Failed to serialize list item at index {i}: {e}",
                        extra={"index": i, "item_type": type(item).__name__},
                    )
                    result_list.append(f"<Non-serializable: {type(item).__name__}>")
            return result_list

        # Handle objects with to_dict method
        if hasattr(obj, "to_dict") and callable(obj.to_dict):
            try:
                return self.trace_to_dict(obj.to_dict(), depth + 1, max_depth)
            except Exception as e:
                logger.warning(
                    f"Failed to call to_dict() on {type(obj).__name__}: {e}",
                    extra={"object_type": type(obj).__name__},
                )
                return f"<Non-serializable: {type(obj).__name__}>"

        # Handle objects with __dict__ attribute
        if hasattr(obj, "__dict__"):
            try:
                # Extract attributes from object with per-attribute error handling
                attrs = {}
                for key, value in obj.__dict__.items():
                    if not key.startswith("_"):  # Skip private attributes
                        try:
                            attrs[key] = self.trace_to_dict(value, depth + 1, max_depth)
                        except Exception as e:
                            logger.warning(
                                f"Failed to serialize attribute '{key}' of {type(obj).__name__}: {e}",
                                extra={
                                    "object_type": type(obj).__name__,
                                    "attr_name": key,
                                },
                            )
                            attrs[key] = f"<Non-serializable: {type(value).__name__}>"
                return attrs
            except Exception as e:
                logger.warning(
                    f"Failed to access __dict__ of {type(obj).__name__}: {e}",
                    extra={"object_type": type(obj).__name__},
                )
                return f"<Non-serializable: {type(obj).__name__}>"

        # Final fallback: try string representation
        try:
            return str(obj)
        except Exception as e:
            logger.warning(
                f"Failed to convert {type(obj).__name__} to string: {e}",
                extra={"object_type": type(obj).__name__},
            )
            return f"<Non-serializable: {type(obj).__name__}>"

    def _format_trace_for_export(self, trace: Trace) -> dict[str, Any]:
        """
        Format trace data for export to Noveum platform.

        Args:
            trace: Trace to format

        Returns:
            Formatted trace data
        """
        log_trace_flow(logger, "Formatting trace for export", trace_id=trace.trace_id)

        trace_data = self.trace_to_dict(trace)

        # Handle case where trace_to_dict returns a string due to serialization errors
        if isinstance(trace_data, str):
            # Create a minimal trace structure with the error message and all required fields
            current_time = datetime.now(timezone.utc)
            trace_data = {
                "trace_id": trace.trace_id,
                "name": getattr(trace, "name", "unknown"),
                "start_time": getattr(trace, "start_time", current_time).isoformat(),
                "end_time": (
                    getattr(trace, "end_time", current_time).isoformat()
                    if getattr(trace, "end_time", None)
                    else current_time.isoformat()
                ),
                "duration_ms": getattr(trace, "duration_ms", 0.0) or 0.0,
                "status": "error",
                "status_message": trace_data,  # The error message from trace_to_dict
                "span_count": getattr(trace, "span_count", 0),
                "error_count": getattr(trace, "error_count", 1),
                "attributes": {},
                "metadata": {
                    "user_id": None,
                    "session_id": None,
                    "request_id": None,
                    "tags": {},
                    "custom_attributes": {},
                },
                "spans": [],
            }

        # Add SDK metadata
        trace_data["sdk"] = {
            "name": "noveum-trace-python",
            "version": self._get_sdk_version(),
        }

        if self.config.service_version:
            trace_data["service_version"] = self.config.service_version

        # Add project information
        if self.config.project:
            trace_data["project"] = self.config.project

        if self.config.environment:
            trace_data["environment"] = self.config.environment

        # Additively emit OpenTelemetry-aligned fields alongside the legacy payload.
        if getattr(self.config, "otel_compat", False):
            self._augment_trace_otel(trace_data)

        return trace_data

    def _augment_trace_otel(self, trace_data: dict[str, Any]) -> None:
        """
        Add OpenTelemetry-aligned fields to an exported trace, in place.

        Adds a top-level
        ``schema_version`` marker, a trace-level ``otel`` block (W3C trace id +
        resource), and a per-span ``otel`` block plus mirrored ``gen_ai.*`` attributes.
        """
        sdk_version = self._get_sdk_version()
        trace_data["schema_version"] = otel_compat.SCHEMA_VERSION
        trace_data["otel"] = {
            "trace_id": otel_compat.to_otel_trace_id(trace_data.get("trace_id")),
            "resource": otel_compat.build_resource(
                service_name=self.config.project,
                sdk_version=sdk_version,
                environment=self.config.environment,
                service_version=self.config.service_version,
            ),
        }

        for span in trace_data.get("spans", []):
            if not isinstance(span, dict):
                continue
            attrs = span.get("attributes")
            if not isinstance(attrs, dict):
                attrs = {}
            gen_ai = otel_compat.derive_gen_ai_attributes(attrs)
            if gen_ai:
                attrs.update(gen_ai)
                span["attributes"] = attrs
            name = span.get("name") or ""
            span["otel"] = {
                "trace_id": otel_compat.to_otel_trace_id(span.get("trace_id")),
                "span_id": otel_compat.to_otel_span_id(span.get("span_id")),
                "parent_span_id": otel_compat.to_otel_span_id(
                    span.get("parent_span_id")
                ),
                "kind": otel_compat.infer_span_kind(name, attrs),
                "status": otel_compat.to_otel_status(
                    span.get("status"), span.get("status_message")
                ),
                "name": otel_compat.otel_span_name(name, attrs),
                "scope": {
                    "name": otel_compat.DEFAULT_SCOPE_NAME,
                    "version": sdk_version,
                },
            }

    def _dev_trace_filename_stem(self, trace: dict[str, Any]) -> str:
        """Filesystem-safe filename stem from a single trace's trace_id."""
        trace_id = trace.get("trace_id")
        if trace_id is None:
            return f"unknown_{time.time_ns()}"
        safe = "".join(c for c in str(trace_id) if c.isalnum() or c in "-_")
        return safe if safe else f"unknown_{time.time_ns()}"

    def _write_dev_trace_json_file(self, trace: dict[str, Any]) -> None:
        """Write one trace dict to ``{trace_id}.json`` (caller checks dev_mode)."""
        dir_str = self.config.dev_traces_dir or DEFAULT_DEV_TRACES_DIR
        out_dir = Path(dir_str).expanduser()
        stem = self._dev_trace_filename_stem(trace)
        path = out_dir / f"{stem}.json"
        try:
            out_dir.mkdir(parents=True, exist_ok=True)
            path.write_text(
                json.dumps(trace, indent=2, default=str, ensure_ascii=False),
                encoding="utf-8",
            )
        except OSError as e:
            logger.warning("dev_mode: failed to write trace JSON to %s: %s", path, e)
        except (TypeError, ValueError) as e:
            logger.warning(
                "dev_mode: failed to serialize trace JSON for %s: %s", path, e
            )

    def _write_dev_trace_payload(self, payload: dict[str, Any]) -> None:
        """Persist each trace as ``{trace_id}.json`` right before the API send.

        Batches write one file per trace; trace IDs are unique.
        """
        if not self.config.dev_mode:
            return
        traces = payload.get("traces")
        if isinstance(traces, list) and traces:
            for trace in traces:
                if isinstance(trace, dict):
                    self._write_dev_trace_json_file(trace)
            return
        self._write_dev_trace_json_file(payload)

    def _send_request(self, trace_data: dict[str, Any]) -> dict[str, Any]:
        """
        Send a single trace request to the Noveum platform.

        Args:
            trace_data: Trace data to send

        Returns:
            Response data

        Raises:
            TransportError: If the request fails
        """
        # The backend has no single-trace route; ingestion goes through the
        # batch endpoint, so one trace ships as a batch of one.
        url = self._build_api_url("/v1/traces")
        payload: dict[str, Any] = {
            "traces": [trace_data],
            "timestamp": time.time(),
        }

        log_http_request(
            logger,
            "POST",
            url,
            trace_id=trace_data.get("trace_id"),
            content_type="application/json",
            timeout=self.config.transport.timeout,
        )

        self._write_dev_trace_payload(payload)

        post_body: dict[str, Any] = payload
        if self._pii_pseudonymizer is not None:
            post_body = self._pii_pseudonymizer.pseudonymize_dict(payload)

        try:
            # Send request with explicit Content-Type for JSON
            response = self.session.post(
                url,
                json=post_body,
                headers={"Content-Type": "application/json"},
                timeout=self.config.transport.timeout,
            )

            log_http_response(
                logger,
                response.status_code,
                url,
                response_headers=(
                    dict(response.headers) if log_debug_enabled() else None
                ),
            )

            # Check response
            if response.status_code in [200, 201]:
                logger.debug(f"Successfully sent trace: {trace_data.get('trace_id')}")
                return response.json()
            elif response.status_code == 401:
                log_error_always(
                    logger,
                    "Authentication failed - check API key",
                    status=response.status_code,
                    url=url,
                )
                raise TransportError("Authentication failed - check API key")
            elif response.status_code == 403:
                log_error_always(
                    logger,
                    "Access forbidden - check project permissions",
                    status=response.status_code,
                    url=url,
                )
                raise TransportError("Access forbidden - check project permissions")
            elif response.status_code == 429:
                log_error_always(
                    logger,
                    "Rate limit exceeded/Quota exceeded (HTTP 429) from Noveum API",
                    status=response.status_code,
                    url=url,
                )
                raise TransportError("Rate limit exceeded")
            else:
                log_error_always(
                    logger,
                    f"Unexpected HTTP status code: {response.status_code}",
                    status=response.status_code,
                    url=url,
                    response_text=self._get_safe_response_preview(response),
                )
                response.raise_for_status()
                # raise_for_status() only raises on 4xx/5xx — an unexpected
                # 2xx/3xx must not fall through as delivered (direct-send
                # callers treat a normal return as delivery confirmation).
                raise TransportError(
                    f"Trace send returned unexpected status {response.status_code}"
                )

        except requests.exceptions.SSLError as e:
            self._handle_ssl_error(e, url)
        except requests.exceptions.RequestException as e:
            log_error_always(
                logger, f"HTTP request failed: {e}", exc_info=True, url=url
            )
            raise TransportError(f"HTTP request failed: {e}") from e

    def _send_batch(self, batch_item: dict[str, Any]) -> None:
        """
        Send audio, images, or traces based on type.

        Args:
            batch_item: Dict with 'type' ('audio', 'image', or 'traces') and 'data'
        """
        item_type = batch_item.get("type")

        if item_type == "audio":
            # Send single audio file
            audio_data = batch_item["data"]
            self._send_single_audio(audio_data)

        elif item_type == "image":
            # Send single image file
            image_data = batch_item["data"]
            self._send_single_image(image_data)

        elif item_type == "traces":
            # Send batch of traces (existing logic)
            traces = batch_item["data"]
            self._send_trace_batch(traces)

        else:
            logger.error(f"Unknown batch item type: {item_type}")

    def _send_trace_batch(self, traces: list[dict[str, Any]]) -> None:
        """
        Send a batch of traces to the Noveum platform.

        Args:
            traces: List of trace data to send

        Raises:
            TransportError: If the request fails
        """
        if not traces:
            logger.debug("_send_batch called with empty traces list, skipping")
            return

        # Prepare request payload
        payload = {
            "traces": traces,
            "timestamp": time.time(),
        }

        # Compress payload if enabled
        if self.config.transport.compression:
            payload = self._compress_payload(payload)

        # Log detailed request information
        url = self._build_api_url("/v1/traces")
        logger.info(f"🚀 SENDING BATCH: {len(traces)} traces to {url}")

        if log_debug_enabled():
            log_http_request(
                logger,
                "POST",
                url,
                trace_count=len(traces),
                payload_keys=list(payload.keys()),
                payload_size_chars=len(str(payload)),
                headers=dict(self.session.headers),
                compression_enabled=self.config.transport.compression,
            )

            # Log individual trace info
            for i, trace in enumerate(traces):
                trace_id = trace.get("trace_id", "unknown")
                trace_name = trace.get("name", "unnamed")
                span_count = len(trace.get("spans", []))
                logger.debug(
                    f"    [{i+1}] {trace_name} (ID: {trace_id}) - {span_count} spans"
                )

        self._write_dev_trace_payload(payload)

        post_payload: dict[str, Any] = payload
        if self._pii_pseudonymizer is not None:
            post_payload = self._pii_pseudonymizer.pseudonymize_dict(payload)

        try:
            # Send request with explicit Content-Type for JSON
            response = self.session.post(
                url,
                json=post_payload,
                headers={"Content-Type": "application/json"},
                timeout=self.config.transport.timeout,
            )

            # Log response details
            logger.info(f"📡 HTTP RESPONSE: Status {response.status_code} from {url}")

            if log_debug_enabled():
                log_http_response(
                    logger,
                    response.status_code,
                    url,
                    response_headers=dict(response.headers),
                    response_size=len(response.text) if response.text else 0,
                    response_preview=self._get_safe_response_preview(response),
                )

            # Check response
            if response.status_code in [200, 201]:
                logger.info(f"✅ Successfully sent batch of {len(traces)} traces")
                if log_debug_enabled():
                    safe_preview = self._get_safe_response_preview(
                        response, max_length=2000
                    )
                    logger.debug(f"📋 Response preview: {safe_preview}")
            elif response.status_code == 401:
                log_error_always(
                    logger,
                    "Authentication failed - check API key",
                    status=response.status_code,
                    url=url,
                    trace_count=len(traces),
                )
                raise TransportError("Authentication failed - check API key")
            elif response.status_code == 403:
                log_error_always(
                    logger,
                    "Access forbidden - check project permissions",
                    status=response.status_code,
                    url=url,
                    trace_count=len(traces),
                )
                raise TransportError("Access forbidden - check project permissions")
            elif response.status_code == 429:
                log_error_always(
                    logger,
                    "Rate limit exceeded (HTTP 429) from Noveum API",
                    status=response.status_code,
                    url=url,
                    trace_count=len(traces),
                )
                raise TransportError("Rate limit exceeded")
            else:
                log_error_always(
                    logger,
                    f"Unexpected status code: {response.status_code}",
                    status=response.status_code,
                    url=url,
                    trace_count=len(traces),
                    response_text=self._get_safe_response_preview(response),
                )
                response.raise_for_status()

        except requests.exceptions.Timeout as e:
            log_error_always(
                logger,
                f"Request timeout after {self.config.transport.timeout}s",
                exc_info=True,
                url=url,
                timeout=self.config.transport.timeout,
                trace_count=len(traces),
            )
            raise TransportError(
                f"Request timeout after {self.config.transport.timeout}s"
            ) from e
        except requests.exceptions.SSLError as e:
            self._handle_ssl_error(e, url, trace_count=len(traces))
        except requests.exceptions.ConnectionError as e:
            log_error_always(
                logger,
                f"Connection error: {e}",
                exc_info=True,
                url=url,
                trace_count=len(traces),
            )
            raise TransportError(f"Connection error: {e}") from e
        except requests.exceptions.HTTPError as e:
            log_error_always(
                logger,
                f"HTTP error: {e}",
                exc_info=True,
                url=url,
                trace_count=len(traces),
            )
            raise TransportError(f"HTTP error: {e}") from e
        except TransportError:
            # Preserve specific TransportError messages (401/403/429 branches)
            # without wrapping or double-logging.
            raise
        except Exception as e:
            log_error_always(
                logger,
                f"Unexpected error: {e}",
                exc_info=True,
                url=url,
                trace_count=len(traces),
            )
            raise TransportError(f"Unexpected error: {e}") from e

    def _send_single_audio(self, audio_item: dict[str, Any]) -> None:
        """
        Send a single audio file to the Noveum platform.

        Args:
            audio_item: Audio data with metadata

        Raises:
            TransportError: If audio_uuid is missing or upload fails
        """
        audio_uuid = audio_item.get("audio_uuid")
        if not audio_uuid:
            audio_item_keys = list(audio_item.keys())
            log_error_always(
                logger,
                "Cannot send audio - missing audio_uuid, dropping audio file",
                audio_item_keys=audio_item_keys,
            )
            raise TransportError(
                f"Cannot send audio - missing audio_uuid, dropping audio file. "
                f"Available keys: {audio_item_keys}"
            )

        trace_id = audio_item.get("trace_id")
        span_id = audio_item.get("span_id")

        url = self._build_api_url("/v1/audio")

        logger.info(f"🚀 SENDING AUDIO: {audio_uuid} to {url}")

        try:
            # Prepare multipart form data
            files = {
                "file": (f"{audio_uuid}.wav", audio_item["audio_data"], "audio/wav")
            }

            data = {
                "traceId": trace_id,  # API expects camelCase
                "spanId": span_id,  # API expects camelCase
                "audio_uuid": audio_uuid,
                "timestamp": audio_item.get("timestamp"),
                **audio_item.get("metadata", {}),
            }

            # Send request
            response = self.session.post(
                url,
                files=files,
                data=data,
                timeout=self.config.transport.timeout,
            )

            # Log response
            logger.info(f"📡 AUDIO RESPONSE: Status {response.status_code}")

            if response.status_code in [200, 201]:
                logger.info(f"✅ Successfully sent audio {audio_uuid}")
            elif response.status_code == 429:
                log_error_always(
                    logger,
                    "Rate limit exceeded (HTTP 429) while uploading audio",
                    status=response.status_code,
                    url=url,
                    audio_uuid=audio_uuid,
                )
                raise TransportError("Rate limit exceeded")
            else:
                log_error_always(
                    logger,
                    f"Failed to send audio {audio_uuid}: {response.status_code}",
                    status=response.status_code,
                    audio_uuid=audio_uuid,
                )
                response.raise_for_status()
                # raise_for_status only raises on 4xx/5xx — a 3xx or an
                # unexpected 2xx must not fall through as success (callers
                # like send_audio_now treat a normal return as delivered).
                raise TransportError(
                    f"Audio upload returned unexpected status "
                    f"{response.status_code}"
                )

        except requests.exceptions.Timeout as e:
            log_error_always(
                logger,
                f"Audio upload timeout for {audio_uuid}",
                exc_info=True,
                audio_uuid=audio_uuid,
            )
            raise TransportError(f"Audio upload timeout: {e}") from e
        except TransportError:
            raise
        except Exception as e:
            log_error_always(
                logger,
                f"Failed to send audio {audio_uuid}: {e}",
                exc_info=True,
                audio_uuid=audio_uuid,
            )
            raise TransportError(f"Audio upload failed: {e}") from e

    def _send_single_image(self, image_item: dict[str, Any]) -> None:
        """
        Send a single image file to the Noveum platform.

        Args:
            image_item: Image data with metadata

        Raises:
            TransportError: If image_uuid is missing or upload fails
        """
        image_uuid = image_item.get("image_uuid")
        if not image_uuid:
            image_item_keys = list(image_item.keys())
            log_error_always(
                logger,
                "Cannot send image - missing image_uuid, dropping image file",
                image_item_keys=image_item_keys,
            )
            raise TransportError(
                f"Cannot send image - missing image_uuid, dropping image file. "
                f"Available keys: {image_item_keys}"
            )

        trace_id = image_item.get("trace_id")
        span_id = image_item.get("span_id")

        url = self._build_api_url("/v1/image")

        logger.info(f"🚀 SENDING IMAGE: {image_uuid} to {url}")

        try:
            # Get image format from metadata
            metadata = image_item.get("metadata", {})
            image_format = metadata.get("format", "jpeg")

            # Determine content type
            content_type = f"image/{image_format}"

            # Prepare multipart form data
            files = {
                "file": (
                    f"{image_uuid}.{image_format}",
                    image_item["image_data"],
                    content_type,
                )
            }

            # Sanitize metadata to prevent overwriting reserved fields
            reserved_keys = {"traceId", "spanId", "image_uuid"}
            sanitized_metadata = {}
            for key, value in metadata.items():
                if key in reserved_keys:
                    sanitized_metadata[f"metadata_{key}"] = value
                else:
                    sanitized_metadata[key] = value

            data = {
                "traceId": trace_id,  # API expects camelCase
                "spanId": span_id,  # API expects camelCase
                "image_uuid": image_uuid,
                "timestamp": image_item.get("timestamp"),
                **sanitized_metadata,
            }

            # Send request
            response = self.session.post(
                url,
                files=files,
                data=data,
                timeout=self.config.transport.timeout,
            )

            # Log response
            logger.info(f"📡 IMAGE RESPONSE: Status {response.status_code}")

            if response.status_code in [200, 201]:
                logger.info(f"✅ Successfully sent image {image_uuid}")
            elif response.status_code == 429:
                log_error_always(
                    logger,
                    "Rate limit exceeded (HTTP 429) while uploading image",
                    status=response.status_code,
                    url=url,
                    image_uuid=image_uuid,
                )
                raise TransportError("Rate limit exceeded")
            else:
                log_error_always(
                    logger,
                    f"Failed to send image {image_uuid}: {response.status_code}",
                    status=response.status_code,
                    image_uuid=image_uuid,
                )
                response.raise_for_status()

        except requests.exceptions.Timeout as e:
            log_error_always(
                logger,
                f"Image upload timeout for {image_uuid}",
                exc_info=True,
                image_uuid=image_uuid,
            )
            raise TransportError(f"Image upload timeout: {e}") from e
        except TransportError:
            raise
        except Exception as e:
            log_error_always(
                logger,
                f"Failed to send image {image_uuid}: {e}",
                exc_info=True,
                image_uuid=image_uuid,
            )
            raise TransportError(f"Image upload failed: {e}") from e

    def _compress_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
        """
        Compress payload if beneficial.

        Args:
            payload: Payload to compress

        Returns:
            Potentially compressed payload
        """
        # For now, just return the payload as-is
        # In the future, we could implement gzip compression
        if log_debug_enabled():
            logger.debug("🗜️  Payload compression requested but not implemented yet")
        return payload

    def health_check(self) -> bool:
        """
        Perform a health check against the Noveum platform.

        Returns:
            True if the platform is reachable, False otherwise
        """
        try:
            url = urljoin(self.config.transport.endpoint, "/health")

            log_trace_flow(logger, "Performing health check", url=url)

            response = self.session.get(url, timeout=10)

            if response.status_code == 200:
                logger.debug(f"✅ Health check passed for {url}")
                return True

            if response.status_code == 429:
                log_error_always(
                    logger,
                    "Rate limit exceeded (HTTP 429) from Noveum API during health check",
                    status=response.status_code,
                    url=url,
                )
                return False

            logger.warning(
                f"⚠️  Health check failed for {url} (status: {response.status_code})"
            )
            return False
        except Exception as e:
            logger.warning(
                f"⚠️  Health check failed for {self.config.transport.endpoint}: {e}"
            )
            return False

    def __repr__(self) -> str:
        """String representation of the transport."""
        return (
            f"HttpTransport(endpoint='{self.config.transport.endpoint}', "
            f"batch_size={self.config.transport.batch_size})"
        )
