"""GCP Cloud Trace exporter for MCA SDK.

This module provides a SpanExporter that sends traces directly to GCP Cloud Trace.
"""

import logging
import time
from threading import Lock
from typing import Sequence, Optional, List, Dict, Any, TYPE_CHECKING

# Validator moved to utils/gcp_validators.py to avoid circular dependency
from ..utils.gcp_validators import validate_gcp_project_id as _validate_gcp_project_id

if TYPE_CHECKING:
    from opentelemetry.sdk.trace.export import SpanExportResult
    from google.cloud.trace_v2 import types

logger = logging.getLogger(__name__)

__all__ = ["GCPCloudTraceExporter"]

# GCP Cloud Trace limits
MAX_SPAN_LINKS = 128
MAX_SPAN_EVENTS = 128
MAX_SPANS_PER_BATCH = 200


# Rate limiting for truncation warnings (prevent log spam)
class _TruncationWarningRateLimiter:
    """Rate-limits truncation warnings to prevent log flooding in high-throughput systems."""

    def __init__(self, max_warnings_per_minute: int = 10):
        self._max_warnings = max_warnings_per_minute
        self._warnings_issued = 0
        self._last_reset = time.time()
        self._lock = Lock()

    def should_warn(self) -> bool:
        """Check if warning should be issued (rate-limited)."""
        with self._lock:
            now = time.time()
            # Reset counter every minute
            if now - self._last_reset > 60:
                self._warnings_issued = 0
                self._last_reset = now

            if self._warnings_issued < self._max_warnings:
                self._warnings_issued += 1
                return True
            return False


class GCPCloudTraceExporter:
    """Exports traces to GCP Cloud Trace with full span fidelity.

    This exporter sends OpenTelemetry spans to GCP Cloud Trace using the
    google-cloud-trace library. It requires GCP credentials configured via
    the gcp_auth module.

    Features:
    - Span status mapping (OK, ERROR, UNSET)
    - Span kind mapping (INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER)
    - Span links and events
    - Resource attribute enrichment
    - Thread-safe export with retry logic
    - Safe attribute truncation with warnings

    Thread Safety:
    - This exporter uses a Lock() to serialize export() calls from multiple threads
    - The google-cloud-trace TraceServiceClient is assumed to be thread-safe per
      google-cloud-python library conventions (client instances use thread-safe gRPC channels)
    - Reference: https://googleapis.dev/python/google-api-core/latest/client_info.html
    - If thread safety issues arise, consider using one client per thread with ThreadLocal

    Args:
        project_id: GCP project ID for Cloud Trace (6-30 chars, lowercase, starts with letter)
        credentials: google.auth.credentials.Credentials instance (optional)
        resource: OpenTelemetry Resource with attributes to attach to spans (optional)

    Raises:
        ImportError: If google-cloud-trace is not installed
        ValueError: If project_id format is invalid

    Example:
        >>> from opentelemetry.sdk.resources import Resource
        >>> resource = Resource.create({"service.name": "my-service"})
        >>> exporter = GCPCloudTraceExporter(
        ...     project_id="my-project-123",
        ...     credentials=credentials,
        ...     resource=resource
        ... )
    """

    def __init__(self, project_id: str, credentials=None, resource=None):
        try:
            from google.cloud import trace_v2
        except ImportError:
            raise ImportError(
                "google-cloud-trace library not installed. "
                "Install with: pip install mca-sdk[gcp-trace] or pip install google-cloud-trace"
            )

        # Validate project_id format
        _validate_gcp_project_id(project_id)

        self._project_id = project_id
        self._project_name = f"projects/{project_id}"
        self._resource = resource

        # Extract resource attributes for span enrichment
        self._resource_attrs: Dict[str, Any] = {}
        if resource:
            for key, value in resource.attributes.items():
                self._resource_attrs[key] = value

        # Internal telemetry metrics (for observability)
        self._metrics = {
            "spans_exported_total": 0,
            "spans_dropped_total": 0,
            "export_attempts_total": 0,
            "export_failures_total": 0,
            "batches_exported_total": 0,
            "retries_total": 0,
        }
        self._metrics_lock = Lock()  # Thread-safe metric updates
        self._last_export_duration_seconds: float = 0.0
        self._truncation_limiter = _TruncationWarningRateLimiter()  # Rate-limit truncation warnings

        # Initialize GCP Cloud Trace client
        # CRITICAL: Credentials must have scope: https://www.googleapis.com/auth/trace.append
        # or broader scope: https://www.googleapis.com/auth/cloud-platform
        # If credentials lack required scopes, exports will fail at runtime with 403 errors.
        # To verify scopes: credentials.scopes or check service account JSON "scopes" field
        try:
            self._client = trace_v2.TraceServiceClient(credentials=credentials)
        except Exception as e:
            logger.error(
                "Failed to initialize GCP Cloud Trace client: %s. "
                "Ensure credentials have required scope: "
                "https://www.googleapis.com/auth/trace.append or "
                "https://www.googleapis.com/auth/cloud-platform",
                e,
            )
            raise

        # Log credential scope information if available (for debugging)
        if credentials and hasattr(credentials, "scopes") and credentials.scopes:
            try:
                required_scopes = [
                    "https://www.googleapis.com/auth/trace.append",
                    "https://www.googleapis.com/auth/cloud-platform",
                ]
                has_required_scope = any(scope in credentials.scopes for scope in required_scopes)
                if not has_required_scope:
                    logger.warning(
                        "Credentials may lack required scope for Cloud Trace. "
                        "Required: trace.append or cloud-platform. "
                        "Found: %s. Exports may fail with 403 errors.",
                        (
                            list(credentials.scopes)
                            if hasattr(credentials.scopes, "__iter__")
                            else credentials.scopes
                        ),
                    )
            except (TypeError, AttributeError):
                # credentials.scopes may not be iterable (e.g., in tests with mocks)
                pass

        logger.info("Initialized GCP Cloud Trace exporter for project: %s", project_id)

    def export(self, spans: Sequence) -> "SpanExportResult":
        """Export spans to GCP Cloud Trace with retry logic.

        Converts OpenTelemetry spans to Cloud Trace format and sends via API.
        Retries transient errors with exponential backoff.

        Enforces GCP Cloud Trace limits:
        - Maximum 200 spans per batch
        - Maximum 128 links per span
        - Maximum 128 events per span

        Args:
            spans: Sequence of ReadableSpan instances to export

        Returns:
            SpanExportResult.SUCCESS if all spans exported successfully
            SpanExportResult.FAILURE if export failed after retries

        Note:
            If batch size exceeds MAX_SPANS_PER_BATCH (200), spans are
            exported in multiple batches sequentially.
        """
        from opentelemetry.sdk.trace.export import SpanExportResult

        if not spans:
            return SpanExportResult.SUCCESS

        # Track export attempt
        export_start_time = time.time()
        with self._metrics_lock:
            self._metrics["export_attempts_total"] += 1

        # Validate input
        if not isinstance(spans, (list, tuple)):
            logger.error("Invalid spans type: %s (expected list or tuple)", type(spans).__name__)
            with self._metrics_lock:
                self._metrics["export_failures_total"] += 1
            return SpanExportResult.FAILURE

        # Split into batches if needed (GCP limit: 200 spans per request)
        span_batches = []
        for i in range(0, len(spans), MAX_SPANS_PER_BATCH):
            span_batches.append(spans[i : i + MAX_SPANS_PER_BATCH])

        if len(span_batches) > 1:
            batch_sizes = [len(batch) for batch in span_batches]
            logger.info(
                "Splitting %d spans into %d batches (max %d spans per batch). Batch sizes: %s",
                len(spans),
                len(span_batches),
                MAX_SPANS_PER_BATCH,
                batch_sizes,
            )

        # Transient errors that should be retried
        try:
            from google.api_core.exceptions import (
                ServiceUnavailable,
                DeadlineExceeded,
                ResourceExhausted,
                InternalServerError,
            )

            RETRYABLE_EXCEPTIONS = (
                ServiceUnavailable,
                DeadlineExceeded,
                ResourceExhausted,
                InternalServerError,
            )
        except ImportError:
            logger.warning(
                "google-api-core not available, retry logic disabled. "
                "Install with: pip install google-api-core"
            )
            RETRYABLE_EXCEPTIONS = ()

        MAX_RETRIES = 3
        BASE_DELAY = 1.0
        # Max retry time: 1+2+4 = 7 seconds per batch
        # BatchSpanProcessor default timeout is 30 seconds
        # If exporting many batches with retries, total time may approach timeout
        # Current design: Export batches sequentially with retries
        # Alternative: Fail fast after timeout budget exceeded

        # Export each batch
        spans_exported_count = 0
        for batch_idx, span_batch in enumerate(span_batches):
            batch_success = False
            # REGRESSION FIX: Track metrics per batch, update once after retry loop
            batch_retries = 0
            batch_failed = False

            for attempt in range(MAX_RETRIES):
                try:
                    trace_spans = self._convert_spans(span_batch)

                    if not trace_spans:
                        # Issue 6 fix: Track ratio of dropped spans to detect systemic issues
                        dropped_ratio = len(span_batch) / len(spans) if spans else 0
                        if dropped_ratio > 0.5:  # >50% dropped indicates systemic problem
                            logger.error(
                                "CRITICAL: %d/%d spans dropped in batch %d (%.1f%%). "
                                "Possible systemic conversion error. Check logs for 'Failed to construct' warnings.",
                                len(span_batch),
                                len(spans),
                                batch_idx + 1,
                                dropped_ratio * 100,
                            )
                        else:
                            logger.warning(
                                "No valid spans in batch %d (all invalid)", batch_idx + 1
                            )
                        batch_success = True
                        break

                    # Add timeout to prevent hangs
                    self._client.batch_write_spans(
                        name=self._project_name, spans=trace_spans, timeout=30.0
                    )

                    with self._metrics_lock:
                        self._metrics["batches_exported_total"] += 1
                        self._metrics["spans_exported_total"] += len(span_batch)
                    spans_exported_count += len(span_batch)
                    batch_success = True
                    logger.debug(
                        "Exported batch %d/%d (%d spans) to GCP Cloud Trace",
                        batch_idx + 1,
                        len(span_batches),
                        len(span_batch),
                    )
                    break

                except RETRYABLE_EXCEPTIONS as e:
                    batch_retries += 1
                    if attempt < MAX_RETRIES - 1:
                        delay = BASE_DELAY * (2**attempt)
                        logger.warning(
                            "Transient error exporting batch %d/%d to GCP Cloud Trace (attempt %d/%d): %s. Retrying in %.1fs",
                            batch_idx + 1,
                            len(span_batches),
                            attempt + 1,
                            MAX_RETRIES,
                            e,
                            delay,
                        )
                        time.sleep(delay)
                    else:
                        logger.error(
                            "Failed to export batch %d/%d to GCP Cloud Trace after %d attempts: %s",
                            batch_idx + 1,
                            len(span_batches),
                            MAX_RETRIES,
                            e,
                        )
                        batch_failed = True
                        self._last_export_duration_seconds = time.time() - export_start_time
                        # Update metrics after loop
                        break

                except Exception as e:
                    logger.error(
                        "Failed to export batch %d/%d to GCP Cloud Trace: %s",
                        batch_idx + 1,
                        len(span_batches),
                        e,
                    )
                    batch_failed = True
                    self._last_export_duration_seconds = time.time() - export_start_time
                    # Update metrics after loop
                    break

            # REGRESSION FIX: Update metrics once per batch, not per retry
            if batch_retries > 0:
                with self._metrics_lock:
                    self._metrics["retries_total"] += batch_retries

            if batch_failed:
                with self._metrics_lock:
                    self._metrics["export_failures_total"] += 1
                return SpanExportResult.FAILURE

            # If batch failed after all retries, fail the entire export
            if not batch_success:
                self._last_export_duration_seconds = time.time() - export_start_time
                return SpanExportResult.FAILURE

        # Record final export duration
        self._last_export_duration_seconds = time.time() - export_start_time

        logger.debug(
            "Successfully exported all %d spans to GCP Cloud Trace in %.3fs "
            "(total: %d spans, %d failures, %d retries)",
            len(spans),
            self._last_export_duration_seconds,
            self._metrics["spans_exported_total"],
            self._metrics["export_failures_total"],
            self._metrics["retries_total"],
        )
        return SpanExportResult.SUCCESS

    def _convert_spans(self, spans: Sequence) -> List:
        """Convert OpenTelemetry spans to Cloud Trace format with full fidelity.

        Maps OTel span structure to google.cloud.trace_v2.types.Span format,
        including status, kind, links, events, and resource attributes.

        Enforces GCP limits:
        - Maximum 128 links per span (extras dropped with warning)
        - Maximum 128 events per span (extras dropped with warning)
        - Validates span timestamps (must be positive)

        Args:
            spans: Sequence of OpenTelemetry ReadableSpan instances

        Returns:
            List of google.cloud.trace_v2.types.Span instances

        Note:
            Invalid spans are skipped and logged as warnings.
        """
        from google.cloud.trace_v2 import types
        from google.protobuf.timestamp_pb2 import Timestamp
        from opentelemetry.trace import StatusCode, SpanKind

        trace_spans = []
        # REGRESSION FIX: Batch metric updates to avoid lock thrashing in failure loops
        spans_dropped_this_batch = 0

        for span_idx, span in enumerate(spans):
            try:
                # Validate span has required attributes
                if not hasattr(span, "context") or not hasattr(span.context, "trace_id"):
                    logger.warning("Skipping span %d: missing context or trace_id", span_idx)
                    continue

                if not hasattr(span, "start_time") or not hasattr(span, "end_time"):
                    logger.warning("Skipping span %d: missing timestamps", span_idx)
                    continue

                # Validate timestamps are positive
                if span.start_time < 0 or span.end_time < 0:
                    logger.warning(
                        "Skipping span %d: invalid timestamps (start=%d, end=%d)",
                        span_idx,
                        span.start_time,
                        span.end_time,
                    )
                    continue

                # Validate end_time without modifying original span object
                actual_end_time = span.end_time
                if span.end_time < span.start_time:
                    logger.warning(
                        "Span %d has end_time < start_time, using start_time for end_time in export",
                        span_idx,
                    )
                    actual_end_time = span.start_time
            except Exception as e:
                logger.warning("Skipping span %d due to validation error: %s", span_idx, e)
                continue

            # Wrap protobuf construction in try/except to handle errors gracefully
            try:
                trace_id = format(span.context.trace_id, "032x")
                span_id = format(span.context.span_id, "016x")

                # Truncate display name to 128 chars (GCP limit)
                display_name = span.name
                if len(display_name) > 128:
                    logger.warning(
                        "Span name '%s...' truncated from %d to 128 chars for display_name",
                        display_name[:40],
                        len(display_name),
                    )
                    display_name = display_name[:128]

                trace_span = types.Span(
                    name=f"{self._project_name}/traces/{trace_id}/spans/{span_id}",
                    span_id=span_id,
                    display_name=types.TruncatableString(value=display_name),
                    start_time=Timestamp(
                        seconds=int(span.start_time // 1_000_000_000),
                        nanos=int(span.start_time % 1_000_000_000),
                    ),
                    end_time=Timestamp(
                        seconds=int(actual_end_time // 1_000_000_000),
                        nanos=int(actual_end_time % 1_000_000_000),
                    ),
                )

                # Map OpenTelemetry status to Cloud Trace status
                status_code_map = {
                    StatusCode.UNSET: None,
                    StatusCode.OK: types.Span.Status(code=0),
                    StatusCode.ERROR: types.Span.Status(code=2),
                }

                otel_status = span.status
                if otel_status and otel_status.status_code != StatusCode.UNSET:
                    gcp_status = status_code_map.get(otel_status.status_code)
                    if gcp_status and otel_status.description:
                        # Truncate status message to 2048 chars (GCP limit)
                        status_msg = otel_status.description
                        if len(status_msg) > 2048:
                            logger.warning(
                                "Status description truncated from %d to 2048 chars",
                                len(status_msg),
                            )
                            status_msg = status_msg[:2048]
                        gcp_status = types.Span.Status(code=gcp_status.code, message=status_msg)
                    if gcp_status:
                        trace_span.status = gcp_status

                # Map OpenTelemetry SpanKind to Cloud Trace SpanKind
                kind_map = {
                    SpanKind.INTERNAL: types.Span.SpanKind.SPAN_KIND_UNSPECIFIED,
                    SpanKind.SERVER: types.Span.SpanKind.RPC_SERVER,
                    SpanKind.CLIENT: types.Span.SpanKind.RPC_CLIENT,
                    SpanKind.PRODUCER: types.Span.SpanKind.SPAN_KIND_UNSPECIFIED,
                    SpanKind.CONSUMER: types.Span.SpanKind.SPAN_KIND_UNSPECIFIED,
                }
                trace_span.span_kind = kind_map.get(
                    span.kind, types.Span.SpanKind.SPAN_KIND_UNSPECIFIED
                )

                # Add resource + span attributes
                trace_span.attributes = self._convert_attributes(
                    span.attributes, self._resource_attrs
                )

                # Fix parent span propagation
                parent_span_id = ""
                if span.parent and hasattr(span.parent, "span_id") and span.parent.span_id:
                    parent_span_id = format(span.parent.span_id, "016x")
                trace_span.parent_span_id = parent_span_id

                # Convert links (enforce GCP limit: 128 links per span)
                if span.links:
                    gcp_links = []
                    links_to_process = span.links[:MAX_SPAN_LINKS]

                    if len(span.links) > MAX_SPAN_LINKS:
                        logger.warning(
                            "Span has %d links, truncating to GCP limit of %d",
                            len(span.links),
                            MAX_SPAN_LINKS,
                        )

                    for link in links_to_process:
                        try:
                            link_trace_id = format(link.context.trace_id, "032x")
                            link_span_id = format(link.context.span_id, "016x")
                            gcp_link = types.Span.Link(
                                trace_id=link_trace_id,
                                span_id=link_span_id,
                                type=types.Span.Link.Type.TYPE_UNSPECIFIED,
                                attributes=(
                                    self._convert_attributes(link.attributes)
                                    if link.attributes
                                    else None
                                ),
                            )
                            gcp_links.append(gcp_link)
                        except Exception as e:
                            logger.warning("Failed to convert span link: %s", e)
                            continue

                    if gcp_links:
                        trace_span.links = types.Span.Links(
                            link=gcp_links,
                            dropped_links_count=max(0, len(span.links) - MAX_SPAN_LINKS),
                        )

                # Convert events to time events (enforce GCP limit: 128 events per span)
                if span.events:
                    gcp_events = []
                    events_to_process = span.events[:MAX_SPAN_EVENTS]

                    if len(span.events) > MAX_SPAN_EVENTS:
                        logger.warning(
                            "Span has %d events, truncating to GCP limit of %d",
                            len(span.events),
                            MAX_SPAN_EVENTS,
                        )

                    for event in events_to_process:
                        try:
                            # Truncate event name to 256 chars
                            # Rationale: Event names are stored in Annotation.description field,
                            # which uses TruncatableString (same as attribute values, 256 char limit).
                            # Display names use 128 char limit for UI brevity.
                            event_name = event.name[:256]
                            gcp_event = types.Span.TimeEvent(
                                time=Timestamp(
                                    seconds=int(event.timestamp // 1_000_000_000),
                                    nanos=int(event.timestamp % 1_000_000_000),
                                ),
                                annotation=types.Span.TimeEvent.Annotation(
                                    description=types.TruncatableString(value=event_name),
                                    attributes=(
                                        self._convert_attributes(event.attributes)
                                        if event.attributes
                                        else None
                                    ),
                                ),
                            )
                            gcp_events.append(gcp_event)
                        except Exception as e:
                            logger.warning("Failed to convert span event: %s", e)
                            continue

                    if gcp_events:
                        trace_span.time_events = types.Span.TimeEvents(
                            time_event=gcp_events,
                            dropped_annotations_count=max(0, len(span.events) - MAX_SPAN_EVENTS),
                        )

                trace_spans.append(trace_span)

            except (TypeError, AttributeError, ValueError, KeyError) as e:
                # Expected errors: invalid data, missing fields, type mismatches
                logger.warning("Invalid span data for span %d: %s", span_idx, e)
                spans_dropped_this_batch += 1
                continue
            except Exception as e:
                # Unexpected error - this is a BUG, not bad data
                # REGRESSION FIX: Catch specific exceptions only, not blanket Exception
                # Blanket catching masked SDK bugs including typos and logic errors
                logger.error(
                    "CRITICAL: Unexpected error converting span %d (likely SDK bug): %s. "
                    "Span dropped. Please report this.",
                    span_idx,
                    e,
                )
                spans_dropped_this_batch += 1
                # Re-raise in debug mode to catch bugs during development
                if logger.isEnabledFor(logging.DEBUG):
                    raise
                continue

        # REGRESSION FIX: Update metrics once after loop, not inside loop
        # Prevents lock thrashing when many spans fail (e.g., 100 failures = 1 lock, not 100)
        if spans_dropped_this_batch > 0:
            with self._metrics_lock:
                self._metrics["spans_dropped_total"] += spans_dropped_this_batch

        return trace_spans

    def _convert_attributes(
        self,
        attributes: Optional[Dict[str, Any]],
        resource_attributes: Optional[Dict[str, Any]] = None,
    ) -> "types.Span.Attributes":
        """Convert OTel attributes to Cloud Trace attributes format.

        Safely truncates values and logs warnings when truncation occurs.
        Merges resource attributes for better Cloud Trace UI filtering.

        Attribute Merge Priority (IMPORTANT):
        - Resource attributes are added FIRST (lower priority)
        - Span attributes are added SECOND (higher priority, can override resource)
        - **Rationale**: Span-specific values should override resource defaults.
          For example, if resource has service.name="default" but span has
          service.name="worker-1", the span value "worker-1" should win.
        - **Use Case**: Allows per-span customization while maintaining resource defaults

        Cloud Trace limits:
        - Attribute keys must be strings (invalid keys skipped)
        - Attribute values truncated to 256 characters (GCP limit, not OTel's 1024)
        - Non-primitive types (dict, list) are dropped

        Args:
            attributes: Span attributes (optional)
            resource_attributes: Resource attributes to merge (optional)

        Returns:
            Cloud Trace Attributes object with attribute_map and dropped_attributes_count

        Example:
            >>> # Resource attributes
            >>> resource_attrs = {"service.name": "my-service", "env": "prod"}
            >>> # Span attributes (override service.name)
            >>> span_attrs = {"service.name": "worker-1", "span_id": "123"}
            >>> result = exporter._convert_attributes(span_attrs, resource_attrs)
            >>> # Result: service.name="worker-1", env="prod", span_id="123"
        """
        from google.cloud.trace_v2 import types

        # GCP Cloud Trace attribute limits (NOT OpenTelemetry limits)
        # OTel spec allows 1024 chars for attribute values (MAX_ATTRIBUTE_LENGTH in CLAUDE.md)
        # GCP Cloud Trace uses TruncatableString with lower limits:
        # - Attribute values: 256 chars (enforced by TruncatableString UI rendering)
        # - Attribute keys: 128 chars (practical limit for UI display)
        MAX_ATTR_VALUE_LENGTH = 256  # GCP Cloud Trace limit
        MAX_ATTR_KEY_LENGTH = 128  # GCP Cloud Trace limit

        if not attributes and not resource_attributes:
            return types.Span.Attributes()

        attribute_map = {}
        dropped_count = 0

        # Add resource attributes first (lower priority)
        if resource_attributes:
            for key, value in resource_attributes.items():
                try:
                    # Validate key is string
                    if not isinstance(key, str):
                        dropped_count += 1
                        logger.debug(
                            "Dropping resource attribute with non-string key: %s",
                            type(key).__name__,
                        )
                        continue

                    # Truncate key if too long
                    if len(key) > MAX_ATTR_KEY_LENGTH:
                        if self._truncation_limiter.should_warn():
                            logger.warning(
                                "Resource attribute key '%s...' truncated from %d to %d chars",
                                key[:20],
                                len(key),
                                MAX_ATTR_KEY_LENGTH,
                            )
                        key = key[:MAX_ATTR_KEY_LENGTH]

                    if isinstance(value, bool):
                        # Use bool_value for boolean attributes (preserves type fidelity)
                        attribute_map[key] = types.AttributeValue(bool_value=value)
                    elif isinstance(value, int):
                        # Try int_value first (preserves type), fallback to string if overflow
                        try:
                            attribute_map[key] = types.AttributeValue(int_value=value)
                        except (TypeError, OverflowError):
                            # Integer out of 64-bit range, use string representation
                            logger.debug(
                                "Integer value for '%s' out of 64-bit range, using string", key
                            )
                            str_value = str(value)
                            if len(str_value) > MAX_ATTR_VALUE_LENGTH:
                                str_value = str_value[:MAX_ATTR_VALUE_LENGTH]
                            attribute_map[key] = types.AttributeValue(
                                string_value=types.TruncatableString(value=str_value)
                            )
                    elif isinstance(value, (str, float)):
                        # Convert str and float to string (float needs conversion)
                        str_value = str(value)
                        if len(str_value) > MAX_ATTR_VALUE_LENGTH:
                            if self._truncation_limiter.should_warn():
                                logger.warning(
                                    "Resource attribute '%s' truncated from %d to %d chars",
                                    key,
                                    len(str_value),
                                    MAX_ATTR_VALUE_LENGTH,
                                )
                            str_value = str_value[:MAX_ATTR_VALUE_LENGTH]
                        attribute_map[key] = types.AttributeValue(
                            string_value=types.TruncatableString(value=str_value)
                        )
                    else:
                        dropped_count += 1
                        logger.debug(
                            "Dropping non-primitive resource attribute '%s' of type %s",
                            key,
                            type(value).__name__,
                        )
                except Exception as e:
                    dropped_count += 1
                    logger.warning("Failed to convert resource attribute '%s': %s", key, e)

        # Add span attributes (higher priority, can override resource)
        if attributes:
            for key, value in attributes.items():
                try:
                    # Validate key is string
                    if not isinstance(key, str):
                        dropped_count += 1
                        logger.debug(
                            "Dropping attribute with non-string key: %s", type(key).__name__
                        )
                        continue

                    # Truncate key if too long
                    if len(key) > MAX_ATTR_KEY_LENGTH:
                        if self._truncation_limiter.should_warn():
                            logger.warning(
                                "Attribute key '%s...' truncated from %d to %d chars",
                                key[:20],
                                len(key),
                                MAX_ATTR_KEY_LENGTH,
                            )
                        key = key[:MAX_ATTR_KEY_LENGTH]

                    if isinstance(value, bool):
                        # Use bool_value for boolean attributes (preserves type fidelity)
                        attribute_map[key] = types.AttributeValue(bool_value=value)
                    elif isinstance(value, int):
                        # Try int_value first (preserves type), fallback to string if overflow
                        try:
                            attribute_map[key] = types.AttributeValue(int_value=value)
                        except (TypeError, OverflowError):
                            # Integer out of 64-bit range, use string representation
                            logger.debug(
                                "Integer value for '%s' out of 64-bit range, using string", key
                            )
                            str_value = str(value)
                            if len(str_value) > MAX_ATTR_VALUE_LENGTH:
                                str_value = str_value[:MAX_ATTR_VALUE_LENGTH]
                            attribute_map[key] = types.AttributeValue(
                                string_value=types.TruncatableString(value=str_value)
                            )
                    elif isinstance(value, (str, float)):
                        # Convert str and float to string (float needs conversion)
                        str_value = str(value)
                        if len(str_value) > MAX_ATTR_VALUE_LENGTH:
                            if self._truncation_limiter.should_warn():
                                logger.warning(
                                    "Span attribute '%s' truncated from %d to %d chars",
                                    key,
                                    len(str_value),
                                    MAX_ATTR_VALUE_LENGTH,
                                )
                            str_value = str_value[:MAX_ATTR_VALUE_LENGTH]
                        attribute_map[key] = types.AttributeValue(
                            string_value=types.TruncatableString(value=str_value)
                        )
                    else:
                        dropped_count += 1
                        logger.debug(
                            "Dropping non-primitive attribute '%s' of type %s",
                            key,
                            type(value).__name__,
                        )
                except Exception as e:
                    dropped_count += 1
                    logger.warning("Failed to convert span attribute '%s': %s", key, e)

        result = types.Span.Attributes(attribute_map=attribute_map)
        if dropped_count > 0:
            result.dropped_attributes_count = dropped_count

        return result

    def shutdown(self) -> None:
        """Shutdown the exporter and cleanup resources."""
        logger.info("Shutting down GCP Cloud Trace exporter")

    def force_flush(self, timeout_millis: int = 30000) -> bool:
        """Force flush any buffered spans.

        Note: The GCP Cloud Trace client itself doesn't buffer spans. However,
        when this exporter is wrapped by BatchSpanProcessor (which MCA SDK does),
        you should call force_flush() on the TracerProvider or SpanProcessor
        instead, not on this exporter directly.

        This method exists to satisfy the SpanExporter interface.

        Args:
            timeout_millis: Timeout in milliseconds (unused)

        Returns:
            True always (no buffering at this level)
        """
        return True

    def get_metrics(self) -> Dict[str, Any]:
        """Get internal telemetry metrics for observability.

        These metrics track the exporter's health and can be used for
        monitoring, alerting, and debugging Cloud Trace integration issues.

        Returns:
            Dictionary with the following metrics:
            - spans_exported_total: Total spans successfully exported
            - spans_dropped_total: Total spans dropped due to validation failures
            - export_attempts_total: Total export() calls
            - export_failures_total: Total failed exports (after retries)
            - batches_exported_total: Total batches sent to GCP
            - retries_total: Total retry attempts across all exports
            - last_export_duration_seconds: Duration of most recent export

        Example:
            >>> metrics = exporter.get_metrics()
            >>> print(f"Exported {metrics['spans_exported_total']} spans")
            >>> print(f"Failure rate: {metrics['export_failures_total'] / metrics['export_attempts_total']:.2%}")
        """
        return {
            **self._metrics,
            "last_export_duration_seconds": self._last_export_duration_seconds,
        }
