//! Configuration for the OpenTelemetry exporter.
//!
//! The OTLP wire transport (OTLP/HTTP+protobuf vs OTLP/gRPC) is a *compile-time*
//! choice driven by the crate's `otel-http` / `otel-grpc` cargo features — see
//! the `otel::transport` module. This config is transport-agnostic: the same
//! `endpoint` and `headers` feed whichever transport was compiled in (gRPC
//! forwards the headers as request metadata).
//!
//! All standard `OTEL_*` env vars take precedence over YAML, matching the
//! upstream OTel SDK spec: <https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/>.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// OpenTelemetry exporter configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OtelConfig {
    /// OTLP/HTTP+protobuf endpoint. Defaults to `OTEL_EXPORTER_OTLP_ENDPOINT`
    /// env var, then to the OTel-spec default `http://localhost:4318`.
    pub endpoint: String,

    /// Additional headers (e.g. vendor API keys).
    pub headers: HashMap<String, String>,

    /// Service name reported on the resource. Defaults to
    /// `OTEL_SERVICE_NAME` env var, then to `"bitrouter"`.
    pub service_name: String,

    /// Extra resource attributes (k=v list, merged from
    /// `OTEL_RESOURCE_ATTRIBUTES` env var).
    pub resource_attributes: HashMap<String, String>,

    /// Sampler kind; matches the OTel-spec `OTEL_TRACES_SAMPLER` values.
    pub sampler: SamplerKind,

    /// Argument to the sampler (only used by `traceidratio` and
    /// `parentbased_traceidratio`; ignored otherwise).
    pub sampler_arg: Option<f64>,

    /// Trace configuration.
    pub traces: TraceConfig,

    /// Metrics configuration.
    pub metrics: MetricsConfig,

    /// Whether to capture prompt / response message content onto spans.
    /// Default [`ContentCaptureMode::Off`] — no message bodies leave the
    /// process. Override with `BITROUTER_OBSERVE_CONTENT_CAPTURE=full`.
    pub content_capture: ContentCaptureMode,

    /// Maximum byte length for a single captured content attribute
    /// (`gen_ai.input.messages` / `gen_ai.output.messages`) under
    /// [`ContentCaptureMode::Full`]. A conservative cap so a pathological
    /// prompt or response can't produce an oversized span the collector or
    /// backend would reject. Defaults to [`DEFAULT_CONTENT_ATTR_MAX_BYTES`];
    /// override with `BITROUTER_OBSERVE_CONTENT_ATTR_MAX_BYTES`.
    pub content_attr_max_bytes: usize,

    /// Optional bearer token. When set, an `Authorization: Bearer <token>`
    /// header is injected into the OTLP request (see
    /// [`OtelConfig::effective_headers`]), authenticating the exporter to a
    /// collector that requires it. `None` leaves the request unauthenticated
    /// (anonymous export).
    pub bearer_token: Option<String>,
}

/// Default cap for a single captured content attribute (128 KiB).
pub const DEFAULT_CONTENT_ATTR_MAX_BYTES: usize = 128 * 1024;

/// Subset of OTel-spec sampler kinds the SDK actually supports. The default
/// (`parentbased_always_on`) matches the OTel-spec default — every trace is
/// sampled unless the inbound `traceparent` says otherwise.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SamplerKind {
    AlwaysOn,
    AlwaysOff,
    #[serde(rename = "traceidratio")]
    TraceIdRatio,
    #[serde(rename = "parentbased_always_on")]
    ParentBasedAlwaysOn,
    #[serde(rename = "parentbased_always_off")]
    ParentBasedAlwaysOff,
    #[serde(rename = "parentbased_traceidratio")]
    ParentBasedTraceIdRatio,
}

/// Whether the exporter copies prompt / response message content onto spans.
///
/// `Off` (default) keeps all message bodies out of telemetry — only metadata
/// (tokens, model, latency, finish reason, cloud-forwarded attributes) is
/// exported. `Full` serializes the prompt messages onto `gen_ai.input.messages`
/// and the response content onto `gen_ai.output.messages`. Redaction is a
/// caller policy, not done here; downstream pipelines decide whether to forward
/// the content (e.g. a collector that strips it from a third-party export).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ContentCaptureMode {
    /// Never copy message content onto spans (privacy-preserving default).
    #[default]
    Off,
    /// Serialize prompt + response message content onto the span.
    Full,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TraceConfig {
    /// Batch processor configuration.
    pub batch: BatchConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BatchConfig {
    /// Maximum queue size before drop.
    pub max_queue_size: usize,

    /// Scheduled flush interval in milliseconds.
    pub flush_ms: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MetricsConfig {
    /// Enable metrics export.
    pub enabled: bool,

    /// Export interval in milliseconds.
    pub export_interval_ms: u64,

    /// Cardinality cap for the `api_key_id` *metric* dimension. Spans always
    /// carry the raw value — capping only applies to metrics, where unbounded
    /// label cardinality is a real problem.
    pub api_key_id_cap: usize,

    /// Cardinality cap for the `user_id` metric dimension.
    pub user_id_cap: usize,
}

impl Default for OtelConfig {
    fn default() -> Self {
        Self {
            endpoint: "http://localhost:4318".to_string(),
            headers: HashMap::new(),
            service_name: "bitrouter".to_string(),
            resource_attributes: HashMap::new(),
            sampler: SamplerKind::ParentBasedAlwaysOn,
            sampler_arg: None,
            traces: TraceConfig::default(),
            metrics: MetricsConfig::default(),
            content_capture: ContentCaptureMode::Off,
            content_attr_max_bytes: DEFAULT_CONTENT_ATTR_MAX_BYTES,
            bearer_token: None,
        }
    }
}

impl OtelConfig {
    /// Headers handed to the OTLP transport: the configured [`headers`] plus an
    /// `Authorization: Bearer <token>` entry when [`bearer_token`] is set. An
    /// explicit `Authorization` header in [`headers`] is preserved (the bearer
    /// only fills the slot when absent).
    ///
    /// [`headers`]: OtelConfig::headers
    /// [`bearer_token`]: OtelConfig::bearer_token
    pub fn effective_headers(&self) -> HashMap<String, String> {
        let mut headers = self.headers.clone();
        if let Some(token) = &self.bearer_token {
            // Don't clobber an operator-supplied auth header — match the slot
            // case-insensitively (HTTP header names are case-insensitive).
            let has_auth = headers
                .keys()
                .any(|k| k.eq_ignore_ascii_case("authorization"));
            if !has_auth {
                headers.insert("Authorization".to_string(), format!("Bearer {token}"));
            }
        }
        headers
    }
}

impl Default for BatchConfig {
    fn default() -> Self {
        Self {
            max_queue_size: 2048,
            flush_ms: 5000,
        }
    }
}

impl Default for MetricsConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            export_interval_ms: 60000,
            api_key_id_cap: 1024,
            user_id_cap: 256,
        }
    }
}

impl OtelConfig {
    /// Apply OTel-spec env-var overrides from the process environment. Env
    /// vars take precedence over YAML, matching the upstream SDK contract.
    pub fn with_env_overrides(self) -> Self {
        self.with_env_from(|k| std::env::var(k).ok())
    }

    /// Apply OTel-spec env-var overrides from an arbitrary source. Used by
    /// `with_env_overrides` against the process env, and by tests against a
    /// HashMap — tests can't mutate the process env here because
    /// `env::set_var` is `unsafe` under Rust 2024 and the crate is
    /// `#![forbid(unsafe_code)]`.
    pub fn with_env_from<F>(mut self, lookup: F) -> Self
    where
        F: Fn(&str) -> Option<String>,
    {
        if let Some(endpoint) = lookup("OTEL_EXPORTER_OTLP_ENDPOINT") {
            self.endpoint = endpoint;
        }
        if let Some(headers_str) = lookup("OTEL_EXPORTER_OTLP_HEADERS") {
            for pair in headers_str.split(',') {
                if let Some((k, v)) = pair.split_once('=') {
                    self.headers
                        .insert(k.trim().to_string(), v.trim().to_string());
                }
            }
        }
        if let Some(name) = lookup("OTEL_SERVICE_NAME") {
            self.service_name = name;
        }
        if let Some(attrs) = lookup("OTEL_RESOURCE_ATTRIBUTES") {
            for pair in attrs.split(',') {
                if let Some((k, v)) = pair.split_once('=') {
                    self.resource_attributes
                        .insert(k.trim().to_string(), v.trim().to_string());
                }
            }
        }
        if let Some(s) = lookup("OTEL_TRACES_SAMPLER")
            && let Some(kind) = parse_sampler(&s)
        {
            self.sampler = kind;
        }
        if let Some(arg) = lookup("OTEL_TRACES_SAMPLER_ARG")
            && let Ok(ratio) = arg.parse::<f64>()
        {
            self.sampler_arg = Some(ratio);
        }
        if let Some(s) = lookup("BITROUTER_OBSERVE_CONTENT_CAPTURE")
            && let Some(mode) = parse_content_capture(&s)
        {
            self.content_capture = mode;
        }
        if let Some(s) = lookup("BITROUTER_OBSERVE_CONTENT_ATTR_MAX_BYTES")
            && let Ok(n) = s.parse::<usize>()
            && n > 0
        {
            self.content_attr_max_bytes = n;
        }
        self
    }
}

fn parse_sampler(s: &str) -> Option<SamplerKind> {
    match s {
        "always_on" => Some(SamplerKind::AlwaysOn),
        "always_off" => Some(SamplerKind::AlwaysOff),
        "traceidratio" => Some(SamplerKind::TraceIdRatio),
        "parentbased_always_on" => Some(SamplerKind::ParentBasedAlwaysOn),
        "parentbased_always_off" => Some(SamplerKind::ParentBasedAlwaysOff),
        "parentbased_traceidratio" => Some(SamplerKind::ParentBasedTraceIdRatio),
        _ => None,
    }
}

fn parse_content_capture(s: &str) -> Option<ContentCaptureMode> {
    match s.to_ascii_lowercase().as_str() {
        "off" => Some(ContentCaptureMode::Off),
        "full" => Some(ContentCaptureMode::Full),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
        let map: HashMap<String, String> = pairs
            .iter()
            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
            .collect();
        move |k: &str| map.get(k).cloned()
    }

    #[test]
    fn env_overrides_endpoint_and_service_name() {
        let cfg = OtelConfig::default().with_env_from(env(&[
            ("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318"),
            ("OTEL_SERVICE_NAME", "myrouter"),
        ]));
        assert_eq!(cfg.endpoint, "http://collector:4318");
        assert_eq!(cfg.service_name, "myrouter");
    }

    #[test]
    fn env_overrides_resource_attributes_and_sampler() {
        let cfg = OtelConfig::default().with_env_from(env(&[
            (
                "OTEL_RESOURCE_ATTRIBUTES",
                "deployment.environment=prod,team=infra",
            ),
            ("OTEL_TRACES_SAMPLER", "parentbased_traceidratio"),
            ("OTEL_TRACES_SAMPLER_ARG", "0.25"),
        ]));
        assert_eq!(
            cfg.resource_attributes
                .get("deployment.environment")
                .map(String::as_str),
            Some("prod"),
        );
        assert_eq!(
            cfg.resource_attributes.get("team").map(String::as_str),
            Some("infra")
        );
        assert_eq!(cfg.sampler, SamplerKind::ParentBasedTraceIdRatio);
        assert_eq!(cfg.sampler_arg, Some(0.25));
    }

    #[test]
    fn env_overrides_headers_parses_comma_list() {
        let cfg = OtelConfig::default().with_env_from(env(&[(
            "OTEL_EXPORTER_OTLP_HEADERS",
            "x-honeycomb-team=secret,x-team=infra",
        )]));
        assert_eq!(
            cfg.headers.get("x-honeycomb-team").map(String::as_str),
            Some("secret")
        );
        assert_eq!(cfg.headers.get("x-team").map(String::as_str), Some("infra"));
    }

    #[test]
    fn effective_headers_injects_bearer_when_set() {
        // No token → headers unchanged.
        let cfg = OtelConfig::default();
        assert!(!cfg.effective_headers().contains_key("Authorization"));

        // Token set → Authorization: Bearer <token> added.
        let cfg = OtelConfig {
            bearer_token: Some("bra_abc".to_string()),
            ..OtelConfig::default()
        };
        assert_eq!(
            cfg.effective_headers()
                .get("Authorization")
                .map(String::as_str),
            Some("Bearer bra_abc")
        );

        // An explicit Authorization header wins over the bearer.
        let mut headers = HashMap::new();
        headers.insert("Authorization".to_string(), "Bearer explicit".to_string());
        let cfg = OtelConfig {
            headers,
            bearer_token: Some("bra_abc".to_string()),
            ..OtelConfig::default()
        };
        assert_eq!(
            cfg.effective_headers()
                .get("Authorization")
                .map(String::as_str),
            Some("Bearer explicit")
        );

        // A lowercase custom auth header also blocks injection (case-insensitive).
        let mut headers = HashMap::new();
        headers.insert("authorization".to_string(), "Bearer lower".to_string());
        let cfg = OtelConfig {
            headers,
            bearer_token: Some("bra_abc".to_string()),
            ..OtelConfig::default()
        };
        let eff = cfg.effective_headers();
        assert!(!eff.contains_key("Authorization"));
        assert_eq!(
            eff.get("authorization").map(String::as_str),
            Some("Bearer lower")
        );
    }

    #[test]
    fn content_attr_max_bytes_defaults_and_env_override() {
        assert_eq!(
            OtelConfig::default().content_attr_max_bytes,
            DEFAULT_CONTENT_ATTR_MAX_BYTES
        );
        let cfg = OtelConfig::default().with_env_from(env(&[(
            "BITROUTER_OBSERVE_CONTENT_ATTR_MAX_BYTES",
            "262144",
        )]));
        assert_eq!(cfg.content_attr_max_bytes, 262_144);
        // Invalid / zero leaves the default untouched.
        let cfg = OtelConfig::default().with_env_from(env(&[(
            "BITROUTER_OBSERVE_CONTENT_ATTR_MAX_BYTES",
            "bogus",
        )]));
        assert_eq!(cfg.content_attr_max_bytes, DEFAULT_CONTENT_ATTR_MAX_BYTES);
    }

    #[test]
    fn content_capture_defaults_off_and_env_overrides_to_full() {
        assert_eq!(
            OtelConfig::default().content_capture,
            ContentCaptureMode::Off
        );
        let cfg = OtelConfig::default()
            .with_env_from(env(&[("BITROUTER_OBSERVE_CONTENT_CAPTURE", "full")]));
        assert_eq!(cfg.content_capture, ContentCaptureMode::Full);
        // Unknown value leaves the default untouched.
        let cfg = OtelConfig::default()
            .with_env_from(env(&[("BITROUTER_OBSERVE_CONTENT_CAPTURE", "bogus")]));
        assert_eq!(cfg.content_capture, ContentCaptureMode::Off);
    }
}
