//! Prometheus-compatible cumulative latency histogram.

use std::sync::atomic::{AtomicU64, Ordering};

// ── LatencyHistogram ────────────────────────────────────────────────────────

/// Bucket boundaries for signal write latency in microseconds.
///
/// Bounds: 1us, 5us, 10us, 25us, 50us, 100us, 250us, 500us, 1ms, 5ms, 10ms.
#[cfg(feature = "metrics")]
pub const WRITE_LATENCY_BOUNDS: &[u64] = &[1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000];

/// Bucket boundaries for query latency (retrieve + search), in microseconds.
///
/// Range: 100µs – 10s. Retrieve and search are 10x–100x slower than signal
/// writes so the buckets are wider and cover cold-start and degraded conditions.
#[cfg(feature = "metrics")]
pub const QUERY_LATENCY_BOUNDS: &[u64] = &[
    100, 500, 1_000, 5_000, 10_000, 50_000, 100_000, 500_000, 1_000_000, 5_000_000, 10_000_000,
];

/// A Prometheus-compatible cumulative histogram.
///
/// Each bucket count represents the number of observations less than or equal
/// to that bucket's upper bound. Thread-safe via `AtomicU64` per bucket.
///
/// Memory ordering: all increments use `Relaxed` because histogram reads are
/// statistical -- a slightly stale count is acceptable for monitoring. No other
/// thread depends on the freshness of any individual bucket value.
#[cfg(feature = "metrics")]
pub struct LatencyHistogram {
    bounds: &'static [u64],
    buckets: Vec<AtomicU64>,
    count: AtomicU64,
    sum: AtomicU64,
}

#[cfg(feature = "metrics")]
impl LatencyHistogram {
    /// Create a new histogram with the given cumulative bucket boundaries.
    ///
    /// `bounds` must be sorted ascending. Each bound defines the upper limit
    /// (inclusive) for that bucket.
    pub fn new(bounds: &'static [u64]) -> Self {
        let buckets = bounds.iter().map(|_| AtomicU64::new(0)).collect();
        Self {
            bounds,
            buckets,
            count: AtomicU64::new(0),
            sum: AtomicU64::new(0),
        }
    }

    /// Record an observation.
    ///
    /// Increments all cumulative buckets where `bound >= value_us` (Prometheus
    /// convention: each bucket counts observations <= its upper bound).
    pub fn observe(&self, value_us: u64) {
        for (i, &bound) in self.bounds.iter().enumerate() {
            if value_us <= bound {
                self.buckets[i].fetch_add(1, Ordering::Relaxed);
            }
        }
        self.count.fetch_add(1, Ordering::Relaxed);
        self.sum.fetch_add(value_us, Ordering::Relaxed);
    }

    /// Render this histogram in Prometheus text exposition format.
    ///
    /// Produces `# HELP`, `# TYPE histogram`, per-bucket lines with `le` labels,
    /// a `+Inf` bucket (equal to total count), `_sum`, and `_count`.
    pub fn render_prometheus(&self, name: &str, help: &str) -> String {
        use std::fmt::Write;

        let mut out = String::new();
        let _ = writeln!(out, "# HELP {name} {help}");
        let _ = writeln!(out, "# TYPE {name} histogram");

        for (i, &bound) in self.bounds.iter().enumerate() {
            let count = self.buckets[i].load(Ordering::Relaxed);
            let _ = writeln!(out, "{name}_bucket{{le=\"{bound}\"}} {count}");
        }

        let total_count = self.count.load(Ordering::Relaxed);
        let total_sum = self.sum.load(Ordering::Relaxed);
        let _ = writeln!(out, "{name}_bucket{{le=\"+Inf\"}} {total_count}");
        let _ = writeln!(out, "{name}_sum {total_sum}");
        let _ = writeln!(out, "{name}_count {total_count}");
        out.push('\n');
        out
    }
}

/// Write a single Prometheus metric line (HELP + TYPE + value).
#[cfg(feature = "metrics")]
pub fn write_metric_line(out: &mut String, name: &str, help: &str, type_str: &str, value: f64) {
    use std::fmt::Write;
    let _ = write!(
        out,
        "\n# HELP {name} {help}\n# TYPE {name} {type_str}\n{name} {value}\n"
    );
}

#[cfg(test)]
mod tests {
    #[cfg(feature = "metrics")]
    use super::*;

    #[cfg(feature = "metrics")]
    #[test]
    fn histogram_observe_increments_correct_buckets() {
        let hist = LatencyHistogram::new(WRITE_LATENCY_BOUNDS);
        // Observe 5us: should NOT increment bucket for bound=1 (5 > 1),
        // but should increment all buckets with bound >= 5.
        hist.observe(5);

        // bound=1: 5 > 1, not incremented
        assert_eq!(hist.buckets[0].load(Ordering::Relaxed), 0);
        // bound=5: 5 <= 5, incremented
        assert_eq!(hist.buckets[1].load(Ordering::Relaxed), 1);
        // bound=10: 5 <= 10, incremented
        assert_eq!(hist.buckets[2].load(Ordering::Relaxed), 1);
        // bound=25: 5 <= 25, incremented
        assert_eq!(hist.buckets[3].load(Ordering::Relaxed), 1);

        assert_eq!(hist.count.load(Ordering::Relaxed), 1);
        assert_eq!(hist.sum.load(Ordering::Relaxed), 5);
    }

    #[cfg(feature = "metrics")]
    #[test]
    fn histogram_observe_above_all_bounds() {
        let hist = LatencyHistogram::new(WRITE_LATENCY_BOUNDS);
        // Observe a value larger than all bounds.
        hist.observe(99_999);

        // No bucket should be incremented (99_999 > every bound).
        for bucket in &hist.buckets {
            assert_eq!(bucket.load(Ordering::Relaxed), 0);
        }
        // But count and sum should still be updated.
        assert_eq!(hist.count.load(Ordering::Relaxed), 1);
        assert_eq!(hist.sum.load(Ordering::Relaxed), 99_999);
    }

    #[cfg(feature = "metrics")]
    #[test]
    fn histogram_multiple_observations() {
        let hist = LatencyHistogram::new(WRITE_LATENCY_BOUNDS);
        hist.observe(1); // <= bounds: 1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000
        hist.observe(50); // <= bounds: 50, 100, 250, 500, 1000, 5000, 10000
        hist.observe(200); // <= bounds: 250, 500, 1000, 5000, 10000

        // bound=1: 1 obs (value=1)
        assert_eq!(hist.buckets[0].load(Ordering::Relaxed), 1);
        // bound=5: 1 obs (value=1)
        assert_eq!(hist.buckets[1].load(Ordering::Relaxed), 1);
        // bound=50: 2 obs (value=1, value=50)
        assert_eq!(hist.buckets[4].load(Ordering::Relaxed), 2);
        // bound=100: 2 obs (value=1, value=50)
        assert_eq!(hist.buckets[5].load(Ordering::Relaxed), 2);
        // bound=250: 3 obs (all three)
        assert_eq!(hist.buckets[6].load(Ordering::Relaxed), 3);

        assert_eq!(hist.count.load(Ordering::Relaxed), 3);
        assert_eq!(hist.sum.load(Ordering::Relaxed), 251);
    }

    #[cfg(feature = "metrics")]
    #[test]
    fn histogram_render_prometheus_valid_format() {
        let hist = LatencyHistogram::new(WRITE_LATENCY_BOUNDS);
        hist.observe(50);
        hist.observe(200);
        let output = hist.render_prometheus("test_latency_us", "Test latency");

        assert!(output.contains("# HELP test_latency_us Test latency"));
        assert!(output.contains("# TYPE test_latency_us histogram"));
        // bound=100: value=50 fits, value=200 does not -> count=1
        assert!(output.contains("test_latency_us_bucket{le=\"100\"} 1"));
        // bound=250: both fit -> count=2
        assert!(output.contains("test_latency_us_bucket{le=\"250\"} 2"));
        // +Inf bucket equals total count
        assert!(output.contains("test_latency_us_bucket{le=\"+Inf\"} 2"));
        assert!(output.contains("test_latency_us_sum 250"));
        assert!(output.contains("test_latency_us_count 2"));
    }
}
