# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

import dataclasses
import inspect
import logging
from collections.abc import Callable
from typing import Any, Protocol
from urllib.parse import urlparse

from opentelemetry.configuration._exceptions import ConfigurationError
from opentelemetry.util._importlib_metadata import entry_points

_logger = logging.getLogger(__name__)


def _additional_properties(cls):
    """Decorator for dataclasses whose JSON Schema sets additionalProperties.

    Wraps the dataclass-generated ``__init__`` so that extra keyword
    arguments are captured into an ``additional_properties`` instance
    attribute instead of raising ``TypeError``.  This lets user-defined
    component names flow through the config pipeline without modifying
    the codegen output for built-in fields.

    Applied automatically by the custom template in ``opentelemetry-sdk/codegen/``
    when ``additionalPropertiesType`` is present in the template context
    (set by ``datamodel-codegen`` for schema types with ``additionalProperties``).
    """
    original_init = cls.__init__
    original_sig = inspect.signature(original_init)
    known_fields = frozenset(f.name for f in dataclasses.fields(cls))

    def _init(self, **kwargs):
        known = {k: v for k, v in kwargs.items() if k in known_fields}
        extra = {k: v for k, v in kwargs.items() if k not in known_fields}
        original_init(self, **known)
        self.additional_properties = extra

    # Preserve the original parameter list for IDE autocompletion and
    # inspect.signature(), adding **kwargs to signal extras are accepted.
    # setattr used because pyright rejects direct __signature__ assignment.
    params = list(original_sig.parameters.values())
    params.append(inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD))
    setattr(_init, "__signature__", original_sig.replace(parameters=params))  # noqa: B010

    cls.__init__ = _init
    return cls


def load_entry_point(group: str, name: str) -> type:
    """Load a plugin class from an entry point group by name.

    Returns the loaded class — callers are responsible for instantiation
    with whatever arguments their config requires.

    Raises:
        ConfigurationError: If the entry point is not found or fails to load.
    """
    try:
        ep = next(iter(entry_points(group=group, name=name)), None)
        if ep is None:
            raise ConfigurationError(
                f"Plugin '{name}' not found in group '{group}'. "
                "Make sure the package providing this plugin is installed."
            )
        return ep.load()
    except ConfigurationError:
        raise
    except Exception as exc:
        raise ConfigurationError(f"Failed to load plugin '{name}' from group '{group}': {exc}") from exc


class _ComponentConfig(Protocol):
    """Protocol for config dataclasses decorated with @_additional_properties.

    Values in ``additional_properties`` are nested config dicts (suitable
    for ``**kwargs`` splatting to the user-defined component class) or
    ``None`` (when the YAML uses ``my_plugin:`` or ``my_plugin: null``).

    Note: the generated models declare ``additional_properties`` as a
    ``ClassVar`` even though the decorator assigns it as an instance
    attribute at runtime. This is tolerated by pyright in ``standard``
    mode but flagged in ``strict`` mode. See #5268.
    """

    additional_properties: dict[str, dict[str, Any] | None]


def _resolve_component(
    config: _ComponentConfig,
    registry: dict[str, Callable[[Any], Any]],
    entry_point_group: str,
    component_type: str,
) -> Any:
    """Resolve a config dataclass to a component instance.

    Checks built-in factories in ``registry`` first (by matching typed
    field names on ``config``), then falls back to entry point loading
    for plugin components found in ``config.additional_properties``.

    The JSON schema enforces exactly one component per config block
    (``minProperties: 1, maxProperties: 1``). If multiple typed fields or
    ``additional_properties`` entries are set (e.g. when schema validation
    is bypassed), the first registry match wins.

    Args:
        config: A dataclass with ``additional_properties`` (from the
            ``@_additional_properties`` decorator).
        registry: Mapping of built-in component names to factory
            callables. Each factory receives the field value from config.
        entry_point_group: The entry point group name for plugin loading.
        component_type: Human-readable name for error messages
            (e.g. "span exporter").

    Returns:
        The resolved component instance.

    Raises:
        ConfigurationError: If no component type is specified in config.
    """
    for name, factory in registry.items():
        value = getattr(config, name, None)
        if value is not None:
            return factory(value)
    if config.additional_properties:
        name, plugin_config = next(iter(config.additional_properties.items()))
        return load_entry_point(entry_point_group, name)(**(plugin_config or {}))
    raise ConfigurationError(f"No {component_type} type specified in config.")


def _parse_headers(
    headers: list | None,
    headers_list: str | None,
) -> dict[str, str] | None:
    """Merge headers struct and headers_list into a dict.

    Returns None if neither is set, letting the exporter read env vars.
    headers struct takes priority over headers_list for the same key.
    """
    if headers is None and headers_list is None:
        return None
    result: dict[str, str] = {}
    if headers_list:
        for item in headers_list.split(","):
            item = item.strip()
            if "=" in item:
                key, value = item.split("=", 1)
                result[key.strip()] = value.strip()
            elif item:
                _logger.warning(
                    "Invalid header pair in headers_list (missing '='): %s",
                    item,
                )
    if headers:
        for pair in headers:
            if isinstance(pair, dict):
                result[pair["name"]] = pair.get("value") or ""
            else:
                result[pair.name] = pair.value or ""
    return result


def _map_compression(
    value: str | None,
    compression_enum: type,
    *,
    allow_deflate: bool = False,
) -> object | None:
    """Map a compression string to the given Compression enum value."""
    if value is None:
        return None

    value_lower = value.lower()
    supports_deflate = hasattr(compression_enum, "Deflate")

    if value_lower == "none":
        return None
    if value_lower == "gzip":
        return compression_enum.Gzip  # type: ignore[attr-defined]
    if value_lower == "deflate" and allow_deflate and supports_deflate:
        return compression_enum.Deflate  # type: ignore[attr-defined]

    supported_values = ["'gzip'", "'none'"]
    if allow_deflate and supports_deflate:
        supported_values.insert(1, "'deflate'")

    raise ConfigurationError(
        f"Unsupported compression value '{value}'. Supported values: {', '.join(supported_values)}."
    )


def _parse_otlp_file_output_stream(output_stream: str | None) -> str | None:
    """Resolve an output_stream value to a file path, or None for stdout.

    Per the OTel file exporter spec, output_stream is "stdout" (or
    None, which means the same), or a "file://" URI giving a path.
    """
    if output_stream is None or output_stream == "stdout":
        return None
    try:
        parsed = urlparse(output_stream)
    except ValueError as exc:
        raise ConfigurationError(
            f"Failed to parse output_stream '{output_stream}' for otlp_file_development exporter: {exc}"
        ) from exc
    is_local_file_uri = parsed.scheme == "file" and parsed.netloc in ("", "localhost") and bool(parsed.path)
    has_extra_components = parsed.params or parsed.query or parsed.fragment
    if is_local_file_uri and not has_extra_components:
        path = parsed.path
        if not path.startswith("/"):
            raise ConfigurationError(
                f"Unsupported output_stream '{output_stream}' for "
                "otlp_file_development exporter. Path must be absolute."
            )
        if path.endswith("/"):
            raise ConfigurationError(
                f"Unsupported output_stream '{output_stream}' for "
                "otlp_file_development exporter. Path must be a file, "
                "not a directory."
            )
        return path
    raise ConfigurationError(
        f"Unsupported output_stream '{output_stream}' for otlp_file_development "
        "exporter. Supported values: stdout, file://<path>."
    )
