//! Side effects for the build-directory GC pass: directory-size walks,
//! recursive removal via `spawn_blocking`, and the pass orchestration entry
//! point.

use std::path::Path;
use std::time::Instant;

use super::{GcCandidate, GcReport, select_merge_workspace, select_orphaned_worktree_targets};

/// Reclaim a single candidate directory: compute its size, then remove it.
///
/// Tolerates races — if the directory has already vanished (removed by a
/// concurrent build, worktree cleanup, or merge), this returns `Ok(0)`
/// rather than failing. Runs on `spawn_blocking` since both the size walk and
/// the removal are blocking filesystem operations.
async fn reclaim_candidate(candidate: &GcCandidate) -> Result<u64, String> {
    let path = candidate.path.clone();
    tokio::task::spawn_blocking(move || {
        if !path.exists() {
            return Ok(0);
        }
        let bytes = crate::service::disk_watch::io::dir_size_bytes(&path);
        match std::fs::remove_dir_all(&path) {
            Ok(()) => Ok(bytes),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
            Err(e) => Err(format!("remove {}: {e}", path.display())),
        }
    })
    .await
    .map_err(|e| format!("spawn_blocking panicked: {e}"))?
}

/// Run one GC pass: reclaim orphaned worktree `target/` dirs and a stale
/// `merge_workspace`, tolerating races (skip on error) so a concurrent build
/// or merge never fails the whole pass. Logs bytes reclaimed and duration.
pub async fn run_gc_pass(project_root: &Path) -> GcReport {
    let start = Instant::now();

    let entries = crate::worktree::list_worktrees(project_root).unwrap_or_default();
    let mut candidates = select_orphaned_worktree_targets(&entries, |id| {
        crate::pipeline_state::read_typed(id)
            .ok()
            .flatten()
            .map(|item| item.stage)
    });

    let merge_lock_free = crate::agents::merge::merge_lock_is_free();
    if let Some(mw) = select_merge_workspace(project_root, merge_lock_free) {
        candidates.push(mw);
    }

    let mut bytes_reclaimed = 0u64;
    let mut reclaimed = Vec::new();
    let mut skipped = Vec::new();

    for candidate in &candidates {
        match reclaim_candidate(candidate).await {
            Ok(bytes) => {
                if bytes > 0 {
                    bytes_reclaimed += bytes;
                    reclaimed.push(candidate.label.clone());
                }
            }
            Err(err) => {
                crate::slog_warn!("[gc] Skipping '{}': {err}", candidate.label);
                skipped.push(candidate.label.clone());
            }
        }
    }

    let duration = start.elapsed();
    crate::slog!(
        "[gc] Pass complete: reclaimed {bytes_reclaimed} bytes across {} dir(s) in {:.2}s ({} skipped)",
        reclaimed.len(),
        duration.as_secs_f64(),
        skipped.len()
    );

    GcReport {
        bytes_reclaimed,
        reclaimed,
        skipped,
        duration,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::service::gc::GcCandidate;
    use std::path::PathBuf;

    #[tokio::test]
    async fn reclaim_candidate_returns_zero_when_path_never_existed() {
        let candidate = GcCandidate {
            label: "999_never_existed".to_string(),
            path: PathBuf::from("/no/such/path/for/story-1199-test"),
        };
        let result = reclaim_candidate(&candidate).await;
        assert_eq!(result, Ok(0));
    }

    #[tokio::test]
    async fn reclaim_candidate_returns_zero_when_path_vanishes_before_reclaim() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("target");
        std::fs::create_dir_all(&path).unwrap();
        std::fs::write(path.join("a.bin"), vec![0u8; 128]).unwrap();

        // Simulate a race: the directory is removed by another process
        // between selection and reclaim.
        std::fs::remove_dir_all(&path).unwrap();

        let candidate = GcCandidate {
            label: "race".to_string(),
            path,
        };
        let result = reclaim_candidate(&candidate).await;
        assert_eq!(
            result,
            Ok(0),
            "a vanished directory must be tolerated, not treated as an error"
        );
    }

    #[tokio::test]
    async fn reclaim_candidate_reports_bytes_for_a_real_directory() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("target");
        std::fs::create_dir_all(&path).unwrap();
        std::fs::write(path.join("a.bin"), vec![0u8; 256]).unwrap();

        let candidate = GcCandidate {
            label: "real".to_string(),
            path: path.clone(),
        };
        let result = reclaim_candidate(&candidate).await;
        assert_eq!(result, Ok(256));
        assert!(!path.exists(), "reclaimed directory must be removed");
    }

    #[tokio::test]
    async fn run_gc_pass_tolerates_one_candidate_vanishing_and_still_reclaims_the_rest() {
        let tmp = tempfile::tempdir().unwrap();
        let project_root = tmp.path().to_path_buf();

        // No worktrees/merge_workspace on disk at all — the pass must still
        // complete cleanly (nothing to reclaim, nothing to error on).
        let report = run_gc_pass(&project_root).await;
        assert_eq!(report.bytes_reclaimed, 0);
        assert!(report.skipped.is_empty());
    }
}
