//! Fleet resource types and pure logic for the `fleet_resources` MCP tool
//! (story 1207): host disk/load/cpu/mem, per-container CPU%/mem, per-project
//! `target/`+`worktrees/` sizes, and threshold-based flagging.
//!
//! Follows the `service/gateway` conventions: this file holds pure types and
//! classification logic (no I/O); `io.rs` performs all side effects (statvfs,
//! getloadavg, `/proc/meminfo`, and `docker` subprocess calls).

/// Side effects for fleet resource collection: host stat reads, `docker
/// stats`/`docker exec` subprocess calls, and a TTL-cached `du` helper.
pub mod io;

use serde::Serialize;

/// Host machine (gateway node) resource snapshot.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct HostStats {
    /// Free bytes on the filesystem containing the gateway's workspace root.
    pub disk_free_bytes: u64,
    /// Total bytes on the filesystem containing the gateway's workspace root.
    pub disk_total_bytes: u64,
    /// 1-minute load average.
    pub load_avg_1: f64,
    /// 5-minute load average.
    pub load_avg_5: f64,
    /// 15-minute load average.
    pub load_avg_15: f64,
    /// Number of logical CPU cores available to the gateway process.
    pub cpu_cores: usize,
    /// Total physical memory in bytes.
    pub mem_total_bytes: u64,
    /// Available (not merely free) memory in bytes, per `/proc/meminfo`'s
    /// `MemAvailable` estimate.
    pub mem_available_bytes: u64,
}

/// Per-container CPU/mem snapshot, sourced via `docker stats`.
///
/// `error` is set (with all other fields `None`) when the container is
/// unreachable or `docker stats` fails — one dead container must not fail the
/// whole `fleet_resources` call.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ContainerStats {
    /// Registered project name (key in `projects.toml`).
    pub project: String,
    /// Docker container name (`huskies-{project}`).
    pub container: String,
    /// CPU usage percentage, as reported by `docker stats`.
    pub cpu_percent: Option<f64>,
    /// Memory currently in use, in bytes.
    pub mem_usage_bytes: Option<u64>,
    /// Memory limit (cgroup limit or host total), in bytes.
    pub mem_limit_bytes: Option<u64>,
    /// Set when `docker stats` failed for this container.
    pub error: Option<String>,
}

/// Per-project on-disk footprint: `target/` build output and
/// `.huskies/worktrees/` (coder agent worktrees), measured inside the
/// project's own container via `docker exec ... find` (see `io.rs`).
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ProjectDirSizes {
    /// Registered project name.
    pub project: String,
    /// Bytes used by `/workspace/target`, or `None` if it could not be measured.
    pub target_bytes: Option<u64>,
    /// Bytes used by `/workspace/.huskies/worktrees`, or `None` if it could
    /// not be measured.
    pub worktrees_bytes: Option<u64>,
    /// Set when the `docker exec ... find` measurement failed.
    pub error: Option<String>,
}

/// Threshold configuration for [`flag_host`]. Supplied per tool call (see
/// `handle_fleet_resources_tool`) rather than read from `projects.toml`, so an
/// operator can widen/narrow thresholds per invocation.
#[derive(Debug, Clone, PartialEq)]
pub struct ResourceThresholds {
    /// Host free disk space (GB) below which a "warn" flag fires.
    pub disk_warn_gb: u64,
    /// Host free disk space (GB) below which a "critical" flag fires.
    pub disk_critical_gb: u64,
    /// 1-minute load average per core above which a "warn" flag fires.
    pub load_warn_per_core: f64,
    /// 1-minute load average per core above which a "critical" flag fires.
    pub load_critical_per_core: f64,
}

impl Default for ResourceThresholds {
    fn default() -> Self {
        Self {
            disk_warn_gb: 50,
            disk_critical_gb: 20,
            load_warn_per_core: 1.0,
            load_critical_per_core: 2.0,
        }
    }
}

/// A single flagged resource condition, surfaced ahead of the raw readings so
/// problems "lead" the response (story 1207 AC3).
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResourceFlag {
    /// Flag category: `"low_disk"` or `"high_load"`.
    pub kind: String,
    /// Severity: `"warn"` or `"critical"`.
    pub level: String,
    /// Human-readable detail (e.g. current reading vs. threshold).
    pub detail: String,
}

/// Full `fleet_resources` response payload.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct FleetResources {
    /// Flagged problems, first so they lead the response.
    pub flags: Vec<ResourceFlag>,
    /// Host-level resource snapshot.
    pub host: HostStats,
    /// Per-container CPU/mem snapshots.
    pub containers: Vec<ContainerStats>,
    /// Per-project `target/`/`worktrees/` directory sizes.
    pub projects: Vec<ProjectDirSizes>,
}

const BYTES_PER_GB: u64 = 1_000_000_000;

/// Flag host disk/load conditions against `thresholds`. Pure — no I/O.
///
/// Mirrors `disk_watch::classify_level`'s "below X" boundary semantics for
/// disk (free space strictly below the threshold triggers the flag) and uses
/// "at or above" for load (a load average right at the threshold already
/// indicates saturation, unlike free-space headroom).
pub fn flag_host(host: &HostStats, thresholds: &ResourceThresholds) -> Vec<ResourceFlag> {
    let mut flags = Vec::new();

    if host.disk_free_bytes < thresholds.disk_critical_gb * BYTES_PER_GB {
        flags.push(ResourceFlag {
            kind: "low_disk".to_string(),
            level: "critical".to_string(),
            detail: format!(
                "{:.1} GB free (critical threshold: {} GB)",
                host.disk_free_bytes as f64 / BYTES_PER_GB as f64,
                thresholds.disk_critical_gb
            ),
        });
    } else if host.disk_free_bytes < thresholds.disk_warn_gb * BYTES_PER_GB {
        flags.push(ResourceFlag {
            kind: "low_disk".to_string(),
            level: "warn".to_string(),
            detail: format!(
                "{:.1} GB free (warn threshold: {} GB)",
                host.disk_free_bytes as f64 / BYTES_PER_GB as f64,
                thresholds.disk_warn_gb
            ),
        });
    }

    if host.cpu_cores > 0 {
        let per_core = host.load_avg_1 / host.cpu_cores as f64;
        if per_core >= thresholds.load_critical_per_core {
            flags.push(ResourceFlag {
                kind: "high_load".to_string(),
                level: "critical".to_string(),
                detail: format!(
                    "load1={:.2} across {} core(s) ({:.2}/core, critical threshold: {:.2}/core)",
                    host.load_avg_1, host.cpu_cores, per_core, thresholds.load_critical_per_core
                ),
            });
        } else if per_core >= thresholds.load_warn_per_core {
            flags.push(ResourceFlag {
                kind: "high_load".to_string(),
                level: "warn".to_string(),
                detail: format!(
                    "load1={:.2} across {} core(s) ({:.2}/core, warn threshold: {:.2}/core)",
                    host.load_avg_1, host.cpu_cores, per_core, thresholds.load_warn_per_core
                ),
            });
        }
    }

    flags
}

/// Parse a `docker stats` CPU percentage field like `"12.34%"`.
pub fn parse_docker_cpu_percent(s: &str) -> Option<f64> {
    s.trim().trim_end_matches('%').parse::<f64>().ok()
}

/// Parse a single `docker stats`-formatted size like `"12.3MiB"`, `"1.5GiB"`,
/// or `"512B"` into bytes. Handles the IEC-binary units `docker stats` uses.
pub fn parse_docker_size(s: &str) -> Option<u64> {
    let s = s.trim();
    let unit_start = s.find(|c: char| !c.is_ascii_digit() && c != '.')?;
    let (number, unit) = s.split_at(unit_start);
    let value: f64 = number.parse().ok()?;
    let multiplier: f64 = match unit {
        "B" => 1.0,
        "KiB" | "KB" => 1024.0,
        "MiB" | "MB" => 1024.0 * 1024.0,
        "GiB" | "GB" => 1024.0 * 1024.0 * 1024.0,
        "TiB" | "TB" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
        _ => return None,
    };
    Some((value * multiplier) as u64)
}

/// Parse a `docker stats` `MemUsage` field like `"58.14MiB / 3.842GiB"` into
/// `(used_bytes, limit_bytes)`.
pub fn parse_docker_mem_usage(s: &str) -> Option<(u64, u64)> {
    let (used, limit) = s.split_once(" / ")?;
    Some((parse_docker_size(used)?, parse_docker_size(limit)?))
}

/// Parse `MemTotal`/`MemAvailable` (in kB) out of `/proc/meminfo` contents
/// into `(total_bytes, available_bytes)`.
pub fn parse_meminfo(contents: &str) -> Option<(u64, u64)> {
    let mut total = None;
    let mut available = None;
    for line in contents.lines() {
        if let Some(rest) = line.strip_prefix("MemTotal:") {
            total = parse_meminfo_kb_field(rest);
        } else if let Some(rest) = line.strip_prefix("MemAvailable:") {
            available = parse_meminfo_kb_field(rest);
        }
    }
    Some((total?, available?))
}

/// Parse a `/proc/meminfo` value field like `"  16384000 kB"` into bytes.
fn parse_meminfo_kb_field(s: &str) -> Option<u64> {
    let kb: u64 = s.trim().trim_end_matches("kB").trim().parse().ok()?;
    Some(kb * 1024)
}

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

    fn thresholds() -> ResourceThresholds {
        ResourceThresholds {
            disk_warn_gb: 50,
            disk_critical_gb: 20,
            load_warn_per_core: 1.0,
            load_critical_per_core: 2.0,
        }
    }

    fn host(disk_free_gb: u64, load_avg_1: f64, cpu_cores: usize) -> HostStats {
        HostStats {
            disk_free_bytes: disk_free_gb * BYTES_PER_GB,
            disk_total_bytes: 500 * BYTES_PER_GB,
            load_avg_1,
            load_avg_5: load_avg_1,
            load_avg_15: load_avg_1,
            cpu_cores,
            mem_total_bytes: 16 * BYTES_PER_GB,
            mem_available_bytes: 8 * BYTES_PER_GB,
        }
    }

    // ── flag_host: disk ──────────────────────────────────────────────────

    #[test]
    fn flag_host_healthy_disk_and_load_produces_no_flags() {
        let flags = flag_host(&host(100, 0.5, 4), &thresholds());
        assert!(flags.is_empty(), "expected no flags, got: {flags:?}");
    }

    #[test]
    fn flag_host_below_warn_gb_flags_warn() {
        let flags = flag_host(&host(49, 0.5, 4), &thresholds());
        assert_eq!(flags.len(), 1);
        assert_eq!(flags[0].kind, "low_disk");
        assert_eq!(flags[0].level, "warn");
    }

    #[test]
    fn flag_host_at_warn_boundary_is_ok() {
        // Exactly at warn_gb is not below it, so no flag (matches disk_watch semantics).
        let flags = flag_host(&host(50, 0.5, 4), &thresholds());
        assert!(flags.is_empty());
    }

    #[test]
    fn flag_host_below_critical_gb_flags_critical_not_warn() {
        let flags = flag_host(&host(19, 0.5, 4), &thresholds());
        assert_eq!(flags.len(), 1);
        assert_eq!(flags[0].kind, "low_disk");
        assert_eq!(flags[0].level, "critical");
    }

    // ── flag_host: load ───────────────────────────────────────────────────

    #[test]
    fn flag_host_load_at_warn_per_core_flags_warn() {
        // 4 cores, warn threshold 1.0/core => load1 == 4.0 triggers warn.
        let flags = flag_host(&host(100, 4.0, 4), &thresholds());
        assert_eq!(flags.len(), 1);
        assert_eq!(flags[0].kind, "high_load");
        assert_eq!(flags[0].level, "warn");
    }

    #[test]
    fn flag_host_load_at_critical_per_core_flags_critical_not_warn() {
        // 4 cores, critical threshold 2.0/core => load1 == 8.0 triggers critical only.
        let flags = flag_host(&host(100, 8.0, 4), &thresholds());
        assert_eq!(flags.len(), 1);
        assert_eq!(flags[0].kind, "high_load");
        assert_eq!(flags[0].level, "critical");
    }

    #[test]
    fn flag_host_zero_cores_does_not_panic_or_flag_load() {
        let flags = flag_host(&host(100, 10.0, 0), &thresholds());
        assert!(flags.iter().all(|f| f.kind != "high_load"));
    }

    #[test]
    fn flag_host_disk_and_load_both_flag_together() {
        let flags = flag_host(&host(10, 10.0, 4), &thresholds());
        assert_eq!(flags.len(), 2);
        assert!(flags.iter().any(|f| f.kind == "low_disk"));
        assert!(flags.iter().any(|f| f.kind == "high_load"));
    }

    // ── parse_docker_cpu_percent ──────────────────────────────────────────

    #[test]
    fn parse_cpu_percent_typical() {
        assert_eq!(parse_docker_cpu_percent("12.34%"), Some(12.34));
    }

    #[test]
    fn parse_cpu_percent_zero() {
        assert_eq!(parse_docker_cpu_percent("0.00%"), Some(0.0));
    }

    #[test]
    fn parse_cpu_percent_invalid_is_none() {
        assert_eq!(parse_docker_cpu_percent("n/a"), None);
    }

    // ── parse_docker_size / parse_docker_mem_usage ────────────────────────

    #[test]
    fn parse_size_bytes_no_unit_suffix() {
        assert_eq!(parse_docker_size("512B"), Some(512));
    }

    #[test]
    fn parse_size_mib() {
        assert_eq!(parse_docker_size("1MiB"), Some(1024 * 1024));
    }

    #[test]
    fn parse_size_fractional_gib() {
        assert_eq!(
            parse_docker_size("1.5GiB"),
            Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
        );
    }

    #[test]
    fn parse_size_unknown_unit_is_none() {
        assert_eq!(parse_docker_size("3.2XB"), None);
    }

    #[test]
    fn parse_mem_usage_typical() {
        let (used, limit) = parse_docker_mem_usage("58.14MiB / 3.842GiB").unwrap();
        assert_eq!(used, (58.14 * 1024.0 * 1024.0) as u64);
        assert_eq!(limit, (3.842 * 1024.0 * 1024.0 * 1024.0) as u64);
    }

    #[test]
    fn parse_mem_usage_missing_separator_is_none() {
        assert_eq!(parse_docker_mem_usage("58.14MiB"), None);
    }

    // ── parse_meminfo ──────────────────────────────────────────────────────

    #[test]
    fn parse_meminfo_extracts_total_and_available() {
        let sample = "MemTotal:       16384000 kB\nMemFree:         1000000 kB\nMemAvailable:    8000000 kB\n";
        let (total, available) = parse_meminfo(sample).unwrap();
        assert_eq!(total, 16_384_000 * 1024);
        assert_eq!(available, 8_000_000 * 1024);
    }

    #[test]
    fn parse_meminfo_missing_fields_is_none() {
        assert_eq!(parse_meminfo("MemFree: 1000 kB\n"), None);
    }

    // ── FleetResources shape ────────────────────────────────────────────────

    #[test]
    fn fleet_resources_serializes_expected_top_level_keys() {
        let resources = FleetResources {
            flags: vec![ResourceFlag {
                kind: "low_disk".to_string(),
                level: "warn".to_string(),
                detail: "40.0 GB free".to_string(),
            }],
            host: host(40, 0.5, 4),
            containers: vec![ContainerStats {
                project: "huskies".to_string(),
                container: "huskies-huskies".to_string(),
                cpu_percent: Some(12.3),
                mem_usage_bytes: Some(100),
                mem_limit_bytes: Some(1000),
                error: None,
            }],
            projects: vec![ProjectDirSizes {
                project: "huskies".to_string(),
                target_bytes: Some(123),
                worktrees_bytes: Some(456),
                error: None,
            }],
        };
        let value = serde_json::to_value(&resources).unwrap();
        assert!(value.get("flags").unwrap().is_array());
        assert!(value.get("host").unwrap().get("disk_free_bytes").is_some());
        assert!(value.get("containers").unwrap().is_array());
        assert!(value.get("projects").unwrap().is_array());
        assert_eq!(value["containers"][0]["cpu_percent"], 12.3);
        assert_eq!(value["projects"][0]["target_bytes"], 123);
    }
}
