"""OpenTelemetry initialisation — Tier C observability.

Lazy imports throughout — the OpenTelemetry SDK is an optional extra
(``mgf-common[observability]``). Code paths in this module degrade
gracefully when the SDK is not installed: :func:`setup_otel` becomes a
no-op, :func:`operation_span` yields without opening a span, and
:func:`build_otlp_log_handler` returns ``None``.

Endpoints + service identity come from standard OpenTelemetry env vars:

  - ``OTEL_EXPORTER_OTLP_ENDPOINT``
  - ``OTEL_EXPORTER_OTLP_HEADERS``
  - ``OTEL_SERVICE_NAME``  (default: :func:`mgf.common.app_name`)
  - ``OTEL_RESOURCE_ATTRIBUTES``

This round ships only the foundation: the entry-point hook + the
span context manager + the OTLP log handler factory.

See ``docs/standards/LOGGING.md`` §7.
"""

from __future__ import annotations

import contextlib
import logging
import os
from typing import TYPE_CHECKING, Any

from mgf.common._identity import app_name

if TYPE_CHECKING:
    from collections.abc import Callable, Iterator
else:
    from collections.abc import Callable

_LOG = logging.getLogger(__name__)

# Module-level flag so re-calling setup_otel is a no-op rather than
# stacking a second TracerProvider.
_INSTALLED = False

# RA-16: one-shot guard for the "OTel cannot be re-wired" warning.
# OpenTelemetry's ``trace.set_tracer_provider`` is one-shot per
# process — the FIRST install wins permanently. A subsequent
# ``bootstrap()`` with different ``OtelSettings`` will SILENTLY
# inherit the first config's TracerProvider. The warning fires
# once per process the first time a second call would have wanted
# to install a NEW provider; consumers ignoring the limitation
# don't get spammed.
_REWIRE_WARNED = False


def _wire_otel(*, enabled: bool | None = None) -> None:
    """Initialise the OpenTelemetry SDK + OTLP span exporter.

    Internal — called by :func:`mgf.common.bootstrap`. The public
    ``setup_otel`` shim was removed pre-v1.0; consumers MUST use
    bootstrap. Idempotent — re-calling is a no-op rather than
    stacking a second TracerProvider.

    RA-16: when a second call would install a different
    TracerProvider (``enabled=True`` while ``_INSTALLED`` is
    already True), emit a one-shot
    :class:`mgf.common._warnings.MgfPlatformLimitation` warning so
    operators know the second config is being ignored.
    """
    global _INSTALLED, _REWIRE_WARNED
    if enabled is None:
        enabled = bool(os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"))
    if _INSTALLED:
        if enabled and not _REWIRE_WARNED:
            _REWIRE_WARNED = True
            import warnings as _warnings

            from mgf.common._warnings import MgfPlatformLimitation

            _warnings.warn(
                "OpenTelemetry is already wired this process; the new "
                "OtelSettings will be IGNORED. OTel's "
                "trace.set_tracer_provider is one-shot per process — "
                "the first bootstrap's TracerProvider is permanent. "
                "Subsequent spans continue through the first config's "
                "exporter regardless of any new OtelSettings.endpoint / "
                "sample_rate / resource_attributes passed to bootstrap().",
                MgfPlatformLimitation,
                stacklevel=3,
            )
        return
    if not enabled:
        return

    try:
        from opentelemetry import trace
        from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
        from opentelemetry.sdk.resources import Resource
        from opentelemetry.sdk.trace import TracerProvider
        from opentelemetry.sdk.trace.export import BatchSpanProcessor
    except ImportError:
        _LOG.warning(
            "OTLP endpoint configured but OpenTelemetry SDK is not installed",
            extra={
                "audit": True,
                "event": "otel.sdk_unavailable",
                "missing_dependency": "opentelemetry-sdk",
                "endpoint_env_var": "OTEL_EXPORTER_OTLP_ENDPOINT",
                "hint": f"pip install '{app_name()}[observability]' (or unset OTEL_EXPORTER_OTLP_ENDPOINT)",
            },
        )
        return

    resource = Resource.create({"service.name": os.environ.get("OTEL_SERVICE_NAME", app_name())})
    provider = TracerProvider(resource=resource)
    # F-004: timeouts/queue use OTel SDK defaults, which honour the
    # standard env vars — OTEL_EXPORTER_OTLP_TIMEOUT (exporter request
    # timeout) and OTEL_BSP_* (BatchSpanProcessor queue/delay/export).
    # A hung/unreachable collector bounds shutdown flush by that export
    # timeout; operators tune it via those env vars (no hard-coded
    # override here so deployments keep one knob).
    provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
    trace.set_tracer_provider(provider)
    _INSTALLED = True

    # Auto-instrument well-known clients so HTTP requests / SQL queries
    # appear as child spans of the currently-active operation_span
    # automatically — callers don't have to manually wrap every IO
    # call. Each helper is best-effort: missing optional dep → no-op.
    _maybe_instrument_httpx()


def _maybe_instrument_httpx() -> None:
    """Auto-instrument the ``httpx`` HTTP client.

    No-op when the optional ``opentelemetry-instrumentation-httpx``
    package is not installed (it is only pulled by the
    ``[observability]`` extra). Idempotent — calling twice does not
    stack instrumentations.
    """
    try:
        from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
    except ImportError:
        return
    try:
        HTTPXClientInstrumentor().instrument()
    except Exception as e:
        # Already-instrumented or some other transient — log and
        # continue rather than blocking startup.
        _LOG.warning(
            "httpx auto-instrumentation skipped",
            extra={
                "audit": True,
                "event": "otel.httpx_instrument_failed",
                "error_type": type(e).__name__,
                "error_message": str(e),
                "hint": "usually benign — httpx already instrumented by an earlier import; investigate only if traces are missing",
            },
        )


@contextlib.contextmanager
def operation_span(name: str, **attributes: Any) -> Iterator[None]:
    """Open an OpenTelemetry span around an operation.

    Usage::

        with operation_span("vm.create", vm_name=spec.name):
            handle = build_and_run(spec, provider)

    When the OpenTelemetry SDK is not installed OR :func:`setup_otel`
    has not been called, this is a no-op — the ``with`` block runs
    normally and no span is opened. This means callers can safely use
    the context manager unconditionally.

    Span names follow ``<subject>.<verb>`` (e.g., ``vm.create``,
    ``snapshot.revert``) — see ``docs/standards/LOGGING.md`` §7.3.

    Attributes are attached as span attributes when a real span is
    opened. Keys SHOULD be snake_case. ``None`` values are skipped
    (OTel attribute API rejects them).

    On exception:
      - The exception is recorded on the span (``record_exception``)
        so the trace shows the failure type + message + stacktrace.
      - The span status is set to ERROR so the trace UI highlights
        the failure path.
      - The exception then re-raises so the caller's error handling
        runs unchanged.
    """
    try:
        from opentelemetry import trace
        from opentelemetry.trace import Status, StatusCode
    except ImportError:
        yield
        return

    tracer = trace.get_tracer(app_name())
    with tracer.start_as_current_span(name) as span:
        for k, v in attributes.items():
            if v is None:
                # OTel attributes API rejects None values. Skip
                # rather than coerce to "None" string — callers can
                # always pass an explicit empty string when they
                # want a placeholder.
                continue
            span.set_attribute(k, v)
        try:
            yield
        except Exception as exc:
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR, description=str(exc)))
            raise


def build_otlp_log_handler() -> logging.Handler | None:
    """Build an OTLP log exporter handler, or return ``None`` if SDK absent.

    Used by :func:`mgf.common.observability.logging_setup.setup_logging`
    when ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set.

    The OTel logs API was experimental in opentelemetry-python until
    SDK ~1.27 — pin appropriately in ``pyproject.toml``.
    """
    try:
        from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
        from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
        from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
    except ImportError:
        return None

    provider = LoggerProvider()
    provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
    handler: logging.Handler = LoggingHandler(level=logging.NOTSET, logger_provider=provider)
    return handler


def emit_log_event(name: str, *, attributes: dict[str, Any]) -> None:
    """Emit a one-off OTel log event (used by the crash reporter's OTLP sink).

    No-op when the SDK is not installed. Distinct from a regular
    log record so the receiving collector can route crash events
    differently from operational logs.
    """
    try:
        from opentelemetry._logs import SeverityNumber, get_logger
    except ImportError:
        return
    logger = get_logger(app_name())
    logger.emit(
        body=name,
        severity_number=SeverityNumber.ERROR,
        attributes=attributes,
    )


# ---------------------------------------------------------------------------
# OTel span-processor registry (Phase 3 — registry shipped; full wiring Phase 5+)
# ---------------------------------------------------------------------------
#
# Consumers register a NAMED span-processor factory; the active
# wiring instantiates each one when its name appears in
# ``MgfSettings.otel.enabled_processors`` (a future field — Phase
# 5 threads it through ``_wire_otel``).
#
# Phase 3 ships the registry + decorator + tests so consumer
# plug-in code can register at import time. The wiring step that
# instantiates them lands when consumers actually need it.

if TYPE_CHECKING:
    from opentelemetry.sdk.trace import SpanProcessor

    from mgf.common.settings import MgfSettings

SpanProcessorFactory = Callable[["MgfSettings"], "SpanProcessor"]
"""Factory that returns a configured OTel ``SpanProcessor`` given
the active :class:`mgf.common.settings.MgfSettings`."""

_SPAN_PROCESSORS: dict[str, SpanProcessorFactory] = {}


def register_span_processor(
    name: str,
) -> Callable[[SpanProcessorFactory], SpanProcessorFactory]:
    """Decorator: register a custom OTel span-processor factory.

    Closes the closed-box leak around custom span export
    (consumers no longer ``import opentelemetry.sdk.trace`` to
    add a custom processor; they register a named factory and
    enable it through settings).

    Example::

        from mgf.common.observability import register_span_processor

        @register_span_processor("custom-batcher")
        def _make_batcher(settings):
            return MyCustomSpanProcessor(...)

    Activated by listing the name in
    ``MgfSettings.otel.enabled_processors`` (wiring lands in a
    later phase). Phase 3 ships the registry so consumers can
    register at import time without source-compat impact.

    Threading: register at module-import / bootstrap time.
    """

    def decorator(fn: SpanProcessorFactory) -> SpanProcessorFactory:
        if name in _SPAN_PROCESSORS:
            raise ValueError(
                f"span processor {name!r} already registered "
                f"(unregister first if you intend to replace it)"
            )
        _SPAN_PROCESSORS[name] = fn
        return fn

    return decorator


def unregister_span_processor(name: str) -> None:
    """Remove a registered span processor. No-op if unknown."""
    _SPAN_PROCESSORS.pop(name, None)


def _registered_span_processors() -> dict[str, SpanProcessorFactory]:
    """Internal: snapshot of the registry."""
    return dict(_SPAN_PROCESSORS)


__all__ = [
    "SpanProcessorFactory",
    "build_otlp_log_handler",
    "emit_log_event",
    "operation_span",
    "register_span_processor",
    "unregister_span_processor",
]
