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

"""Configuration file loading and parsing."""

import importlib.resources
import json
import logging
import os
from pathlib import Path
from typing import Any

from opentelemetry.configuration._conversion import _dict_to_dataclass
from opentelemetry.configuration._exceptions import (
    ConfigurationError,
    MissingDependencyError,
)
from opentelemetry.configuration.file._env_substitution import (
    substitute_env_vars,
)
from opentelemetry.configuration.models import OpenTelemetryConfiguration

try:
    import yaml
except ImportError as exc:
    raise MissingDependencyError(
        package="pyyaml",
        feature="File configuration",
        install_name="opentelemetry-sdk",
        extras="file-configuration",
    ) from exc

try:
    import jsonschema
except ImportError as exc:
    raise MissingDependencyError(
        package="jsonschema",
        feature="File configuration",
        install_name="opentelemetry-sdk",
        extras="file-configuration",
    ) from exc

# Schema version vendored in schema.json. ``file_format`` values are accepted
# per the configuration spec's versioning rules: the major version must match,
# and a minor version newer than the one this SDK targets is accepted with a
# warning. See
# https://github.com/open-telemetry/opentelemetry-configuration/blob/main/VERSIONING.md
_SUPPORTED_SCHEMA_MAJOR = 1
_SUPPORTED_SCHEMA_MINOR = 1

_schema_cache: list[dict] = []


def _get_schema() -> dict:
    if not _schema_cache:
        schema_path = importlib.resources.files("opentelemetry.configuration") / "schema.json"
        _schema_cache.append(json.loads(schema_path.read_text(encoding="utf-8")))
    return _schema_cache[0]


_logger = logging.getLogger(__name__)


def load_config_file(
    file_path: str | os.PathLike[str],
) -> OpenTelemetryConfiguration:
    """Load and parse an OpenTelemetry configuration file.

    Supports YAML and JSON formats. Performs environment variable substitution
    before parsing.

    Args:
        file_path: Path to the configuration file (.yaml, .yml, or .json).

    Returns:
        Parsed OpenTelemetryConfiguration object.

    Raises:
        ConfigurationError: If file cannot be read, parsed, or validated.

    Examples:
        >>> config = load_config_file("otel-config.yaml")
        >>> print(config.tracer_provider)
    """
    path = Path(file_path)

    if not path.exists():
        _logger.error("Configuration file not found: %s", file_path)
        raise ConfigurationError(f"Configuration file not found: {file_path}")

    if not path.is_file():
        _logger.error("Configuration path is not a file: %s", file_path)
        raise ConfigurationError(f"Configuration path is not a file: {file_path}")

    try:
        with open(path, encoding="utf-8") as config_file:
            content = config_file.read()
    except OSError as exc:
        _logger.exception("Failed to read configuration file: %s", file_path)
        raise ConfigurationError(f"Failed to read configuration file: {file_path}") from exc

    # Perform environment variable substitution
    try:
        content = substitute_env_vars(content)
    except Exception as exc:
        raise ConfigurationError(f"Environment variable substitution failed: {exc}") from exc

    # Parse based on file extension
    suffix = path.suffix.lower()
    try:
        if suffix in (".yaml", ".yml"):
            data = yaml.safe_load(content)
        elif suffix == ".json":
            data = json.loads(content)
        else:
            _logger.error("Unsupported file format: %s", suffix)
            raise ConfigurationError(f"Unsupported file format: {suffix}. Use .yaml, .yml, or .json")
    except yaml.YAMLError as exc:
        _logger.exception("Failed to parse YAML from %s", file_path)
        raise ConfigurationError(f"Failed to parse YAML: {exc}") from exc
    except json.JSONDecodeError as exc:
        _logger.exception("Failed to parse JSON from %s", file_path)
        raise ConfigurationError(f"Failed to parse JSON: {exc}") from exc

    if data is None:
        _logger.error("Configuration file is empty: %s", file_path)
        raise ConfigurationError("Configuration file is empty")

    if not isinstance(data, dict):
        _logger.error(
            "Configuration must be a mapping/object, got %s",
            type(data).__name__,
        )
        raise ConfigurationError(f"Configuration must be a mapping/object, got {type(data).__name__}")

    _validate_schema(data)
    _validate_file_format(data)

    # Convert to OpenTelemetryConfiguration model
    try:
        config = _dict_to_model(data)
    except Exception as exc:
        _logger.exception("Failed to validate configuration from %s", file_path)
        raise ConfigurationError(f"Failed to validate configuration: {exc}") from exc

    return config


def _validate_schema(data: dict) -> None:
    """Validate configuration dict against the OTel configuration JSON schema.

    Raises:
        ConfigurationError: If the data does not conform to the schema.
    """
    try:
        jsonschema.validate(
            instance=data,
            schema=_get_schema(),
            cls=jsonschema.Draft202012Validator,
        )
    except jsonschema.ValidationError as exc:
        raise ConfigurationError(
            f"Configuration does not match schema: {exc.message} (at {' -> '.join(str(p) for p in exc.absolute_path)})"
            if exc.absolute_path
            else f"Configuration does not match schema: {exc.message}"
        ) from exc
    except jsonschema.SchemaError as exc:
        raise ConfigurationError(f"Invalid configuration schema: {exc.message}") from exc


def _validate_file_format(data: dict) -> None:
    """Validate the ``file_format`` version per the configuration spec.

    The spec requires implementations to fail on an unsupported major version
    (a breaking-change boundary) and to accept, with a warning, a minor version
    newer than the one this SDK targets. See
    https://github.com/open-telemetry/opentelemetry-configuration/blob/main/VERSIONING.md

    Raises:
        ConfigurationError: If ``file_format`` is malformed or its major
            version is not supported.
    """
    file_format = data.get("file_format")
    # file_format is required and typed as a string by the schema, which is
    # validated before this runs; guard defensively regardless.
    if not isinstance(file_format, str):
        raise ConfigurationError(f"Invalid file_format: expected a version string, got {file_format!r}")

    # Drop any pre-release / meta tag, e.g. "1.0-rc.2" -> "1.0".
    version = file_format.split("-", 1)[0]
    parts = version.split(".")
    try:
        major = int(parts[0])
        minor = int(parts[1]) if len(parts) > 1 else 0
    except ValueError as exc:
        raise ConfigurationError(f"Invalid file_format '{file_format}': expected MAJOR.MINOR version numbers") from exc

    if major != _SUPPORTED_SCHEMA_MAJOR:
        raise ConfigurationError(
            f"Unsupported file_format '{file_format}': this SDK supports schema version {_SUPPORTED_SCHEMA_MAJOR}.x"
        )

    if minor > _SUPPORTED_SCHEMA_MINOR:
        _logger.warning(
            "Configuration file_format '%s' has a newer minor version than "
            "this SDK supports (%d.%d); some settings may be ignored.",
            file_format,
            _SUPPORTED_SCHEMA_MAJOR,
            _SUPPORTED_SCHEMA_MINOR,
        )


def _dict_to_model(data: dict[str, Any]) -> OpenTelemetryConfiguration:
    """Convert a parsed config dictionary to the full typed model tree.

    Walks each field's type annotation, recursively converting nested
    dicts to their corresponding dataclass types. The resulting
    ``OpenTelemetryConfiguration`` is fully typed end-to-end, so factory
    functions can rely on typed attribute access (e.g. ``config.sampler``,
    ``config.processors[0].batch.exporter``).

    Args:
        data: Parsed configuration dictionary.

    Returns:
        OpenTelemetryConfiguration instance.

    Raises:
        TypeError: If data doesn't match expected structure.
        ValueError: If values are invalid.
    """
    try:
        return _dict_to_dataclass(data, OpenTelemetryConfiguration)
    except TypeError as exc:
        raise TypeError(
            f"Configuration structure is invalid. Check that all required fields are present and correctly typed: {exc}"
        ) from exc
