//! Low-disk-space watchdog (story 1200) — checks free space on the
//! `/workspace` filesystem each tick and decides whether to emit a
//! rate-limited warn/critical warning or a one-shot recovery notice.
//!
//! Follows service-module conventions: this file holds pure classification
//! and rate-limiting logic (no I/O, no `Instant::now()` calls — callers
//! supply `now`); `io.rs` is the only place performing side effects
//! (statvfs reads, directory-size walks, broadcasting `WatcherEvent`s).

/// Side effects: free-space reads, directory-size walks, and the periodic
/// check-and-notify entry point.
pub mod io;

use std::time::{Duration, Instant};

use crate::config::DiskWatchConfig;

/// Bytes per gigabyte, using the same decimal (GB, not GiB) convention as the
/// `warn_gb` / `critical_gb` config fields.
const BYTES_PER_GB: u64 = 1_000_000_000;

/// Disk-space severity level relative to configured thresholds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiskLevel {
    /// Free space is at or above the warn threshold.
    Ok,
    /// Free space is below `warn_gb` but at/above `critical_gb`.
    Warn,
    /// Free space is below `critical_gb`.
    Critical,
}

impl DiskLevel {
    /// Wire/display name used in notifications and gateway dedupe keys.
    pub fn as_str(self) -> &'static str {
        match self {
            DiskLevel::Ok => "ok",
            DiskLevel::Warn => "warn",
            DiskLevel::Critical => "critical",
        }
    }
}

/// Classify `free_bytes` against the configured warn/critical thresholds.
pub fn classify_level(free_bytes: u64, config: &DiskWatchConfig) -> DiskLevel {
    if free_bytes < config.critical_gb * BYTES_PER_GB {
        DiskLevel::Critical
    } else if free_bytes < config.warn_gb * BYTES_PER_GB {
        DiskLevel::Warn
    } else {
        DiskLevel::Ok
    }
}

/// Returns `true` if free space has recovered enough to send a recovery
/// notice: at or above `warn_gb * (1 + recovery_margin_pct / 100)`.
pub fn is_recovered(free_bytes: u64, config: &DiskWatchConfig) -> bool {
    let recovery_threshold =
        config.warn_gb * BYTES_PER_GB * (100 + config.recovery_margin_pct) / 100;
    free_bytes >= recovery_threshold
}

/// Returns `true` if a repeat notification for the same level should be
/// sent, given when it was last sent (`None` if never) and the configured
/// rate limit.
pub fn should_send(last_sent: Option<Instant>, now: Instant, rate_limit_secs: u64) -> bool {
    match last_sent {
        None => true,
        Some(last) => now.duration_since(last) >= Duration::from_secs(rate_limit_secs),
    }
}

/// Per-sled in-memory state tracked across tick iterations: the last level
/// observed, and when a notification was last sent for each level.
#[derive(Debug, Default)]
pub struct DiskWatchState {
    /// The disk level observed on the previous check, `None` before the first check.
    pub last_level: Option<DiskLevel>,
    /// When a "warn" notification was last sent.
    pub warn_last_sent: Option<Instant>,
    /// When a "critical" notification was last sent.
    pub critical_last_sent: Option<Instant>,
}

/// The next action to take based on a fresh disk-level observation.
#[derive(Debug, PartialEq)]
pub enum DiskAction {
    /// No notification needed.
    None,
    /// Send a warn/critical warning notification.
    Warning(DiskLevel),
    /// Send a single recovery notice.
    Recovery,
}

/// Decide what action to take given a fresh free-space reading, updating
/// `state` in place. Pure aside from taking `now` as an argument (no direct
/// I/O or wall-clock reads).
pub fn decide_action(
    free_bytes: u64,
    config: &DiskWatchConfig,
    state: &mut DiskWatchState,
    now: Instant,
) -> DiskAction {
    let level = classify_level(free_bytes, config);
    let was_persisting = matches!(
        state.last_level,
        Some(DiskLevel::Warn) | Some(DiskLevel::Critical)
    );
    state.last_level = Some(level);

    match level {
        DiskLevel::Ok => {
            if was_persisting && is_recovered(free_bytes, config) {
                state.warn_last_sent = None;
                state.critical_last_sent = None;
                DiskAction::Recovery
            } else {
                DiskAction::None
            }
        }
        DiskLevel::Warn => {
            if should_send(state.warn_last_sent, now, config.rate_limit_secs) {
                state.warn_last_sent = Some(now);
                DiskAction::Warning(DiskLevel::Warn)
            } else {
                DiskAction::None
            }
        }
        DiskLevel::Critical => {
            if should_send(state.critical_last_sent, now, config.rate_limit_secs) {
                state.critical_last_sent = Some(now);
                DiskAction::Warning(DiskLevel::Critical)
            } else {
                DiskAction::None
            }
        }
    }
}

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

    fn config() -> DiskWatchConfig {
        DiskWatchConfig {
            warn_gb: 50,
            critical_gb: 20,
            rate_limit_secs: 6 * 60 * 60,
            recovery_margin_pct: 10,
        }
    }

    const GB: u64 = 1_000_000_000;

    // ── classify_level (threshold crossing) ───────────────────────────────

    #[test]
    fn classify_above_warn_is_ok() {
        assert_eq!(classify_level(60 * GB, &config()), DiskLevel::Ok);
    }

    #[test]
    fn classify_at_warn_boundary_is_warn() {
        // Below warn_gb (50) counts as Warn; exactly at warn_gb is still Ok
        // (the threshold is "below", not "at or below").
        assert_eq!(classify_level(50 * GB, &config()), DiskLevel::Ok);
        assert_eq!(classify_level(49 * GB, &config()), DiskLevel::Warn);
    }

    #[test]
    fn classify_between_warn_and_critical_is_warn() {
        assert_eq!(classify_level(30 * GB, &config()), DiskLevel::Warn);
    }

    #[test]
    fn classify_at_critical_boundary() {
        assert_eq!(classify_level(20 * GB, &config()), DiskLevel::Warn);
        assert_eq!(classify_level(19 * GB, &config()), DiskLevel::Critical);
    }

    #[test]
    fn classify_below_critical_is_critical() {
        assert_eq!(classify_level(5 * GB, &config()), DiskLevel::Critical);
    }

    // ── is_recovered ───────────────────────────────────────────────────────

    #[test]
    fn is_recovered_requires_margin_above_warn() {
        let cfg = config();
        // warn_gb=50, margin=10% => recovery threshold = 55 GB.
        assert!(!is_recovered(54 * GB, &cfg));
        assert!(is_recovered(55 * GB, &cfg));
        assert!(is_recovered(60 * GB, &cfg));
    }

    // ── should_send (rate limiting) ────────────────────────────────────────

    #[test]
    fn should_send_when_never_sent() {
        assert!(should_send(None, Instant::now(), 21_600));
    }

    #[test]
    fn should_not_send_within_rate_limit_window() {
        let now = Instant::now();
        let last = now - Duration::from_secs(100);
        assert!(!should_send(Some(last), now, 21_600));
    }

    #[test]
    fn should_send_after_rate_limit_window_expires() {
        let now = Instant::now();
        let last = now - Duration::from_secs(21_601);
        assert!(should_send(Some(last), now, 21_600));
    }

    // ── decide_action (threshold crossing + rate limiting + recovery) ──────

    #[test]
    fn decide_action_first_warn_reading_sends_warning() {
        let cfg = config();
        let mut state = DiskWatchState::default();
        let action = decide_action(30 * GB, &cfg, &mut state, Instant::now());
        assert_eq!(action, DiskAction::Warning(DiskLevel::Warn));
    }

    #[test]
    fn decide_action_repeat_warn_within_window_is_suppressed() {
        let cfg = config();
        let mut state = DiskWatchState::default();
        let now = Instant::now();
        assert_eq!(
            decide_action(30 * GB, &cfg, &mut state, now),
            DiskAction::Warning(DiskLevel::Warn)
        );
        // Second check 1 minute later — same level, still within 6h window.
        let later = now + Duration::from_secs(60);
        assert_eq!(
            decide_action(30 * GB, &cfg, &mut state, later),
            DiskAction::None
        );
    }

    #[test]
    fn decide_action_repeat_warn_after_window_sends_again() {
        let cfg = config();
        let mut state = DiskWatchState::default();
        let now = Instant::now();
        decide_action(30 * GB, &cfg, &mut state, now);
        let later = now + Duration::from_secs(6 * 60 * 60 + 1);
        assert_eq!(
            decide_action(30 * GB, &cfg, &mut state, later),
            DiskAction::Warning(DiskLevel::Warn)
        );
    }

    #[test]
    fn decide_action_warn_and_critical_have_independent_rate_limits() {
        let cfg = config();
        let mut state = DiskWatchState::default();
        let now = Instant::now();
        assert_eq!(
            decide_action(30 * GB, &cfg, &mut state, now),
            DiskAction::Warning(DiskLevel::Warn)
        );
        // Dropping straight to critical moments later must still notify —
        // it has its own rate-limit bucket, separate from warn's.
        let later = now + Duration::from_secs(5);
        assert_eq!(
            decide_action(10 * GB, &cfg, &mut state, later),
            DiskAction::Warning(DiskLevel::Critical)
        );
    }

    #[test]
    fn decide_action_recovery_after_persisting_warn() {
        let cfg = config();
        let mut state = DiskWatchState::default();
        let now = Instant::now();
        decide_action(30 * GB, &cfg, &mut state, now); // Warn
        // Free space climbs back above the 55 GB recovery threshold.
        assert_eq!(
            decide_action(60 * GB, &cfg, &mut state, now),
            DiskAction::Recovery
        );
    }

    #[test]
    fn decide_action_no_recovery_notice_without_prior_warning() {
        let cfg = config();
        let mut state = DiskWatchState::default();
        // First-ever reading is healthy — must not fire a spurious recovery notice.
        assert_eq!(
            decide_action(60 * GB, &cfg, &mut state, Instant::now()),
            DiskAction::None
        );
    }

    #[test]
    fn decide_action_recovery_is_sent_only_once() {
        let cfg = config();
        let mut state = DiskWatchState::default();
        let now = Instant::now();
        decide_action(30 * GB, &cfg, &mut state, now); // Warn
        assert_eq!(
            decide_action(60 * GB, &cfg, &mut state, now),
            DiskAction::Recovery
        );
        // Still healthy on the next tick — no repeat recovery notice.
        assert_eq!(
            decide_action(60 * GB, &cfg, &mut state, now),
            DiskAction::None
        );
    }

    #[test]
    fn decide_action_back_above_warn_but_below_margin_is_not_recovery() {
        let cfg = config();
        let mut state = DiskWatchState::default();
        let now = Instant::now();
        decide_action(30 * GB, &cfg, &mut state, now); // Warn
        // 52 GB is above warn_gb (50) but below the 55 GB recovery margin.
        assert_eq!(
            decide_action(52 * GB, &cfg, &mut state, now),
            DiskAction::None
        );
    }
}
