"""
TraceKit Client - Main tracing client using OpenTelemetry
"""

import os
import random
import traceback
from dataclasses import dataclass
from typing import Any, Dict, Optional

from opentelemetry import trace, context
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanProcessor, SpanExportResult
from opentelemetry.trace import Span, Status, StatusCode
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.urllib import URLLibInstrumentor
from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor
from opentelemetry.sdk.trace import ReadableSpan

from tracekit.snapshot_client import SnapshotClient
from tracekit.metrics import MetricsRegistry, Counter, Gauge, Histogram, noop_counter, noop_gauge, noop_histogram


def _detect_local_ui() -> bool:
    """
    Detect if TraceKit Local UI is running at http://localhost:9999.

    Returns:
        True if local UI is available, False otherwise
    """
    try:
        import http.client
        conn = http.client.HTTPConnection('localhost', 9999, timeout=0.5)
        conn.request('GET', '/api/health')
        response = conn.getresponse()
        status = response.status
        response.read()  # Consume response to free connection
        conn.close()
        return status == 200
    except:
        return False


def _is_development_mode() -> bool:
    """
    Check if running in development mode based on environment variables.

    Returns:
        True if in development mode, False otherwise
    """
    env = os.getenv('ENV', '').lower()
    node_env = os.getenv('NODE_ENV', '').lower()
    return env == 'development' or node_env == 'development'


class LocalUISpanProcessor(SpanProcessor):
    """
    Custom span processor that sends traces to TraceKit Local UI in development mode.
    This runs in addition to the main cloud exporter.
    """

    def __init__(self):
        self.local_ui_available = False
        self.local_ui_checked = False
        self.local_ui_url = 'http://localhost:9999/v1/traces'

    def on_start(self, span: ReadableSpan, parent_context=None) -> None:
        """Called when a span is started."""
        pass

    def on_end(self, span: ReadableSpan) -> None:
        """Called when a span is ended. Send to local UI if available."""
        # Only check once per process
        if not self.local_ui_checked:
            self.local_ui_checked = True
            self.local_ui_available = _detect_local_ui()
            if self.local_ui_available:
                print('🔍 Local UI detected at http://localhost:9999')

        if not self.local_ui_available:
            return

        # Send to local UI using raw HTTP to avoid instrumentation infinite loop
        # Cannot use requests, urllib, or urllib3 - all are instrumented by OpenTelemetry
        try:
            import http.client
            import json

            # Convert span to OTLP JSON format (same format as Node.js SDK)
            otlp_payload = self._convert_span_to_otlp(span)
            payload = json.dumps(otlp_payload)

            # Use http.client (not instrumented by OpenTelemetry)
            conn = http.client.HTTPConnection('localhost', 9999, timeout=1.0)
            conn.request(
                'POST',
                '/v1/traces',
                body=payload.encode('utf-8'),
                headers={'Content-Type': 'application/json'}
            )
            response = conn.getresponse()
            response.read()  # Consume response to free connection
            conn.close()
        except Exception as e:
            # Silently fail - don't block trace sending to cloud
            pass

    def _convert_span_to_otlp(self, span: ReadableSpan) -> dict:
        """Convert ReadableSpan to OTLP JSON format."""
        # Convert resource attributes
        resource_attrs = []
        if span.resource:
            for key, value in span.resource.attributes.items():
                resource_attrs.append({
                    'key': key,
                    'value': self._convert_value(value)
                })

        # Convert span attributes
        span_attrs = []
        for key, value in span.attributes.items():
            span_attrs.append({
                'key': key,
                'value': self._convert_value(value)
            })

        # Convert events
        events = []
        for event in span.events:
            event_attrs = []
            for key, value in (event.attributes or {}).items():
                event_attrs.append({
                    'key': key,
                    'value': self._convert_value(value)
                })
            events.append({
                'timeUnixNano': str(event.timestamp),
                'name': event.name,
                'attributes': event_attrs
            })

        # Build OTLP structure
        return {
            'resourceSpans': [{
                'resource': {
                    'attributes': resource_attrs
                },
                'scopeSpans': [{
                    'scope': {
                        'name': span.instrumentation_scope.name if span.instrumentation_scope else '',
                        'version': span.instrumentation_scope.version if span.instrumentation_scope else ''
                    },
                    'spans': [{
                        'traceId': format(span.context.trace_id, '032x'),
                        'spanId': format(span.context.span_id, '016x'),
                        'parentSpanId': format(span.parent.span_id, '016x') if span.parent else '',
                        'name': span.name,
                        'kind': span.kind.value if span.kind else 0,
                        'startTimeUnixNano': str(span.start_time),
                        'endTimeUnixNano': str(span.end_time),
                        'attributes': span_attrs,
                        'events': events,
                        'status': {
                            'code': span.status.status_code.value if span.status else 0,
                            'message': span.status.description if span.status else ''
                        }
                    }]
                }]
            }]
        }

    def _convert_value(self, value: Any) -> dict:
        """Convert attribute value to OTLP format."""
        if isinstance(value, str):
            return {'stringValue': value}
        elif isinstance(value, bool):
            return {'boolValue': value}
        elif isinstance(value, int):
            return {'intValue': str(value)}
        elif isinstance(value, float):
            return {'doubleValue': value}
        elif isinstance(value, (list, tuple)):
            return {
                'arrayValue': {
                    'values': [self._convert_value(v) for v in value]
                }
            }
        else:
            return {'stringValue': str(value)}

    def shutdown(self) -> None:
        """Called when the processor is shut down."""
        pass

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


def _resolve_endpoint(endpoint: str, path: str, use_ssl: bool = True) -> str:
    """
    Resolve endpoint URL from base endpoint and path.

    Always strips any existing service-specific paths and appends the correct path.

    Args:
        endpoint: Base endpoint (host or full URL)
        path: Path to append (e.g. '/v1/traces')
        use_ssl: Whether to use SSL (ignored if endpoint has scheme)

    Returns:
        Full endpoint URL
    """
    # If endpoint has a scheme (http:// or https://)
    if endpoint.startswith('http://') or endpoint.startswith('https://'):
        endpoint = endpoint.rstrip('/')  # Remove trailing slash

        trimmed = endpoint.replace('https://', '').replace('http://', '')

        # If endpoint has a path component, extract base URL
        if '/' in trimmed:
            # Always extract base URL and append correct path
            base = _extract_base_url(endpoint)
            return base + path if path else base

        # Just host with scheme, add the path
        return endpoint + path

    # No scheme provided - build URL with scheme
    scheme = 'https://' if use_ssl else 'http://'
    endpoint = endpoint.rstrip('/')  # Remove trailing slash
    return scheme + endpoint + path


def _extract_base_url(full_url: str) -> str:
    """
    Extract base URL (scheme + host) from full URL, only if it contains
    known service-specific paths.

    Args:
        full_url: Full URL with scheme and path

    Returns:
        Base URL (scheme + host only) if service-specific path detected,
        otherwise returns full URL
    """
    # Check if URL contains known service-specific paths
    has_service_path = any(
        path in full_url
        for path in ['/v1/traces', '/v1/metrics', '/api/v1/traces', '/api/v1/metrics']
    )

    # If it doesn't have a service-specific path, keep the URL as-is
    if not has_service_path:
        return full_url

    # Extract scheme
    if full_url.startswith('https://'):
        scheme = 'https://'
        remaining = full_url[8:]
    elif full_url.startswith('http://'):
        scheme = 'http://'
        remaining = full_url[7:]
    else:
        return full_url

    # Find first "/" to separate host from path
    idx = remaining.find('/')
    if idx != -1:
        return scheme + remaining[:idx]

    return scheme + remaining


@dataclass
class TracekitConfig:
    """Configuration for TraceKit client"""
    api_key: str
    service_name: str = "python-app"
    endpoint: str = "app.tracekit.dev"
    traces_path: str = "/v1/traces"
    metrics_path: str = "/v1/metrics"
    enabled: bool = True
    sample_rate: float = 1.0
    enable_code_monitoring: bool = False
    auto_instrument_http_client: bool = True
    # Map hostnames to service names for peer.service attribute
    # Useful for mapping localhost URLs to actual service names
    # Example: {"localhost:8082": "go-test-app", "localhost:8084": "node-test-app"}
    service_name_mappings: Optional[Dict[str, str]] = None


class TracekitClient:
    """
    Main TraceKit client for distributed tracing and code monitoring.

    Uses OpenTelemetry for standards-based distributed tracing.
    """

    def __init__(self, config: TracekitConfig):
        self.config = config
        self._snapshot_client: Optional[SnapshotClient] = None
        self._metrics_registry: Optional[MetricsRegistry] = None

        # Resolve endpoints
        use_ssl = not config.endpoint.startswith('http://')
        traces_endpoint = _resolve_endpoint(config.endpoint, config.traces_path, use_ssl)
        metrics_endpoint = _resolve_endpoint(config.endpoint, config.metrics_path, use_ssl)
        base_endpoint = _resolve_endpoint(config.endpoint, '', use_ssl)

        # Create resource with service name
        resource = Resource(attributes={
            SERVICE_NAME: config.service_name
        })

        # Initialize tracer provider
        self.provider = TracerProvider(resource=resource)

        if config.enabled:
            # Configure OTLP exporter for cloud
            exporter = OTLPSpanExporter(
                endpoint=traces_endpoint,
                headers={"X-API-Key": config.api_key}
            )

            # Use batch processor for better performance
            self.provider.add_span_processor(BatchSpanProcessor(exporter))

            # Add local UI processor in development mode
            if _is_development_mode():
                self.provider.add_span_processor(LocalUISpanProcessor())

            # Register the provider
            trace.set_tracer_provider(self.provider)

            # Auto-instrument HTTP clients for CLIENT span creation
            if config.auto_instrument_http_client:
                self._instrument_http_clients()

        self.tracer = trace.get_tracer(__name__, "1.0.0")

        # Initialize metrics registry
        self._metrics_registry = MetricsRegistry(metrics_endpoint, config.api_key, config.service_name)

        # Initialize snapshot client if enabled
        if config.enable_code_monitoring:
            self._snapshot_client = SnapshotClient(
                api_key=config.api_key,
                base_url=base_endpoint,
                service_name=config.service_name
            )
            self._snapshot_client.start()

    def start_trace(
        self,
        operation_name: str,
        attributes: Optional[Dict[str, Any]] = None
    ) -> Span:
        """
        Start a new root trace span (server request).

        Args:
            operation_name: Name of the operation
            attributes: Optional attributes to add to the span

        Returns:
            OpenTelemetry Span object
        """
        span = self.tracer.start_span(
            operation_name,
            kind=trace.SpanKind.SERVER,
            attributes=self._normalize_attributes(attributes or {})
        )
        return span

    def start_server_span(
        self,
        operation_name: str,
        attributes: Optional[Dict[str, Any]] = None,
        parent_context: Optional[Any] = None
    ) -> Span:
        """
        Start a SERVER span, properly inheriting from the provided context.
        This is used by middleware to create spans that are children of incoming trace context.

        Args:
            operation_name: Name of the operation
            attributes: Optional attributes to add to the span
            parent_context: Optional parent context from traceparent header extraction

        Returns:
            OpenTelemetry Span object
        """
        ctx = parent_context if parent_context else context.get_current()
        span = self.tracer.start_span(
            operation_name,
            kind=trace.SpanKind.SERVER,
            attributes=self._normalize_attributes(attributes or {}),
            context=ctx
        )
        return span

    def start_span(
        self,
        operation_name: str,
        attributes: Optional[Dict[str, Any]] = None
    ) -> Span:
        """
        Start a new child span. Automatically inherits from the currently active span.

        Args:
            operation_name: Name of the operation
            attributes: Optional attributes to add to the span

        Returns:
            OpenTelemetry Span object
        """
        span = self.tracer.start_span(
            operation_name,
            kind=trace.SpanKind.INTERNAL,
            attributes=self._normalize_attributes(attributes or {})
        )
        return span

    def end_span(
        self,
        span: Span,
        final_attributes: Optional[Dict[str, Any]] = None,
        status: str = "OK"
    ) -> None:
        """
        End a span with optional final attributes.

        Args:
            span: The span to end
            final_attributes: Optional attributes to add before ending
            status: Span status ('OK' or 'ERROR')
        """
        if final_attributes:
            span.set_attributes(self._normalize_attributes(final_attributes))

        if status == "ERROR":
            span.set_status(Status(StatusCode.ERROR))
        elif status == "OK":
            span.set_status(Status(StatusCode.OK))

        span.end()

    def add_event(
        self,
        span: Span,
        name: str,
        attributes: Optional[Dict[str, Any]] = None
    ) -> None:
        """
        Add an event to a span.

        Args:
            span: The span to add the event to
            name: Name of the event
            attributes: Optional attributes for the event
        """
        span.add_event(name, attributes=self._normalize_attributes(attributes or {}))

    def record_exception(
        self,
        span: Span,
        exception: Exception
    ) -> None:
        """
        Record an exception on a span with formatted stack trace for code discovery.

        Args:
            span: The span to record the exception on
            exception: The exception to record
        """
        # Format stack trace for code discovery
        formatted_stacktrace = self._format_stacktrace(exception)

        # Record exception as an event with formatted stack trace
        span.add_event(
            "exception",
            attributes={
                "exception.type": type(exception).__name__,
                "exception.message": str(exception),
                "exception.stacktrace": formatted_stacktrace,
            }
        )

        # Also use standard OpenTelemetry exception recording
        span.record_exception(exception)

        span.set_status(Status(StatusCode.ERROR, str(exception)))

    def _format_stacktrace(self, exception: Exception) -> str:
        """
        Format stack trace for code discovery.
        Returns native Python traceback format which matches backend expectations:
        File "filename.py", line 123, in function_name

        Args:
            exception: The exception to format

        Returns:
            Native Python stack trace string
        """
        tb_lines = traceback.format_exception(
            type(exception),
            exception,
            exception.__traceback__
        )

        # Return native Python traceback format - backend expects this exact format
        # Pattern: File "([^"]+)", line (\d+), in (\S+)
        return "".join(tb_lines)

    async def flush(self) -> None:
        """Force flush all pending spans to the backend."""
        if self.config.enabled:
            self.provider.force_flush()

    async def shutdown(self) -> None:
        """Shutdown the tracer provider, metrics registry, and snapshot client."""
        # Stop snapshot client first
        if self._snapshot_client:
            self._snapshot_client.stop()

        # Shutdown metrics registry
        if self._metrics_registry:
            self._metrics_registry.shutdown()

        # Shutdown tracing provider
        if self.config.enabled:
            self.provider.shutdown()

    def is_enabled(self) -> bool:
        """Check if tracing is enabled."""
        return self.config.enabled and bool(self.config.api_key)

    def should_sample(self) -> bool:
        """Determine if the current request should be sampled."""
        return random.random() < self.config.sample_rate

    def get_tracer(self) -> trace.Tracer:
        """Get the underlying OpenTelemetry tracer."""
        return self.tracer

    def get_snapshot_client(self) -> Optional[SnapshotClient]:
        """Get the snapshot client if code monitoring is enabled."""
        return self._snapshot_client

    def counter(self, name: str, tags: Dict[str, str] = None) -> Counter:
        """
        Get or create a counter metric.

        Args:
            name: Metric name
            tags: Optional tags/labels for the metric

        Returns:
            Counter instance
        """
        if self._metrics_registry:
            return self._metrics_registry.counter(name, tags)
        return noop_counter

    def gauge(self, name: str, tags: Dict[str, str] = None) -> Gauge:
        """
        Get or create a gauge metric.

        Args:
            name: Metric name
            tags: Optional tags/labels for the metric

        Returns:
            Gauge instance
        """
        if self._metrics_registry:
            return self._metrics_registry.gauge(name, tags)
        return noop_gauge

    def histogram(self, name: str, tags: Dict[str, str] = None) -> Histogram:
        """
        Get or create a histogram metric.

        Args:
            name: Metric name
            tags: Optional tags/labels for the metric

        Returns:
            Histogram instance
        """
        if self._metrics_registry:
            return self._metrics_registry.histogram(name, tags)
        return noop_histogram

    def capture_snapshot(
        self,
        label: str,
        variables: Optional[Dict[str, Any]] = None
    ) -> None:
        """
        Convenience method for capturing snapshots.

        Args:
            label: Label for the snapshot
            variables: Variables to capture
        """
        if self._snapshot_client:
            self._snapshot_client.check_and_capture_with_context(
                label,
                variables or {}
            )

    def _instrument_http_clients(self) -> None:
        """
        Auto-instrument HTTP client libraries to create CLIENT spans
        with peer.service attribute for service dependency mapping.
        """
        # Instrument requests library (most common)
        try:
            RequestsInstrumentor().instrument(
                tracer_provider=self.provider,
                request_hook=self._http_request_hook,
            )
        except Exception as e:
            # Ignore if already instrumented or not available
            pass

        # Instrument urllib
        try:
            URLLibInstrumentor().instrument(
                tracer_provider=self.provider,
                request_hook=self._http_request_hook,
            )
        except Exception as e:
            pass

        # Instrument urllib3
        try:
            URLLib3Instrumentor().instrument(
                tracer_provider=self.provider,
                request_hook=self._http_request_hook,
            )
        except Exception as e:
            pass

    def _http_request_hook(self, span: Span, request: Any) -> None:
        """
        Hook called for outgoing HTTP requests to add peer.service attribute.

        Args:
            span: The CLIENT span being created
            request: The request object (varies by library)
        """
        try:
            # Extract hostname from request (different per library)
            hostname = None

            # requests library
            if hasattr(request, 'url'):
                from urllib.parse import urlparse
                parsed = urlparse(request.url)
                hostname = parsed.hostname
            # urllib/urllib3
            elif hasattr(request, 'host'):
                hostname = request.host

            if hostname:
                service_name = self._extract_service_name(hostname)
                span.set_attribute("peer.service", service_name)
        except Exception:
            # Don't fail request if attribute extraction fails
            pass

    def _extract_service_name(self, hostname: str) -> str:
        """
        Extract service name from hostname for service-to-service mapping.

        Examples:
            "payment-service" -> "payment-service"
            "payment.internal.svc.cluster.local" -> "payment"
            "api.example.com" -> "api.example.com"

        Args:
            hostname: The hostname to extract from

        Returns:
            Extracted service name
        """
        if not hostname:
            return "unknown"

        # First, check if there's a configured mapping for this hostname
        # This allows mapping localhost:port to actual service names
        if self.config.service_name_mappings:
            if hostname in self.config.service_name_mappings:
                return self.config.service_name_mappings[hostname]

            # Also check without port
            host_without_port = hostname.split(":")[0]
            if host_without_port in self.config.service_name_mappings:
                return self.config.service_name_mappings[host_without_port]

        # Handle Kubernetes service names
        if ".svc.cluster.local" in hostname:
            return hostname.split(".")[0]

        # Handle internal domain
        if ".internal" in hostname:
            return hostname.split(".")[0]

        # Default: return full hostname (strip port if present)
        return hostname.split(":")[0]

    def _normalize_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
        """
        Normalize attributes to OpenTelemetry compatible types.

        Args:
            attributes: Raw attributes dictionary

        Returns:
            Normalized attributes dictionary
        """
        normalized = {}
        for key, value in attributes.items():
            if isinstance(value, (str, int, float, bool)):
                normalized[key] = value
            elif isinstance(value, (list, tuple)):
                normalized[key] = [str(v) for v in value]
            else:
                normalized[key] = str(value)
        return normalized
