"""OTelInitializer: unified entry point for logging + tracing configuration.

Configures both the OTelLoggingHandler and an optional TracerProvider from a
single call, sharing a common Resource identity.  When no tracing endpoint is
available the SDK falls back to a no-op TracerProvider so the application
never crashes due to missing telemetry infrastructure.

Tracing SDK packages (``opentelemetry-sdk``, ``opentelemetry-exporter-otlp-proto-grpc``)
are imported lazily so that logging-only users are not forced to install them.

Requirements: 1.1–1.5, 2.2–2.4, 3.1, 3.2, 3.5, 6.1–6.3, 7.1–7.5, 8.1,
              9.1–9.7, 10.1–10.4, 12.1–12.4
"""

from __future__ import annotations

import atexit
import logging
import os
import sys
import threading
from typing import Any, Dict, List, Optional, Tuple

from .handler import OTelLoggingHandler
from .instrumentation_config import InitResult, InstrumentationConfig
from .resource_provider import ResourceConfig, ResourceProvider

logger = logging.getLogger(__name__)


class OTelInitializer:
    """Unified initializer for OTel logging and tracing.

    Call :meth:`initialize` to configure both pipelines in one step.
    The created ``TracerProvider`` is stored as a class variable so that
    subsequent calls reuse the same instance (singleton pattern).
    """

    _provider: Any = None  # Singleton TracerProvider (SDK or no-op)

    @classmethod
    def initialize(
        cls,
        resource_config: Optional[ResourceConfig] = None,
        instrumentation_config: Optional[InstrumentationConfig] = None,
        auto_instrumentors: Optional[List[Tuple[Any, Optional[Any]]]] = None,
    ) -> InitResult:
        """Configure logging and (optionally) tracing, returning an InitResult.

        Args:
            resource_config: Resource attributes for both pipelines.
                Falls back to env vars and defaults when ``None``.
            instrumentation_config: Tracing pipeline settings.  When ``None``
                and ``OTEL_EXPORTER_OTLP_ENDPOINT`` is unset, tracing is
                disabled (no-op).
            auto_instrumentors: List of ``(instrumentor, app_or_None)`` tuples.
                Activation is deferred to a later task; accepted here for API
                compatibility.

        Returns:
            An :class:`InitResult` with the handler, tracer, and shutdown
            callable.
        """
        # ---- 1. Build shared Resource ----
        resource_attrs = cls._build_resource(resource_config)

        # Build a ResourceConfig that reflects the merged attributes so the
        # handler's ResourceProvider produces the same resource dict.
        merged_config = cls._resource_attrs_to_config(resource_attrs)

        # ---- 2. Configure logging handler ----
        handler = OTelLoggingHandler(resource_config=merged_config)

        # ---- 3. Resolve tracing endpoint ----
        endpoint = cls._resolve_endpoint(instrumentation_config)

        # ---- 4. Configure TracerProvider ----
        tracer: Any = None
        shutdown = lambda: None  # noqa: E731  — no-op default

        if endpoint:
            tracer, shutdown = cls._setup_tracing(
                endpoint, resource_attrs, instrumentation_config
            )
        else:
            # No endpoint → no-op tracer from the API package
            tracer = cls._get_noop_tracer()

        # ---- 5. Activate auto-instrumentors ----
        if auto_instrumentors:
            cls._activate_instrumentors(auto_instrumentors)

        return InitResult(handler=handler, tracer=tracer, shutdown=shutdown)

    # ------------------------------------------------------------------
    # Auto-instrumentor activation
    # ------------------------------------------------------------------

    @classmethod
    def _activate_instrumentors(
        cls,
        auto_instrumentors: List[Tuple[Any, Optional[Any]]],
    ) -> None:
        """Activate auto-instrumentors after TracerProvider is configured.

        Each tuple is ``(instrumentor_instance, app_instance_or_None)``.
        For instrumentors that need an app (e.g. FlaskInstrumentor), calls
        ``instrumentor.instrument_app(app)``.  For global instrumentors
        (app is ``None``), calls ``instrumentor.instrument()``.

        Failures are logged as warnings; the SDK never crashes due to
        instrumentor failures.
        """
        for instrumentor, app in auto_instrumentors:
            try:
                if app is not None:
                    instrumentor.instrument_app(app)
                else:
                    instrumentor.instrument()
            except Exception as exc:
                logger.warning(
                    "Failed to activate auto-instrumentor %r: %s",
                    instrumentor,
                    exc,
                )

    # ------------------------------------------------------------------
    # Resource helpers
    # ------------------------------------------------------------------

    @classmethod
    def _build_resource(cls, config: Optional[ResourceConfig] = None) -> Dict[str, Any]:
        """Merge resource attributes with correct precedence.

        Resolution order (highest wins):
        1. Explicit ResourceConfig fields
        2. ResourceConfig.custom_attributes
        3. OTEL_SERVICE_NAME env var (service.name only)
        4. OTEL_RESOURCE_ATTRIBUTES env var
        5. Default: service.name = "unknown_service"
        """
        # Start with OTEL_RESOURCE_ATTRIBUTES (lowest precedence of env vars)
        merged: Dict[str, Any] = ResourceProvider.parse_otel_resource_attributes()

        # Layer OTEL_SERVICE_NAME on top (overrides OTEL_RESOURCE_ATTRIBUTES)
        env_service_name = os.environ.get("OTEL_SERVICE_NAME")
        if env_service_name:
            merged["service.name"] = env_service_name

        # Layer custom_attributes from config
        cfg = config or ResourceConfig()
        if cfg.custom_attributes:
            merged.update(cfg.custom_attributes)

        # Layer explicit ResourceConfig fields (highest precedence)
        # Only override if the caller actually set a non-default value.
        if cfg.service_name != "unknown_service":
            merged["service.name"] = cfg.service_name
        elif "service.name" not in merged:
            merged["service.name"] = "unknown_service"

        if cfg.service_version is not None:
            merged["service.version"] = cfg.service_version

        if cfg.deployment_environment is not None:
            merged["deployment.environment"] = cfg.deployment_environment

        return merged

    @staticmethod
    def _resource_attrs_to_config(attrs: Dict[str, Any]) -> ResourceConfig:
        """Convert a flat resource-attributes dict back to a ResourceConfig."""
        service_name = str(attrs.get("service.name", "unknown_service"))
        service_version = attrs.get("service.version")
        deployment_env = attrs.get("deployment.environment")

        # Everything that isn't one of the three well-known keys goes into
        # custom_attributes.
        well_known = {"service.name", "service.version", "deployment.environment"}
        custom = {k: v for k, v in attrs.items() if k not in well_known}

        return ResourceConfig(
            service_name=service_name,
            service_version=str(service_version) if service_version is not None else None,
            deployment_environment=str(deployment_env) if deployment_env is not None else None,
            custom_attributes=custom if custom else None,
        )

    # ------------------------------------------------------------------
    # Env-var resolution helpers
    # ------------------------------------------------------------------

    @classmethod
    def _resolve_endpoint(
        cls, config: Optional[InstrumentationConfig]
    ) -> Optional[str]:
        """Return the OTLP endpoint or ``None`` if tracing should be disabled."""
        if config and config.endpoint:
            return config.endpoint
        env_ep = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "")
        return env_ep if env_ep else None

    @classmethod
    def _resolve_protocol(cls, config: Optional[InstrumentationConfig]) -> str:
        if config and config.protocol:
            return config.protocol
        return os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")

    @classmethod
    def _resolve_headers(
        cls, config: Optional[InstrumentationConfig]
    ) -> Optional[Dict[str, str]]:
        if config and config.headers is not None:
            return config.headers
        raw = os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "")
        if not raw:
            return None
        headers: Dict[str, str] = {}
        for pair in raw.split(","):
            if "=" in pair:
                k, v = pair.split("=", 1)
                headers[k.strip()] = v.strip()
            else:
                print(
                    f"Warning: skipping malformed OTEL_EXPORTER_OTLP_HEADERS pair: '{pair.strip()}'",
                    file=sys.stderr,
                )
        return headers if headers else None

    @classmethod
    def _resolve_tls_cert(cls, config: Optional[InstrumentationConfig]) -> Optional[str]:
        if config and config.tls_cert_path:
            return config.tls_cert_path
        cert = os.environ.get("OTEL_EXPORTER_OTLP_CERTIFICATE", "")
        return cert if cert else None

    # ------------------------------------------------------------------
    # Tracing setup
    # ------------------------------------------------------------------

    @classmethod
    def _setup_tracing(
        cls,
        endpoint: str,
        resource_attrs: Dict[str, Any],
        config: Optional[InstrumentationConfig],
    ) -> Tuple[Any, Any]:
        """Create TracerProvider + BatchSpanProcessor + OTLPSpanExporter.

        Returns ``(tracer, shutdown_callable)``.  Falls back to no-op on
        import errors (tracing SDK not installed).
        """
        try:
            from opentelemetry.sdk.trace import TracerProvider
            from opentelemetry.sdk.trace.export import BatchSpanProcessor
            from opentelemetry.sdk.resources import Resource
        except ImportError:
            logger.warning(
                "opentelemetry-sdk is not installed; falling back to no-op tracing. "
                "Install with: pip install opentelemetry-sdk"
            )
            return cls._get_noop_tracer(), lambda: None

        # Reuse existing provider (singleton)
        if cls._provider is not None:
            import opentelemetry.trace as trace_api
            tracer = trace_api.get_tracer(__name__)
            return tracer, lambda: None

        # Build SDK Resource from merged attributes
        sdk_resource = Resource.create(resource_attrs)

        # Create TracerProvider
        provider = TracerProvider(resource=sdk_resource)

        # Create span exporter
        exporter = cls._create_span_exporter(endpoint, config)
        processor = None
        if exporter is not None:
            # Pass batch_size and flush_interval_ms from InstrumentationConfig
            # to the BatchSpanProcessor.  Retry logic (max_retries,
            # retry_backoff_ms) is handled by the underlying gRPC/HTTP
            # transport's built-in retry mechanism with exponential backoff.
            bsp_kwargs: Dict[str, Any] = {}
            if config is not None:
                bsp_kwargs["max_export_batch_size"] = config.batch_size
                bsp_kwargs["schedule_delay_millis"] = config.flush_interval_ms
            processor = BatchSpanProcessor(exporter, **bsp_kwargs)
            provider.add_span_processor(processor)

        # Register as global provider
        import opentelemetry.trace as trace_api
        trace_api.set_tracer_provider(provider)
        cls._provider = provider

        tracer = trace_api.get_tracer(__name__)

        # Build idempotent shutdown handler
        shutdown_timeout_ms = config.shutdown_timeout_ms if config else 5000
        shutdown_called = threading.Event()

        def _shutdown() -> None:
            """Flush and shut down tracing pipeline. Idempotent."""
            if shutdown_called.is_set():
                return
            shutdown_called.set()

            timeout_s = shutdown_timeout_ms / 1000.0
            try:
                # 1. Flush BatchSpanProcessor (forces pending spans to export)
                if processor is not None:
                    processor.force_flush(timeout_millis=shutdown_timeout_ms)
                # 2. Shutdown the provider (shuts down processor + exporter)
                provider.shutdown()
            except Exception:
                # Shutdown is best-effort — never crash the application
                pass

        # Register with atexit for automatic cleanup on process exit
        atexit.register(_shutdown)

        return tracer, _shutdown

    @classmethod
    def _create_span_exporter(
        cls, endpoint: str, config: Optional[InstrumentationConfig]
    ) -> Any:
        """Create an OTLPSpanExporter for the resolved protocol."""
        protocol = cls._resolve_protocol(config)
        headers = cls._resolve_headers(config)
        tls_cert = cls._resolve_tls_cert(config)

        kwargs: Dict[str, Any] = {"endpoint": endpoint}
        if headers:
            kwargs["headers"] = headers
        if tls_cert:
            kwargs["certificate_file"] = tls_cert

        if protocol == "grpc":
            return cls._create_grpc_exporter(kwargs)
        else:
            return cls._create_http_exporter(kwargs)

    @staticmethod
    def _create_grpc_exporter(kwargs: Dict[str, Any]) -> Any:
        try:
            from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
                OTLPSpanExporter,
            )
            return OTLPSpanExporter(**kwargs)
        except ImportError:
            logger.warning(
                "opentelemetry-exporter-otlp-proto-grpc is not installed; "
                "span export disabled. Install with: "
                "pip install opentelemetry-exporter-otlp-proto-grpc"
            )
            return None

    @staticmethod
    def _create_http_exporter(kwargs: Dict[str, Any]) -> Any:
        try:
            from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
                OTLPSpanExporter,
            )
            return OTLPSpanExporter(**kwargs)
        except ImportError:
            logger.warning(
                "opentelemetry-exporter-otlp-proto-http is not installed; "
                "span export disabled. Install with: "
                "pip install opentelemetry-exporter-otlp-proto-http"
            )
            return None

    # ------------------------------------------------------------------
    # No-op fallback
    # ------------------------------------------------------------------

    @staticmethod
    def _get_noop_tracer() -> Any:
        """Return a no-op Tracer from the OTel API package."""
        try:
            import opentelemetry.trace as trace_api
            return trace_api.get_tracer(__name__)
        except ImportError:
            return None
