//! Orphaned build-dir GC (story 1199) — reclaims disk space from dead
//! worktree `target/` directories and a stale `.huskies/merge_workspace`,
//! without ever touching the main project's or any live worktree's
//! `target/` (the warm caches that keep rebuilds fast).
//!
//! Follows service-module conventions: this file holds pure orphan-selection
//! logic (no I/O); `io.rs` is the only place performing side effects
//! (directory-size walks, `fs::remove_dir_all`, `spawn_blocking`, the pass
//! orchestration entry point).

/// Side effects for the GC pass: directory-size walks, recursive removal via
/// `spawn_blocking`, and the pass orchestration entry point.
pub mod io;

use std::path::PathBuf;

use crate::pipeline_state::Stage;
use crate::worktree::WorktreeListEntry;

/// A single directory identified as safe to reclaim by a GC pass.
#[derive(Debug, Clone, PartialEq)]
pub struct GcCandidate {
    /// Human-readable label for reporting (story ID, or `"merge_workspace"`).
    pub label: String,
    pub path: PathBuf,
}

/// Summary of one GC pass: what was reclaimed, what was skipped (raced or
/// errored), how many bytes were freed, and how long the pass took.
#[derive(Debug, Clone, Default)]
pub struct GcReport {
    pub bytes_reclaimed: u64,
    pub reclaimed: Vec<String>,
    pub skipped: Vec<String>,
    pub duration: std::time::Duration,
}

/// Select `target/` directories under worktrees whose story is no longer
/// active in the pool (terminal stage, or absent from the CRDT entirely).
///
/// Reuses [`crate::worktree::worktree_should_be_swept`] — the same predicate
/// that decides whether a whole worktree should be swept — so a `target/`
/// dir is only ever reclaimed here for a worktree that full cleanup would
/// also remove. Live worktrees (Backlog, Coding, Qa, Merge) are never
/// touched, preserving their warm build cache.
pub fn select_orphaned_worktree_targets<F>(
    entries: &[WorktreeListEntry],
    lookup: F,
) -> Vec<GcCandidate>
where
    F: Fn(&str) -> Option<Stage>,
{
    entries
        .iter()
        .filter(|e| crate::worktree::worktree_should_be_swept(lookup(&e.story_id).as_ref()))
        .map(|e| GcCandidate {
            label: e.story_id.clone(),
            path: e.path.join("target"),
        })
        .collect()
}

/// Select the `.huskies/merge_workspace` candidate, if it's safe to reclaim.
///
/// `merge_lock_free` should come from [`crate::agents::merge::merge_lock_is_free`]
/// — a momentary snapshot of whether a squash-merge is currently running.
/// When a merge is running, `merge_workspace` is live state, not an orphan.
pub fn select_merge_workspace(
    project_root: &std::path::Path,
    merge_lock_free: bool,
) -> Option<GcCandidate> {
    if !merge_lock_free {
        return None;
    }
    Some(GcCandidate {
        label: "merge_workspace".to_string(),
        path: project_root.join(".huskies").join("merge_workspace"),
    })
}

/// Returns `true` if an automatic GC pass should run given the configured
/// threshold and current free space.
///
/// `gc_min_free_gb == 0` disables automatic GC entirely — the on-demand `gc`
/// MCP tool remains available regardless.
pub fn should_auto_run(gc_min_free_gb: u64, free_bytes: u64) -> bool {
    gc_min_free_gb > 0 && free_bytes < gc_min_free_gb * 1_000_000_000
}

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

    fn done_stage() -> Stage {
        Stage::Done {
            merged_at: Utc::now(),
            merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
        }
    }

    fn entry(story_id: &str) -> WorktreeListEntry {
        WorktreeListEntry {
            story_id: story_id.to_string(),
            path: PathBuf::from(format!("/project/.huskies/worktrees/{story_id}")),
        }
    }

    // ── select_orphaned_worktree_targets ─────────────────────────────────

    #[test]
    fn orphaned_target_is_selected_when_story_is_terminal() {
        let entries = vec![entry("100_done")];
        let candidates = select_orphaned_worktree_targets(&entries, |_| Some(done_stage()));

        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].label, "100_done");
        assert_eq!(
            candidates[0].path,
            PathBuf::from("/project/.huskies/worktrees/100_done/target")
        );
    }

    #[test]
    fn orphaned_target_is_selected_when_story_absent_from_crdt() {
        let entries = vec![entry("101_purged")];
        let candidates = select_orphaned_worktree_targets(&entries, |_| None);

        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].label, "101_purged");
    }

    #[test]
    fn live_worktree_target_is_kept_not_selected() {
        let entries = vec![entry("102_coding")];
        let candidates = select_orphaned_worktree_targets(&entries, |_| {
            Some(Stage::Coding {
                claim: None,
                plan: Default::default(),
                retries: 0,
            })
        });

        assert!(
            candidates.is_empty(),
            "a live (Coding) worktree's target/ must never be selected for GC"
        );
    }

    #[test]
    fn mixed_pool_only_selects_the_orphan() {
        let entries = vec![entry("103_coding"), entry("104_done")];
        let candidates = select_orphaned_worktree_targets(&entries, |id| {
            if id == "104_done" {
                Some(done_stage())
            } else {
                Some(Stage::Coding {
                    claim: None,
                    plan: Default::default(),
                    retries: 0,
                })
            }
        });

        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].label, "104_done");
    }

    // ── select_merge_workspace ────────────────────────────────────────────

    #[test]
    fn merge_workspace_selected_when_lock_free() {
        let root = PathBuf::from("/project");
        let candidate = select_merge_workspace(&root, true);
        assert_eq!(
            candidate,
            Some(GcCandidate {
                label: "merge_workspace".to_string(),
                path: PathBuf::from("/project/.huskies/merge_workspace"),
            })
        );
    }

    #[test]
    fn merge_workspace_not_selected_when_merge_running() {
        let root = PathBuf::from("/project");
        assert_eq!(select_merge_workspace(&root, false), None);
    }

    // ── should_auto_run (disabled config) ──────────────────────────────────

    #[test]
    fn auto_run_disabled_when_threshold_is_zero() {
        assert!(!should_auto_run(0, 0));
        assert!(!should_auto_run(0, u64::MAX));
    }

    #[test]
    fn auto_run_triggers_below_threshold() {
        let gb = 1_000_000_000;
        assert!(should_auto_run(50, 10 * gb));
        assert!(!should_auto_run(50, 60 * gb));
    }
}
