//! Prometheus metrics for the voice plugin.
//!
//! Implements the manifest's `[plugin.metrics]` contract: when the
//! daemon scrapes `plugin.voice.metrics.scrape` via broker JSON-RPC,
//! we return one Prometheus exposition-format text body. Namespace
//! prefix `nexo_voice_` per manifest.
//!
//! Hand-rolled accumulator (no `prometheus` crate dep) — cheap
//! counters + histogram buckets are all this plugin needs.

use std::sync::Mutex;

use crate::error::ReasonCode;

/// Singleton-style metrics registry. Cloneable; clones share via
/// an `Arc<Mutex<...>>` per family.
#[derive(Debug, Default)]
pub struct Metrics {
    synth_attempts: Mutex<Vec<((String, String), u64)>>,
    synth_duration_buckets: Mutex<HistogramData>,
    fallback_depth: Mutex<Vec<((String, String), u64)>>,
    cache_hits: Mutex<u64>,
    cache_misses: Mutex<u64>,
    pool_active: Mutex<Vec<(String, i64)>>,
}

#[derive(Debug, Default)]
struct HistogramData {
    /// Buckets in seconds: 0.1, 0.25, 0.5, 1, 2, 5, 10, +Inf.
    counts: [u64; 8],
    sum: f64,
    total_count: u64,
}

const BUCKET_BOUNDS_S: [f64; 7] = [0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0];

impl Metrics {
    pub fn new() -> Self {
        Self::default()
    }

    /// Record one synthesis attempt outcome.
    pub fn record_attempt(&self, provider: &str, reason: ReasonCode) {
        let key = (provider.to_string(), reason.as_metric_label().to_string());
        let mut g = self.synth_attempts.lock().unwrap();
        if let Some((_, c)) = g.iter_mut().find(|(k, _)| k == &key) {
            *c += 1;
        } else {
            g.push((key, 1));
        }
    }

    /// Record one synth latency.
    pub fn record_synth_duration_seconds(&self, dur_s: f64) {
        let mut g = self.synth_duration_buckets.lock().unwrap();
        g.sum += dur_s;
        g.total_count += 1;
        for (i, bound) in BUCKET_BOUNDS_S.iter().enumerate() {
            if dur_s <= *bound {
                g.counts[i] += 1;
            }
        }
        // +Inf bucket always counts everything.
        g.counts[7] += 1;
    }

    /// Record one fallback chain outcome.
    pub fn record_fallback_depth(&self, primary: &str, winner: &str) {
        let key = (primary.to_string(), winner.to_string());
        let mut g = self.fallback_depth.lock().unwrap();
        if let Some((_, c)) = g.iter_mut().find(|(k, _)| k == &key) {
            *c += 1;
        } else {
            g.push((key, 1));
        }
    }

    pub fn record_cache_hit(&self) {
        *self.cache_hits.lock().unwrap() += 1;
    }

    pub fn record_cache_miss(&self) {
        *self.cache_misses.lock().unwrap() += 1;
    }

    pub fn set_pool_active(&self, provider: &str, value: i64) {
        let mut g = self.pool_active.lock().unwrap();
        if let Some((_, v)) = g.iter_mut().find(|(p, _)| p == provider) {
            *v = value;
        } else {
            g.push((provider.to_string(), value));
        }
    }

    /// Render Prometheus exposition format text.
    pub fn render(&self) -> String {
        let mut out = String::with_capacity(2048);

        out.push_str("# HELP nexo_voice_synth_attempts_total Synthesis attempts by provider+reason.\n");
        out.push_str("# TYPE nexo_voice_synth_attempts_total counter\n");
        for ((provider, reason), c) in self.synth_attempts.lock().unwrap().iter() {
            out.push_str(&format!(
                "nexo_voice_synth_attempts_total{{provider=\"{provider}\",reason=\"{reason}\"}} {c}\n"
            ));
        }

        out.push_str("# HELP nexo_voice_synth_duration_seconds Synthesis end-to-end latency.\n");
        out.push_str("# TYPE nexo_voice_synth_duration_seconds histogram\n");
        let g = self.synth_duration_buckets.lock().unwrap();
        for (i, bound) in BUCKET_BOUNDS_S.iter().enumerate() {
            out.push_str(&format!(
                "nexo_voice_synth_duration_seconds_bucket{{le=\"{bound}\"}} {}\n",
                g.counts[i]
            ));
        }
        out.push_str(&format!(
            "nexo_voice_synth_duration_seconds_bucket{{le=\"+Inf\"}} {}\n",
            g.counts[7]
        ));
        out.push_str(&format!("nexo_voice_synth_duration_seconds_sum {}\n", g.sum));
        out.push_str(&format!(
            "nexo_voice_synth_duration_seconds_count {}\n",
            g.total_count
        ));
        drop(g);

        out.push_str("# HELP nexo_voice_fallback_depth_total Provider that won, by primary.\n");
        out.push_str("# TYPE nexo_voice_fallback_depth_total counter\n");
        for ((primary, winner), c) in self.fallback_depth.lock().unwrap().iter() {
            out.push_str(&format!(
                "nexo_voice_fallback_depth_total{{primary=\"{primary}\",winner=\"{winner}\"}} {c}\n"
            ));
        }

        out.push_str("# HELP nexo_voice_cache_hits_total Pipeline cache hits.\n");
        out.push_str("# TYPE nexo_voice_cache_hits_total counter\n");
        out.push_str(&format!(
            "nexo_voice_cache_hits_total {}\n",
            *self.cache_hits.lock().unwrap()
        ));

        out.push_str("# HELP nexo_voice_cache_misses_total Pipeline cache misses.\n");
        out.push_str("# TYPE nexo_voice_cache_misses_total counter\n");
        out.push_str(&format!(
            "nexo_voice_cache_misses_total {}\n",
            *self.cache_misses.lock().unwrap()
        ));

        out.push_str("# HELP nexo_voice_pool_active Active connections per provider pool.\n");
        out.push_str("# TYPE nexo_voice_pool_active gauge\n");
        for (provider, v) in self.pool_active.lock().unwrap().iter() {
            out.push_str(&format!(
                "nexo_voice_pool_active{{provider=\"{provider}\"}} {v}\n"
            ));
        }
        out
    }
}

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

    #[test]
    fn render_starts_with_help_lines_per_family() {
        let m = Metrics::new();
        let text = m.render();
        assert!(text.contains("# HELP nexo_voice_synth_attempts_total"));
        assert!(text.contains("# HELP nexo_voice_synth_duration_seconds"));
        assert!(text.contains("# HELP nexo_voice_fallback_depth_total"));
        assert!(text.contains("# HELP nexo_voice_cache_hits_total"));
    }

    #[test]
    fn record_attempt_increments_counter() {
        let m = Metrics::new();
        m.record_attempt("edge", ReasonCode::Success);
        m.record_attempt("edge", ReasonCode::Success);
        m.record_attempt("edge", ReasonCode::EmptyAudio);
        let text = m.render();
        assert!(text.contains("provider=\"edge\",reason=\"success\"} 2"));
        assert!(text.contains("provider=\"edge\",reason=\"empty_audio\"} 1"));
    }

    #[test]
    fn histogram_buckets_count_cumulative() {
        let m = Metrics::new();
        m.record_synth_duration_seconds(0.05);
        m.record_synth_duration_seconds(0.7);
        let text = m.render();
        // The 0.1 bucket counted only the 0.05 sample.
        assert!(text.contains("le=\"0.1\"} 1"));
        // The 1 bucket counted both.
        assert!(text.contains("le=\"1\"} 2"));
        assert!(text.contains("_count 2"));
    }

    #[test]
    fn namespace_prefix_consistent() {
        let m = Metrics::new();
        m.record_attempt("edge", ReasonCode::Success);
        m.record_cache_hit();
        let text = m.render();
        for line in text.lines() {
            if line.starts_with('#') || line.is_empty() {
                continue;
            }
            assert!(
                line.starts_with("nexo_voice_"),
                "metric line missing namespace: `{line}`"
            );
        }
    }
}
