// Copyright 2018-2026 the Deno authors. MIT license.

use std::io::Write;
use std::sync::Once;
use std::time::Duration;

use flate2::Compression;
use flate2::write::GzEncoder;
use hyper::header;
use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest;
use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest;
use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
use opentelemetry_proto::transform::common::tonic::ResourceAttributesWithSchema;
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::error::OTelSdkError;
use opentelemetry_sdk::error::OTelSdkResult;
use opentelemetry_sdk::logs::LogBatch;
use opentelemetry_sdk::metrics::Temporality;
use opentelemetry_sdk::metrics::data::ResourceMetrics;
use opentelemetry_sdk::trace::SpanData;
use prost::Message;

use crate::hyper_client::HyperClient;

const DEFAULT_GRPC_ENDPOINT: &str = "http://localhost:4317";

const USER_AGENT: &str =
  concat!("OTel-OTLP-Exporter-Deno/", env!("CARGO_PKG_VERSION"));

const TRACES_PATH: &str =
  "/opentelemetry.proto.collector.trace.v1.TraceService/Export";
const METRICS_PATH: &str =
  "/opentelemetry.proto.collector.metrics.v1.MetricsService/Export";
const LOGS_PATH: &str =
  "/opentelemetry.proto.collector.logs.v1.LogsService/Export";

#[derive(Clone, Copy)]
enum Signal {
  Traces,
  Metrics,
  Logs,
}

impl Signal {
  fn endpoint_env_var(self) -> &'static str {
    match self {
      Signal::Traces => "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
      Signal::Metrics => "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
      Signal::Logs => "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
    }
  }

  fn headers_env_var(self) -> &'static str {
    match self {
      Signal::Traces => "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
      Signal::Metrics => "OTEL_EXPORTER_OTLP_METRICS_HEADERS",
      Signal::Logs => "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
    }
  }
}

/// Per-signal endpoint takes precedence over the generic one per the OTLP spec.
/// Bare `host:port` endpoints get a scheme synthesized from
/// `OTEL_EXPORTER_OTLP_INSECURE`.
fn grpc_endpoint(signal: Signal) -> String {
  let raw = std::env::var(signal.endpoint_env_var())
    .or_else(|_| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT"))
    .unwrap_or_else(|_| DEFAULT_GRPC_ENDPOINT.to_string());

  if raw.starts_with("http://") || raw.starts_with("https://") {
    return raw;
  }

  let insecure = std::env::var("OTEL_EXPORTER_OTLP_INSECURE")
    .map(|v| v.eq_ignore_ascii_case("true"))
    .unwrap_or(false);
  let scheme = if insecure { "http" } else { "https" };
  format!("{}://{}", scheme, raw)
}

/// Parse OTLP headers. Per-signal headers are merged with (and override)
/// the generic headers, per the OTLP spec.
fn grpc_headers(
  signal: Signal,
) -> impl Iterator<Item = (header::HeaderName, header::HeaderValue)> {
  fn parse_headers(
    val: &str,
  ) -> Vec<(header::HeaderName, header::HeaderValue)> {
    val
      .split(',')
      .filter_map(|pair| {
        let (k, v) = pair.split_once('=')?;
        let name = match header::HeaderName::from_bytes(k.trim().as_bytes()) {
          Ok(n) => n,
          Err(_) => {
            log::warn!("invalid OTEL header name: {:?}", k.trim());
            return None;
          }
        };
        let value = match header::HeaderValue::from_str(v.trim()) {
          Ok(v) => v,
          Err(_) => {
            log::warn!("invalid OTEL header value for {:?}", k.trim());
            return None;
          }
        };
        Some((name, value))
      })
      .collect()
  }

  let mut headers = Vec::new();
  if let Ok(val) = std::env::var("OTEL_EXPORTER_OTLP_HEADERS") {
    headers.extend(parse_headers(&val));
  }
  // Per-signal headers override generic ones
  if let Ok(val) = std::env::var(signal.headers_env_var()) {
    headers.extend(parse_headers(&val));
  }
  headers.into_iter()
}

#[derive(Clone, Copy, PartialEq)]
enum GrpcCompression {
  None,
  Gzip,
}

fn grpc_compression() -> GrpcCompression {
  match std::env::var("OTEL_EXPORTER_OTLP_COMPRESSION") {
    Ok(val) if val.eq_ignore_ascii_case("gzip") => GrpcCompression::Gzip,
    Ok(val) if val.is_empty() || val.eq_ignore_ascii_case("none") => {
      GrpcCompression::None
    }
    Ok(val) => {
      static WARN_ONCE: Once = Once::new();
      WARN_ONCE.call_once(|| {
        log::warn!(
          "unsupported OTEL_EXPORTER_OTLP_COMPRESSION value {:?}, \
           falling back to no compression",
          val,
        );
      });
      GrpcCompression::None
    }
    Err(_) => GrpcCompression::None,
  }
}

/// Encode a protobuf message into a gRPC frame: 1 byte compression flag
/// + 4 byte big-endian message length + message bytes (optionally gzip'd).
fn grpc_frame(msg: &impl Message) -> Vec<u8> {
  let compression = grpc_compression();
  let raw_len = msg.encoded_len();
  let mut proto_buf = Vec::with_capacity(raw_len);
  msg.encode(&mut proto_buf).unwrap();

  let (compressed_flag, payload) = if compression == GrpcCompression::Gzip {
    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
    encoder.write_all(&proto_buf).expect("gzip encode failed");
    (1u8, encoder.finish().expect("gzip finish failed"))
  } else {
    (0u8, proto_buf)
  };

  let len = payload.len();
  let mut buf = Vec::with_capacity(5 + len);
  buf.push(compressed_flag);
  buf.extend_from_slice(&(len as u32).to_be_bytes());
  buf.extend_from_slice(&payload);
  buf
}

/// Format a `Duration` as a gRPC timeout header value (milliseconds).
fn grpc_timeout_header(timeout: Duration) -> String {
  format!("{}m", timeout.as_millis())
}

/// Check grpc-status in a header map, returning an error if non-zero.
fn check_grpc_status(
  headers: &hyper::HeaderMap,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
  if let Some(grpc_status) = headers.get("grpc-status") {
    let code: i32 = grpc_status.to_str()?.parse()?;
    if code != 0 {
      let message = headers
        .get("grpc-message")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("unknown error");
      return Err(format!("gRPC error {}: {}", code, message).into());
    }
    return Ok(true); // found and was 0
  }
  Ok(false) // not found
}

/// Send a gRPC unary request and check the response status.
///
/// Checks grpc-status in both initial response headers (Trailers-Only
/// form for early errors) and HTTP/2 trailers (normal success/error
/// form), per the gRPC spec.
async fn grpc_send(
  client: &HyperClient,
  signal: Signal,
  path: &str,
  body: Vec<u8>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
  let endpoint = grpc_endpoint(signal);
  let url = format!("{}{}", endpoint.trim_end_matches('/'), path);

  let mut builder = hyper::Request::builder()
    .method(hyper::Method::POST)
    .uri(&url)
    .header("content-type", "application/grpc")
    .header("te", "trailers")
    .header("user-agent", USER_AGENT)
    .header("grpc-timeout", grpc_timeout_header(client.timeout()));

  if grpc_compression() == GrpcCompression::Gzip {
    builder = builder.header("grpc-encoding", "gzip");
  }

  let mut request = builder.body(body)?;

  for (k, v) in grpc_headers(signal) {
    request.headers_mut().insert(k, v);
  }

  let (parts, trailers) = client.grpc_request(request).await?;
  if !parts.status.is_success() {
    return Err(format!("gRPC transport error: HTTP {}", parts.status).into());
  }

  // Check grpc-status in initial headers (Trailers-Only form)
  if let Err(e) = check_grpc_status(&parts.headers) {
    log::error!("OTLP gRPC export failed: {e}");
    return Err(e);
  }

  // Check grpc-status in HTTP/2 trailers (normal form)
  if let Some(ref trailers) = trailers
    && let Err(e) = check_grpc_status(trailers)
  {
    log::error!("OTLP gRPC export failed: {e}");
    return Err(e);
  }

  Ok(())
}

// ---- Span Exporter ----

#[derive(Debug)]
pub struct GrpcSpanExporter {
  client: HyperClient,
  resource: Resource,
}

impl GrpcSpanExporter {
  pub fn new(client: HyperClient) -> Self {
    Self {
      client,
      resource: Resource::builder_empty().build(),
    }
  }
}

impl opentelemetry_sdk::trace::SpanExporter for GrpcSpanExporter {
  async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
    let resource: ResourceAttributesWithSchema = (&self.resource).into();
    let client = self.client.clone();
    let resource_spans =
      opentelemetry_proto::transform::trace::tonic::group_spans_by_resource_and_scope(
        batch, &resource,
      );
    let request = ExportTraceServiceRequest { resource_spans };
    let body = grpc_frame(&request);
    grpc_send(&client, Signal::Traces, TRACES_PATH, body)
      .await
      .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))
  }

  fn set_resource(&mut self, resource: &Resource) {
    self.resource = resource.clone();
  }
}

// ---- Log Exporter ----

#[derive(Debug)]
pub struct GrpcLogExporter {
  client: HyperClient,
  resource: Resource,
}

impl GrpcLogExporter {
  pub fn new(client: HyperClient) -> Self {
    Self {
      client,
      resource: Resource::builder_empty().build(),
    }
  }
}

impl opentelemetry_sdk::logs::LogExporter for GrpcLogExporter {
  async fn export(&self, batch: LogBatch<'_>) -> OTelSdkResult {
    let resource: ResourceAttributesWithSchema = (&self.resource).into();
    let resource_logs =
      opentelemetry_proto::transform::logs::tonic::group_logs_by_resource_and_scope(
        &batch, &resource,
      );
    let request = ExportLogsServiceRequest { resource_logs };
    let body = grpc_frame(&request);
    grpc_send(&self.client, Signal::Logs, LOGS_PATH, body)
      .await
      .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))
  }

  fn set_resource(&mut self, resource: &Resource) {
    self.resource = resource.clone();
  }
}

// ---- Metric Exporter ----

#[derive(Debug)]
pub struct GrpcMetricExporter {
  client: HyperClient,
  temporality: Temporality,
}

impl GrpcMetricExporter {
  pub fn new(client: HyperClient, temporality: Temporality) -> Self {
    Self {
      client,
      temporality,
    }
  }
}

impl opentelemetry_sdk::metrics::exporter::PushMetricExporter
  for GrpcMetricExporter
{
  async fn export(&self, metrics: &ResourceMetrics) -> OTelSdkResult {
    let request = ExportMetricsServiceRequest::from(metrics);
    let body = grpc_frame(&request);
    grpc_send(&self.client, Signal::Metrics, METRICS_PATH, body)
      .await
      .map_err(|e| OTelSdkError::InternalFailure(e.to_string()))
  }

  fn force_flush(&self) -> OTelSdkResult {
    Ok(())
  }

  fn shutdown_with_timeout(&self, _timeout: Duration) -> OTelSdkResult {
    Ok(())
  }

  fn temporality(&self) -> Temporality {
    self.temporality
  }
}
