//! Side effects for `fleet_resources` (story 1207): host stat reads (statvfs,
//! getloadavg, `/proc/meminfo`), `docker stats`/`docker exec` subprocess
//! calls, and a short-TTL cache for the `docker exec ... find` directory-size
//! probe.
//!
//! Host stat reads are genuinely blocking syscalls/file reads and run inside
//! `spawn_blocking`. `docker` subprocess calls use `tokio::process::Command`,
//! which is already async and non-blocking — wrapping an awaited async call
//! in `spawn_blocking` would be a no-op, so those are left as plain `.await`s
//! (matching the existing convention in `chat::transport::matrix::project_rebuild`).

use std::collections::HashMap;
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt as _;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

use futures::future::Future;

use super::{
    ContainerStats, HostStats, ProjectDirSizes, parse_docker_cpu_percent, parse_docker_mem_usage,
    parse_meminfo,
};

// ── Host stats ───────────────────────────────────────────────────────────

/// Read free/total bytes on the filesystem containing `path` via `statvfs(2)`.
fn disk_stats(path: &Path) -> std::io::Result<(u64, u64)> {
    let c_path = CString::new(path.as_os_str().as_bytes())
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
    // SAFETY: `c_path` is a valid NUL-terminated string and `stat` is a
    // zero-initialized `libc::statvfs` passed by mutable reference, matching
    // the `statvfs(2)` contract.
    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
    let ret = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
    if ret != 0 {
        return Err(std::io::Error::last_os_error());
    }
    let free = stat.f_bavail as u64 * stat.f_frsize as u64;
    let total = stat.f_blocks as u64 * stat.f_frsize as u64;
    Ok((free, total))
}

/// Read 1/5/15-minute load averages via `getloadavg(3)`.
fn load_averages() -> Option<(f64, f64, f64)> {
    let mut loads = [0f64; 3];
    // SAFETY: `loads` has room for 3 entries, matching the `3` passed in.
    let n = unsafe { libc::getloadavg(loads.as_mut_ptr(), 3) };
    if n == 3 {
        Some((loads[0], loads[1], loads[2]))
    } else {
        None
    }
}

/// Read `MemTotal`/`MemAvailable` from `/proc/meminfo`.
fn mem_stats() -> Option<(u64, u64)> {
    let contents = std::fs::read_to_string("/proc/meminfo").ok()?;
    parse_meminfo(&contents)
}

/// Collect all host-level stats. Runs on `spawn_blocking` since `statvfs`,
/// `getloadavg`, and the `/proc/meminfo` read are all blocking calls.
pub async fn collect_host_stats(workspace_root: &Path) -> Result<HostStats, String> {
    let root = workspace_root.to_path_buf();
    tokio::task::spawn_blocking(move || {
        let (disk_free_bytes, disk_total_bytes) = disk_stats(&root).map_err(|e| e.to_string())?;
        let (load_avg_1, load_avg_5, load_avg_15) = load_averages().unwrap_or((0.0, 0.0, 0.0));
        let cpu_cores = std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(1);
        let (mem_total_bytes, mem_available_bytes) = mem_stats().unwrap_or((0, 0));
        Ok(HostStats {
            disk_free_bytes,
            disk_total_bytes,
            load_avg_1,
            load_avg_5,
            load_avg_15,
            cpu_cores,
            mem_total_bytes,
            mem_available_bytes,
        })
    })
    .await
    .map_err(|e| format!("spawn_blocking panicked: {e}"))?
}

// ── Docker container stats ──────────────────────────────────────────────

/// The Docker container name for a registered project, following the
/// convention established by `project_rebuild.rs`.
fn container_name(project: &str) -> String {
    format!("huskies-{project}")
}

/// Query `docker stats --no-stream` for one container's CPU%/mem usage.
/// Tolerates failure (missing/stopped container, no Docker) by returning a
/// `ContainerStats` with `error` set rather than propagating an error.
async fn docker_stats_one(project: &str) -> ContainerStats {
    let container = container_name(project);
    let output = tokio::process::Command::new("docker")
        .args([
            "stats",
            "--no-stream",
            "--format",
            "{{.CPUPerc}}\t{{.MemUsage}}",
            &container,
        ])
        .output()
        .await;

    match output {
        Ok(out) if out.status.success() => {
            let text = String::from_utf8_lossy(&out.stdout);
            let line = text.lines().next().unwrap_or("");
            let mut parts = line.splitn(2, '\t');
            let cpu_percent = parts.next().and_then(parse_docker_cpu_percent);
            let mem = parts.next().and_then(parse_docker_mem_usage);
            ContainerStats {
                project: project.to_string(),
                container,
                cpu_percent,
                mem_usage_bytes: mem.map(|(used, _)| used),
                mem_limit_bytes: mem.map(|(_, limit)| limit),
                error: None,
            }
        }
        Ok(out) => ContainerStats {
            project: project.to_string(),
            container,
            cpu_percent: None,
            mem_usage_bytes: None,
            mem_limit_bytes: None,
            error: Some(String::from_utf8_lossy(&out.stderr).trim().to_string()),
        },
        Err(e) => ContainerStats {
            project: project.to_string(),
            container,
            cpu_percent: None,
            mem_usage_bytes: None,
            mem_limit_bytes: None,
            error: Some(format!("docker stats failed to spawn: {e}")),
        },
    }
}

/// Query `docker stats` for every project's container concurrently,
/// tolerating per-container failures (mirrors `gateway_health`'s per-project
/// tolerance) so one dead container never fails the whole call.
pub async fn collect_container_stats(project_names: &[String]) -> Vec<ContainerStats> {
    use futures::future::join_all;
    join_all(project_names.iter().map(|name| docker_stats_one(name))).await
}

// ── Bounded `du` via `docker exec ... find`, TTL-cached ─────────────────

/// How long a computed directory size is reused before being recomputed.
/// Bounds repeated `fleet_resources` calls in a short window from re-shelling
/// out to `docker exec ... find` on every call (story 1207 AC4).
const DU_CACHE_TTL: Duration = Duration::from_secs(30);

/// Memoize an async, fallible value producer for [`DU_CACHE_TTL`], keyed by
/// `key`. `compute` is only invoked on a cache miss/expiry — errors are
/// returned directly without populating the cache, so a transient failure
/// doesn't poison later calls for [`DU_CACHE_TTL`].
///
/// Generic over the producer so it can be exercised in tests with a mocked
/// closure (no Docker required) while real callers pass a `docker exec`
/// invocation.
async fn cached_or_compute<F, Fut>(key: String, compute: F) -> Result<u64, String>
where
    F: FnOnce() -> Fut,
    Fut: Future<Output = Result<u64, String>>,
{
    fn cache() -> &'static Mutex<HashMap<String, (Instant, u64)>> {
        static CACHE: OnceLock<Mutex<HashMap<String, (Instant, u64)>>> = OnceLock::new();
        CACHE.get_or_init(|| Mutex::new(HashMap::new()))
    }

    if let Some((at, bytes)) = cache().lock().unwrap().get(&key)
        && at.elapsed() < DU_CACHE_TTL
    {
        return Ok(*bytes);
    }

    let bytes = compute().await?;
    cache().lock().unwrap().insert(key, (Instant::now(), bytes));
    Ok(bytes)
}

/// Sum file sizes under `path_in_container` (inside the named container) via
/// `docker exec <container> find`, skipping `node_modules`/`.git` entirely so
/// the walk stays bounded on large worktree trees. Returns `0` (rather than
/// erroring) when the path doesn't exist in the container — `find` reports
/// that on stderr with a non-zero exit but partial/empty stdout is still
/// valid, matching `dir_size_bytes`'s "missing path returns 0" contract.
async fn du_via_docker_exec(container: &str, path_in_container: &str) -> Result<u64, String> {
    let output = tokio::process::Command::new("docker")
        .args([
            "exec",
            container,
            "find",
            path_in_container,
            "-type",
            "f",
            "-not",
            "-path",
            "*/node_modules/*",
            "-not",
            "-path",
            "*/.git/*",
            "-printf",
            "%s\n",
        ])
        .output()
        .await
        .map_err(|e| format!("docker exec failed to spawn: {e}"))?;

    let bytes = String::from_utf8_lossy(&output.stdout)
        .lines()
        .filter_map(|l| l.trim().parse::<u64>().ok())
        .sum();
    Ok(bytes)
}

/// Compute `target/` and `.huskies/worktrees/` sizes for one project's
/// container, each cached independently for [`DU_CACHE_TTL`].
async fn project_dir_sizes_one(project: &str) -> ProjectDirSizes {
    let container = container_name(project);

    let target_key = format!("{container}:/workspace/target");
    let target_container = container.clone();
    let target_result = cached_or_compute(target_key, || async move {
        du_via_docker_exec(&target_container, "/workspace/target").await
    })
    .await;

    let worktrees_key = format!("{container}:/workspace/.huskies/worktrees");
    let worktrees_container = container.clone();
    let worktrees_result = cached_or_compute(worktrees_key, || async move {
        du_via_docker_exec(&worktrees_container, "/workspace/.huskies/worktrees").await
    })
    .await;

    match (target_result, worktrees_result) {
        (Ok(target_bytes), Ok(worktrees_bytes)) => ProjectDirSizes {
            project: project.to_string(),
            target_bytes: Some(target_bytes),
            worktrees_bytes: Some(worktrees_bytes),
            error: None,
        },
        (Err(e), _) | (_, Err(e)) => ProjectDirSizes {
            project: project.to_string(),
            target_bytes: None,
            worktrees_bytes: None,
            error: Some(e),
        },
    }
}

/// Compute per-project directory sizes for every project concurrently,
/// tolerating per-project failures.
pub async fn collect_project_dir_sizes(project_names: &[String]) -> Vec<ProjectDirSizes> {
    use futures::future::join_all;
    join_all(project_names.iter().map(|name| project_dir_sizes_one(name))).await
}

// ── Orchestration ────────────────────────────────────────────────────────

/// Collect the full `fleet_resources` response: host stats, per-container
/// CPU/mem, per-project directory sizes, and threshold flags.
///
/// `workspace_root` is the gateway's own workspace path (used for host disk
/// stats); `project_names` are the currently registered `projects.toml` keys.
pub async fn collect_fleet_resources(
    workspace_root: &Path,
    project_names: &[String],
    thresholds: &super::ResourceThresholds,
) -> Result<super::FleetResources, String> {
    let host = collect_host_stats(workspace_root).await?;
    let (containers, projects) = tokio::join!(
        collect_container_stats(project_names),
        collect_project_dir_sizes(project_names),
    );
    let flags = super::flag_host(&host, thresholds);

    Ok(super::FleetResources {
        flags,
        host,
        containers,
        projects,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};

    // ── collect_host_stats ──────────────────────────────────────────────

    #[tokio::test]
    async fn collect_host_stats_reads_real_host() {
        let stats = collect_host_stats(Path::new(".")).await.unwrap();
        assert!(stats.disk_total_bytes > 0);
        assert!(stats.disk_free_bytes <= stats.disk_total_bytes);
        assert!(stats.cpu_cores >= 1);
    }

    #[tokio::test]
    async fn collect_host_stats_missing_path_is_error() {
        let result = collect_host_stats(Path::new("/no/such/path/1207")).await;
        assert!(result.is_err());
    }

    // ── cached_or_compute (mocked producer, no Docker needed) ───────────

    #[tokio::test]
    async fn cached_or_compute_calls_producer_once_within_ttl() {
        let calls = std::sync::Arc::new(AtomicUsize::new(0));
        let key = "test-key-1".to_string();

        let make_producer = |calls: std::sync::Arc<AtomicUsize>, value: u64| {
            move || {
                let calls = calls.clone();
                async move {
                    calls.fetch_add(1, Ordering::SeqCst);
                    Ok(value)
                }
            }
        };

        let first = cached_or_compute(key.clone(), make_producer(calls.clone(), 42))
            .await
            .unwrap();
        let second = cached_or_compute(key.clone(), make_producer(calls.clone(), 99))
            .await
            .unwrap();

        assert_eq!(first, 42);
        assert_eq!(
            second, 42,
            "second call within TTL must reuse the cached value"
        );
        assert_eq!(
            calls.load(Ordering::SeqCst),
            1,
            "producer must only run once while the cache entry is still fresh — proves the \
             expensive computation itself (not just a pre-computed value) is skipped on a hit"
        );
    }

    #[tokio::test]
    async fn cached_or_compute_different_keys_do_not_share_a_cache_entry() {
        let calls = std::sync::Arc::new(AtomicUsize::new(0));
        let make_producer = |calls: std::sync::Arc<AtomicUsize>, value: u64| {
            move || {
                let calls = calls.clone();
                async move {
                    calls.fetch_add(1, Ordering::SeqCst);
                    Ok(value)
                }
            }
        };

        let a = cached_or_compute("key-a".to_string(), make_producer(calls.clone(), 1))
            .await
            .unwrap();
        let b = cached_or_compute("key-b".to_string(), make_producer(calls.clone(), 2))
            .await
            .unwrap();

        assert_eq!(a, 1);
        assert_eq!(b, 2);
        assert_eq!(calls.load(Ordering::SeqCst), 2);
    }

    #[tokio::test]
    async fn cached_or_compute_errors_are_not_cached() {
        let calls = std::sync::Arc::new(AtomicUsize::new(0));
        let key = "test-key-err".to_string();

        let calls_1 = calls.clone();
        let first = cached_or_compute(key.clone(), || async move {
            calls_1.fetch_add(1, Ordering::SeqCst);
            Err::<u64, String>("boom".to_string())
        })
        .await;
        assert!(first.is_err());

        let calls_2 = calls.clone();
        let second = cached_or_compute(key.clone(), || async move {
            calls_2.fetch_add(1, Ordering::SeqCst);
            Ok(7)
        })
        .await
        .unwrap();

        assert_eq!(second, 7);
        assert_eq!(
            calls.load(Ordering::SeqCst),
            2,
            "an error result must not populate the cache, so the next call recomputes"
        );
    }

    // ── container_name ────────────────────────────────────────────────────

    #[test]
    fn container_name_follows_project_rebuild_convention() {
        assert_eq!(container_name("myproj"), "huskies-myproj");
    }
}
