//! Runtime metrics for tidalDB.
//!
//! [`MetricsState`] is an `Arc`-shared bag of atomics that `TidalDb` updates
//! on every operation. The metrics HTTP server (when the `metrics` feature
//! is enabled) reads from this shared state to serve Prometheus text format.
//!
//! Adding a new counter in future milestones is:
//! 1. Add an `AtomicU64` field to `MetricsState`
//! 2. Increment it in the relevant `TidalDb` method
//! 3. Add one line to `MetricsState::render_prometheus`

pub(crate) mod histogram;
use std::{
    sync::atomic::{AtomicBool, AtomicU64, Ordering},
    time::Instant,
};

#[cfg(feature = "metrics")]
pub(crate) use histogram::{
    LatencyHistogram, QUERY_LATENCY_BOUNDS, WRITE_LATENCY_BOUNDS, write_metric_line,
};

/// Current wall-clock time as Unix nanoseconds, saturating to `u64::MAX`.
///
/// Shared by checkpoint-staleness derivation and Prometheus rendering so the
/// "now in nanos" pattern lives in one place. Returns `0` if the system clock
/// is before the Unix epoch, which both callers treat as "no meaningful age" —
/// never a panic. The `u64` nanosecond space does not overflow until the year
/// 2554, well beyond any realistic deployment.
///
/// Only the `metrics` feature consumes wall-clock-now (staleness gauge +
/// Prometheus render), so the helper is gated to match and avoid a dead-code
/// warning under `-D warnings` when `metrics` is off.
#[cfg(feature = "metrics")]
fn now_unix_nanos() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
}

// ── MetricsState ────────────────────────────────────────────────────────────

/// Shared runtime metrics for a `TidalDb` instance.
///
/// Cheap to clone (`Arc` inside). Thread-safe.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let db = tidaldb::TidalDb::builder().ephemeral().open()?;
/// let metrics = db.metrics();
/// assert!(metrics.uptime_seconds() >= 0.0);
/// assert!((metrics.health_ok_value() - 1.0).abs() < f64::EPSILON);
/// # Ok(())
/// # }
/// ```
pub struct MetricsState {
    /// Time the database was opened.
    pub(crate) opened_at: Instant,
    /// Whether the database is currently healthy.
    pub(crate) health_ok: AtomicBool,

    // ── Signal system + WAL metrics (M7p4 Task 02) ──────────────────────
    /// Bytes of WAL segments not yet compacted.
    #[cfg(feature = "metrics")]
    pub(crate) wal_lag_bytes: AtomicU64,

    /// Total WAL segments compacted since database open.
    #[cfg(feature = "metrics")]
    pub(crate) wal_compacted_segments_total: AtomicU64,

    /// Unix timestamp (nanoseconds) of the last successful checkpoint.
    #[cfg(feature = "metrics")]
    pub(crate) last_checkpoint_ns: AtomicU64,

    /// Number of entries in the signal ledger hot tier.
    #[cfg(feature = "metrics")]
    pub(crate) signal_hot_entries: AtomicU64,

    /// Total signal writes since database open.
    #[cfg(feature = "metrics")]
    pub(crate) signal_writes_total: AtomicU64,

    /// Signal write latency histogram (microseconds).
    #[cfg(feature = "metrics")]
    pub(crate) signal_write_latency: LatencyHistogram,

    /// Retrieve query end-to-end latency histogram (microseconds).
    #[cfg(feature = "metrics")]
    pub(crate) retrieve_latency: LatencyHistogram,

    /// Search query end-to-end latency histogram (microseconds).
    #[cfg(feature = "metrics")]
    pub(crate) search_latency: LatencyHistogram,

    // ── Session + cohort + degradation metrics (m7p4, task-04) ─────────
    /// Number of currently active sessions.
    #[cfg(feature = "metrics")]
    pub(crate) active_sessions: AtomicU64,
    /// Total sessions closed since open (cumulative).
    #[cfg(feature = "metrics")]
    pub(crate) closed_sessions_total: AtomicU64,
    /// Total sessions auto-closed due to timeout since open (cumulative).
    #[cfg(feature = "metrics")]
    pub(crate) session_auto_closed_total: AtomicU64,
    /// Total requests rate-limited since open (cumulative).
    #[cfg(feature = "metrics")]
    pub(crate) rate_limited_total: AtomicU64,
    /// Current degradation level (0=Full, 1=ReducedCandidates, 2=CoarseAggregates, 3=NoDiversity).
    #[cfg(feature = "metrics")]
    pub(crate) degradation_level: AtomicU64,

    // ── Index health metrics (m7p4, task-03) ──────────────────────────────
    /// Number of Tantivy segments for the items text index.
    #[cfg(feature = "metrics")]
    pub(crate) tantivy_segment_count: AtomicU64,
    /// Number of documents indexed in the items text index.
    #[cfg(feature = "metrics")]
    pub(crate) tantivy_indexed_docs: AtomicU64,
    /// Total byte size of the `USearch` index files (f16 estimate).
    #[cfg(feature = "metrics")]
    pub(crate) usearch_index_size_bytes: AtomicU64,
    /// Number of vectors stored in the `USearch` index.
    #[cfg(feature = "metrics")]
    pub(crate) usearch_vector_count: AtomicU64,
    /// Total cardinality across all bitmap index entries (category + format + creator + tag).
    #[cfg(feature = "metrics")]
    pub(crate) bitmap_index_cardinality: AtomicU64,

    /// Total number of failed periodic signal checkpoints.
    pub(crate) checkpoint_failures_total: AtomicU64,

    /// Set to `true` by the checkpoint thread's supervisor if the checkpoint
    /// loop panicked. A panicked checkpoint thread means signal durability has
    /// silently stopped, so health must report degraded (CONCURRENCY-2). Also
    /// set by [`health_check`](crate::TidalDb::health_check) when it observes
    /// the join handle has finished unexpectedly (HEALTH-1).
    pub(crate) checkpoint_thread_died: AtomicBool,

    // ── Replication metrics (m8p2) ──────────────────────────────────────
    /// Current replication lag in WAL segments (follower only; 0 on leader).
    #[cfg(feature = "metrics")]
    pub(crate) replication_lag_seqno: AtomicU64,

    // ── M8p5 control plane ───────────────────────────────────────────────
    /// Shared control plane for cluster health queries.
    pub(crate) control_plane: Option<std::sync::Arc<crate::replication::ControlPlane>>,

    /// This node's shard/partition id, stamped onto the node-identity labels of
    /// every Prometheus line. Defaults to `0` (the single-node shard) so an
    /// un-wired metrics state still emits valid `partition_id="0"` output; in
    /// cluster mode the construction site installs the real `ShardId` via
    /// [`set_partition_id`](Self::set_partition_id) so each node's series is
    /// distinguishable when scraped into one Prometheus. Stored as an atomic so
    /// the install takes `&self` and works even after the `Arc` is shared.
    partition_id: AtomicU64,
}

impl MetricsState {
    pub(crate) fn new() -> Self {
        Self {
            opened_at: Instant::now(),
            health_ok: AtomicBool::new(true),
            #[cfg(feature = "metrics")]
            wal_lag_bytes: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            wal_compacted_segments_total: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            last_checkpoint_ns: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            signal_hot_entries: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            signal_writes_total: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            signal_write_latency: LatencyHistogram::new(WRITE_LATENCY_BOUNDS),
            #[cfg(feature = "metrics")]
            retrieve_latency: LatencyHistogram::new(QUERY_LATENCY_BOUNDS),
            #[cfg(feature = "metrics")]
            search_latency: LatencyHistogram::new(QUERY_LATENCY_BOUNDS),
            #[cfg(feature = "metrics")]
            active_sessions: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            closed_sessions_total: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            session_auto_closed_total: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            rate_limited_total: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            degradation_level: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            tantivy_segment_count: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            tantivy_indexed_docs: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            usearch_index_size_bytes: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            usearch_vector_count: AtomicU64::new(0),
            #[cfg(feature = "metrics")]
            bitmap_index_cardinality: AtomicU64::new(0),
            checkpoint_failures_total: AtomicU64::new(0),
            checkpoint_thread_died: AtomicBool::new(false),
            #[cfg(feature = "metrics")]
            replication_lag_seqno: AtomicU64::new(0),
            control_plane: None,
            partition_id: AtomicU64::new(0),
        }
    }

    /// Maximum age (nanoseconds) a checkpoint may reach before health reports
    /// degraded. The periodic checkpoint thread runs every 30s; a checkpoint
    /// older than 5 minutes means the thread is stuck, dead, or failing — all
    /// of which silently stop signal durability if not surfaced (HEALTH-1).
    pub(crate) const CHECKPOINT_STALENESS_LIMIT_NS: u64 = 300_000_000_000;

    /// Install the shared control plane so health and replication-lag readers
    /// can observe real cluster state. Called once during `from_parts`.
    pub(crate) fn set_control_plane(
        &mut self,
        control_plane: std::sync::Arc<crate::replication::ControlPlane>,
    ) {
        self.control_plane = Some(control_plane);
    }

    /// Stamp this node's shard/partition id onto rendered Prometheus lines.
    ///
    /// Called once at construction with the node's real `ShardId` so cluster
    /// nodes emit their own `partition_id` label instead of the single-node `0`
    /// default — without it, every node's series collide when scraped into one
    /// Prometheus. Takes `&self` (atomic store) so it works after the metrics
    /// `Arc` is shared, unlike [`set_control_plane`](Self::set_control_plane).
    ///
    /// Wiring (W13): the construction sites in `db/mod.rs` call this right after
    /// `set_control_plane` — `config.cluster.shard_id.0` on the persistent/cluster
    /// path and `ShardId::SINGLE.0` on the single-node path — so each node's label
    /// reflects its real topology and cluster series do not collide on `0`.
    pub(crate) fn set_partition_id(&self, shard_id: u16) {
        self.partition_id
            .store(u64::from(shard_id), Ordering::Relaxed);
    }

    /// Return the current cluster health snapshot, if a control plane is wired.
    #[must_use]
    pub fn cluster_health(&self) -> Option<crate::replication::ClusterHealth> {
        self.control_plane.as_ref().map(|cp| cp.health())
    }

    /// Derive whether the database is currently degraded from real conditions,
    /// not just the explicit `close()` flag (HEALTH-1, CONCURRENCY-2).
    ///
    /// Reports degraded when any of the following hold:
    /// - the explicit `health_ok` flag is false (closed or shutting down),
    /// - the periodic checkpoint thread has died — the panic supervisor or
    ///   [`health_check`](crate::TidalDb::health_check) set
    ///   `checkpoint_thread_died`,
    /// - a checkpoint has succeeded at least once but the most recent success
    ///   is older than [`CHECKPOINT_STALENESS_LIMIT_NS`] (stuck/failing thread
    ///   or disk-full — durability has silently stalled).
    ///
    /// A freshly opened database that has not yet run its first checkpoint
    /// (`last_checkpoint_ns == 0`) is **not** degraded on staleness alone —
    /// the 30s interval has simply not elapsed yet. Once any checkpoint
    /// succeeds, staleness is enforced from then on. Staleness derivation is
    /// `metrics`-gated because the last-checkpoint timestamp gauge only exists
    /// with that feature; the always-present flag and `health_ok` checks cover
    /// the rest.
    #[must_use]
    pub fn is_degraded(&self) -> bool {
        if !self.health_ok.load(Ordering::Acquire) {
            return true;
        }
        if self.checkpoint_thread_died.load(Ordering::Acquire) {
            return true;
        }
        #[cfg(feature = "metrics")]
        {
            let last_ok = self.last_checkpoint_ns.load(Ordering::Acquire);
            if last_ok > 0 {
                let age_ns = now_unix_nanos().saturating_sub(last_ok);
                if age_ns > Self::CHECKPOINT_STALENESS_LIMIT_NS {
                    return true;
                }
            }
        }
        false
    }

    /// Uptime in fractional seconds since the database was opened.
    #[must_use]
    pub fn uptime_seconds(&self) -> f64 {
        self.opened_at.elapsed().as_secs_f64()
    }

    /// Whether the database reports healthy (1.0) or degraded (0.0).
    ///
    /// Derived from real conditions via [`is_degraded`](Self::is_degraded), not
    /// just the explicit close flag, so `tidaldb_health_ok` flips during
    /// disk-full, failed checkpoints, or a dead checkpoint thread (HEALTH-1).
    #[must_use]
    pub fn health_ok_value(&self) -> f64 {
        if self.is_degraded() { 0.0 } else { 1.0 }
    }

    /// Render Prometheus text exposition format for all metrics.
    ///
    /// Format: <https://prometheus.io/docs/instrumenting/exposition_formats/>
    #[must_use]
    #[allow(clippy::too_many_lines)]
    pub fn render_prometheus(&self) -> String {
        let uptime = self.uptime_seconds();
        let health = self.health_ok_value();
        let version = env!("CARGO_PKG_VERSION");
        let build_hash = crate::BUILD_HASH;
        // Real shard id in cluster mode; `0` for the single-node default. Stamps
        // the node-identity label so per-node series stay distinct when many
        // nodes are scraped into one Prometheus (W13).
        let partition_id = self.partition_id.load(Ordering::Relaxed);

        #[allow(unused_mut)]
        let mut out = format!(
            "# HELP tidaldb_uptime_seconds Seconds since database opened.\n\
             # TYPE tidaldb_uptime_seconds gauge\n\
             tidaldb_uptime_seconds{{partition_id=\"{partition_id}\"}} {uptime:.3}\n\n\
             # HELP tidaldb_health_ok Whether the database is healthy. 1 = ok, 0 = degraded.\n\
             # TYPE tidaldb_health_ok gauge\n\
             tidaldb_health_ok{{partition_id=\"{partition_id}\"}} {health}\n\n\
             # HELP tidaldb_info Build and version information.\n\
             # TYPE tidaldb_info gauge\n\
             tidaldb_info{{version=\"{version}\",build_hash=\"{build_hash}\",partition_id=\"{partition_id}\"}} 1\n"
        );

        // Prometheus uses f64 natively; precision loss on u64->f64 is
        // intentional and acceptable for monitoring counters/gauges.
        #[cfg(feature = "metrics")]
        #[allow(clippy::cast_precision_loss)]
        {
            let now_ns = now_unix_nanos();

            write_metric_line(
                &mut out,
                "tidaldb_wal_lag_bytes",
                "Bytes of WAL segments not yet compacted",
                "gauge",
                self.wal_lag_bytes.load(Ordering::Relaxed) as f64,
            );

            write_metric_line(
                &mut out,
                "tidaldb_wal_compacted_segments_total",
                "Total WAL segments compacted since open",
                "counter",
                self.wal_compacted_segments_total.load(Ordering::Relaxed) as f64,
            );

            let last_cp_ns = self.last_checkpoint_ns.load(Ordering::Relaxed);
            let checkpoint_age = if last_cp_ns > 0 && now_ns > last_cp_ns {
                now_ns - last_cp_ns
            } else {
                0
            };
            write_metric_line(
                &mut out,
                "tidaldb_checkpoint_age_seconds",
                "Seconds since the last successful checkpoint",
                "gauge",
                checkpoint_age as f64 / 1_000_000_000.0,
            );

            write_metric_line(
                &mut out,
                "tidaldb_signal_hot_entries",
                "Number of entries in the signal ledger hot tier",
                "gauge",
                self.signal_hot_entries.load(Ordering::Relaxed) as f64,
            );

            write_metric_line(
                &mut out,
                "tidaldb_signal_writes_total",
                "Total signal writes since database open",
                "counter",
                self.signal_writes_total.load(Ordering::Relaxed) as f64,
            );

            out.push_str(&self.signal_write_latency.render_prometheus(
                "tidaldb_signal_write_latency_us",
                "Signal write latency in microseconds",
            ));

            out.push_str(&self.retrieve_latency.render_prometheus(
                "tidaldb_retrieve_latency_us",
                "Retrieve query end-to-end latency in microseconds",
            ));

            out.push_str(&self.search_latency.render_prometheus(
                "tidaldb_search_latency_us",
                "Search query end-to-end latency in microseconds",
            ));

            // Index health metrics.
            write_metric_line(
                &mut out,
                "tidaldb_tantivy_segment_count",
                "Number of Tantivy index segments",
                "gauge",
                self.tantivy_segment_count.load(Ordering::Relaxed) as f64,
            );
            write_metric_line(
                &mut out,
                "tidaldb_tantivy_indexed_docs",
                "Number of documents indexed in Tantivy",
                "gauge",
                self.tantivy_indexed_docs.load(Ordering::Relaxed) as f64,
            );
            write_metric_line(
                &mut out,
                "tidaldb_usearch_index_size_bytes",
                "Estimated byte size of USearch vector indexes",
                "gauge",
                self.usearch_index_size_bytes.load(Ordering::Relaxed) as f64,
            );
            write_metric_line(
                &mut out,
                "tidaldb_usearch_vector_count",
                "Number of vectors stored in USearch indexes",
                "gauge",
                self.usearch_vector_count.load(Ordering::Relaxed) as f64,
            );
            write_metric_line(
                &mut out,
                "tidaldb_bitmap_index_cardinality",
                "Total entity IDs across all bitmap indexes",
                "gauge",
                self.bitmap_index_cardinality.load(Ordering::Relaxed) as f64,
            );

            // Session lifecycle metrics.
            write_metric_line(
                &mut out,
                "tidaldb_active_sessions",
                "Number of currently active agent sessions",
                "gauge",
                self.active_sessions.load(Ordering::Relaxed) as f64,
            );
            write_metric_line(
                &mut out,
                "tidaldb_closed_sessions_total",
                "Total agent sessions closed since open",
                "counter",
                self.closed_sessions_total.load(Ordering::Relaxed) as f64,
            );
            write_metric_line(
                &mut out,
                "tidaldb_session_auto_closed_total",
                "Total agent sessions auto-closed due to timeout",
                "counter",
                self.session_auto_closed_total.load(Ordering::Relaxed) as f64,
            );
            // Rate limiting.
            write_metric_line(
                &mut out,
                "tidaldb_rate_limited_total",
                "Total requests rate-limited due to overload",
                "counter",
                self.rate_limited_total.load(Ordering::Relaxed) as f64,
            );
            // Degradation.
            write_metric_line(
                &mut out,
                "tidaldb_degradation_level",
                "Current degradation level (0=full, 1=reduced, 2=coarse, 3=no_diversity)",
                "gauge",
                self.degradation_level.load(Ordering::Relaxed) as f64,
            );

            // Replication lag. Prefer the live control-plane gauge (real
            // follower lag) when a control plane is wired; fall back to the
            // last value stored by the checkpoint refresh path otherwise
            // (obs-REPL-1).
            let replication_lag = self.control_plane.as_ref().map_or_else(
                || self.replication_lag_seqno.load(Ordering::Relaxed),
                |cp| cp.lag_for(crate::replication::ShardId::SINGLE),
            );
            write_metric_line(
                &mut out,
                "tidaldb_replication_lag_seqno",
                "Replication lag in WAL segments behind the leader",
                "gauge",
                replication_lag as f64,
            );
        }

        // Checkpoint failure counter (unconditional -- not feature-gated).
        // Carries the partition_id label so this per-node series stays distinct
        // when many nodes are scraped into one Prometheus (W13), matching the
        // uptime/health/info lines above. Without it, every node's
        // checkpoint_failures_total would collapse onto a single unlabeled series.
        {
            use std::fmt::Write;
            let failures = self.checkpoint_failures_total.load(Ordering::Relaxed);
            let _ = write!(
                out,
                "\n# HELP tidaldb_checkpoint_failures_total Total number of failed periodic signal checkpoints\n\
                 # TYPE tidaldb_checkpoint_failures_total counter\n\
                 tidaldb_checkpoint_failures_total{{partition_id=\"{partition_id}\"}} {failures}\n"
            );
        }

        out
    }

    /// Render JSON for /healthz.
    ///
    /// `status` is derived from real conditions via
    /// [`is_degraded`](Self::is_degraded), so `/healthz` reports `degraded`
    /// during disk-full, failed/stale checkpoints, or a dead checkpoint thread
    /// — not only on explicit close (HEALTH-1).
    #[must_use]
    pub fn render_healthz(&self) -> String {
        let uptime = self.uptime_seconds();
        let status = if self.is_degraded() { "degraded" } else { "ok" };
        let version = env!("CARGO_PKG_VERSION");
        let build_hash = crate::BUILD_HASH;
        format!(
            r#"{{"status":"{status}","uptime_seconds":{uptime:.3},"version":"{version}","build_hash":"{build_hash}"}}"#
        )
    }
}

#[cfg(test)]
mod tests;

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

    /// A fresh metrics state stamps the single-node default `partition_id="0"`
    /// onto every node-identity line — preserving the pre-W13 behavior when the
    /// shard id is never installed.
    #[test]
    fn default_partition_id_is_zero() {
        let state = MetricsState::new();
        let out = state.render_prometheus();
        assert!(out.contains("tidaldb_uptime_seconds{partition_id=\"0\"}"));
        assert!(out.contains("tidaldb_health_ok{partition_id=\"0\"}"));
        assert!(out.contains("partition_id=\"0\"} 1"));
    }

    /// After the construction site installs a real shard id (cluster mode), the
    /// node-identity label reflects it so per-node series do not collide when
    /// many nodes are scraped into one Prometheus (W13). The old hardcoded `0`
    /// must no longer appear on those lines.
    #[test]
    fn cluster_partition_id_is_stamped_on_node_lines() {
        let state = MetricsState::new();
        state.set_partition_id(7);
        let out = state.render_prometheus();
        assert!(out.contains("tidaldb_uptime_seconds{partition_id=\"7\"}"));
        assert!(out.contains("tidaldb_health_ok{partition_id=\"7\"}"));
        assert!(out.contains("partition_id=\"7\"} 1"));
        assert!(
            !out.contains("partition_id=\"0\""),
            "cluster node must not emit the single-node default partition_id: {out}"
        );
    }

    /// The unconditional `checkpoint_failures_total` counter must carry the
    /// `partition_id` label like every other node-identity series, so per-node
    /// failure counts stay distinct in a multi-node Prometheus scrape (W13).
    #[test]
    fn checkpoint_failures_total_carries_partition_id() {
        let state = MetricsState::new();
        let out = state.render_prometheus();
        assert!(
            out.contains("tidaldb_checkpoint_failures_total{partition_id=\"0\"}"),
            "checkpoint_failures_total must carry the default partition_id label: {out}"
        );
        // Never emit the old unlabeled series.
        assert!(
            !out.contains("\ntidaldb_checkpoint_failures_total 0\n"),
            "checkpoint_failures_total must not emit an unlabeled series: {out}"
        );

        let cluster = MetricsState::new();
        cluster.set_partition_id(7);
        let cout = cluster.render_prometheus();
        assert!(
            cout.contains("tidaldb_checkpoint_failures_total{partition_id=\"7\"}"),
            "checkpoint_failures_total must reflect the cluster shard id: {cout}"
        );
    }
}
