//! Worktree orphan sweep helpers.
//!
//! `worktree_should_be_swept` and `sweep_with_lookup` are test helpers used
//! to verify that the reactive worktree-cleanup subscriber (`spawn_worktree_cleanup_subscriber`)
//! correctly identifies which stages warrant worktree removal.  Production
//! worktree cleanup is driven by `TransitionFired` events in
//! `agents::pool::worktree_lifecycle`, not by periodic scanning.

#[cfg(test)]
use super::{list_worktrees, remove_worktree_by_story_id};
#[cfg(test)]
use crate::config::ProjectConfig;
use crate::pipeline_state::Stage;
#[cfg(test)]
use std::path::Path;

/// Returns `true` if a worktree for the given pipeline stage should be removed.
///
/// A worktree is swept when its story is `Done`, `Archived`, or not present in
/// the CRDT at all (i.e. `stage` is `None`).  Active stages (`Backlog`,
/// `Coding`, `Qa`, `Merge`) are left alone.
pub fn worktree_should_be_swept(stage: Option<&Stage>) -> bool {
    match stage {
        None => true,
        Some(
            Stage::Done { .. }
            | Stage::Archived { .. }
            | Stage::Abandoned { .. }
            | Stage::Superseded { .. }
            | Stage::Rejected { .. },
        ) => true,
        Some(_) => false,
    }
}

/// Internal sweep implementation for tests: walks worktrees and removes those
/// whose stories are in a terminal stage according to the provided `lookup`.
///
/// Accepts a `lookup` closure `fn(&str) -> Option<Stage>` that returns the
/// stage for a given story ID, or `None` if the story is not in the CRDT.
/// This indirection makes the sweep testable without a real CRDT.
#[cfg(test)]
pub(crate) async fn sweep_with_lookup<F>(
    project_root: &Path,
    config: &ProjectConfig,
    lookup: F,
) -> usize
where
    F: Fn(&str) -> Option<Stage>,
{
    let entries = match list_worktrees(project_root) {
        Ok(e) => e,
        Err(err) => {
            crate::slog_error!("[worktree-sweep] Failed to list worktrees: {err}");
            return 0;
        }
    };

    let mut removed = 0usize;
    for entry in entries {
        let stage = lookup(&entry.story_id);
        if !worktree_should_be_swept(stage.as_ref()) {
            continue;
        }

        crate::slog!(
            "[worktree-sweep] Removing orphaned worktree for '{}' (stage: {})",
            entry.story_id,
            stage
                .as_ref()
                .map(|s| format!("{:?}", s))
                .unwrap_or_else(|| "not in CRDT".to_string())
        );

        match remove_worktree_by_story_id(project_root, &entry.story_id, config).await {
            Ok(()) => removed += 1,
            Err(err) => {
                crate::slog_error!(
                    "[worktree-sweep] Failed to remove worktree for '{}': {err}",
                    entry.story_id
                );
            }
        }
    }

    removed
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::WatcherConfig;
    use chrono::Utc;
    use std::fs;
    use std::num::NonZeroU32;
    use std::path::PathBuf;
    use std::process::Command;
    use tempfile::TempDir;

    fn init_git_repo(dir: &Path) {
        Command::new("git")
            .args(["init"])
            .current_dir(dir)
            .output()
            .expect("git init");
        Command::new("git")
            .args(["commit", "--allow-empty", "-m", "init"])
            .current_dir(dir)
            .output()
            .expect("git commit");
    }

    fn empty_config() -> ProjectConfig {
        ProjectConfig {
            component: vec![],
            agent: vec![],
            watcher: WatcherConfig::default(),
            disk_watch: Default::default(),
            default_qa: "server".to_string(),
            default_coder_model: None,
            max_coders: None,
            max_retries: 2,
            base_branch: None,
            rate_limit_notifications: true,
            web_ui_status_consumer: true,
            matrix_status_consumer: true,
            slack_status_consumer: true,
            discord_status_consumer: true,
            whatsapp_status_consumer: true,
            timezone: None,
            rendezvous: None,
            trusted_keys: Vec::new(),
            crdt_require_token: false,
            crdt_tokens: Vec::new(),
            max_mesh_peers: 3,
            gateway_url: None,
            gateway_project: None,
            status_push_enabled: true,
            merge_failure_block_threshold: 3,
            gc_min_free_gb: 0,
        }
    }

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

    fn archived_stage() -> Stage {
        Stage::Archived {
            archived_at: Utc::now(),
            reason: crate::pipeline_state::ArchiveReason::Completed,
        }
    }

    // ── worktree_should_be_swept unit tests ─────────────────────────────────

    #[test]
    fn should_sweep_when_not_in_crdt() {
        assert!(worktree_should_be_swept(None));
    }

    #[test]
    fn should_sweep_done() {
        assert!(worktree_should_be_swept(Some(&done_stage())));
    }

    #[test]
    fn should_sweep_archived() {
        assert!(worktree_should_be_swept(Some(&archived_stage())));
    }

    #[test]
    fn should_not_sweep_backlog() {
        assert!(!worktree_should_be_swept(Some(&Stage::Backlog)));
    }

    #[test]
    fn should_not_sweep_coding() {
        assert!(!worktree_should_be_swept(Some(&Stage::Coding {
            claim: None,
            plan: Default::default(),
            retries: 0,
        })));
    }

    #[test]
    fn should_not_sweep_qa() {
        assert!(!worktree_should_be_swept(Some(&Stage::Qa)));
    }

    #[test]
    fn should_not_sweep_merge() {
        let stage = Stage::Merge {
            feature_branch: crate::pipeline_state::BranchName("feature/x".to_string()),
            commits_ahead: NonZeroU32::new(1).unwrap(),
            claim: None,
            retries: 0,
            server_start_time: None,
        };
        assert!(!worktree_should_be_swept(Some(&stage)));
    }

    // ── Integration tests: sweep_with_lookup ────────────────────────────────

    fn setup_project_with_worktree(story_id: &str) -> (TempDir, PathBuf) {
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("project");
        fs::create_dir_all(&project_root).unwrap();
        init_git_repo(&project_root);

        // Create a bare worktree directory (simulates a worktree without full git checkout)
        let worktrees_dir = project_root.join(".huskies").join("worktrees");
        fs::create_dir_all(worktrees_dir.join(story_id)).unwrap();

        (tmp, project_root)
    }

    #[tokio::test]
    async fn sweep_removes_done_worktree() {
        let story_id = "100_done_story";
        let (_tmp, project_root) = setup_project_with_worktree(story_id);
        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        assert!(wt_dir.exists(), "worktree should exist before sweep");

        // We can't remove via git worktree because it's not a real git worktree,
        // so mock the removal: create a worktree directory and confirm sweep detects it.
        // Instead, test that the sweep logic identifies the worktree as removable.
        let config = empty_config();
        let removed = sweep_with_lookup(&project_root, &config, |id| {
            if id == story_id {
                Some(done_stage())
            } else {
                None
            }
        })
        .await;

        // The worktree dir exists but is not a real git worktree, so remove_worktree_by_story_id
        // will fail (not a git worktree). We can't assert removed == 1, but we can verify
        // it was attempted (removed == 0 due to error is fine; what matters is no panic and
        // the sweep continued).
        // For a stronger test, use a real git worktree below.
        let _ = removed; // sweep ran without panic
    }

    #[tokio::test]
    async fn sweep_removes_real_done_worktree() {
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("project");
        fs::create_dir_all(&project_root).unwrap();
        init_git_repo(&project_root);

        let story_id = "101_done_real";
        let config = empty_config();

        // Create a real git worktree.
        super::super::create::create_worktree(&project_root, story_id, &config, 3001)
            .await
            .unwrap();

        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        assert!(wt_dir.exists(), "worktree must exist before sweep");

        let removed = sweep_with_lookup(&project_root, &config, |id| {
            if id == story_id {
                Some(done_stage())
            } else {
                None
            }
        })
        .await;

        assert_eq!(removed, 1, "sweep should remove the done worktree");
        assert!(
            !wt_dir.exists(),
            "worktree directory should be gone after sweep"
        );
    }

    #[tokio::test]
    async fn sweep_does_not_remove_current_worktree() {
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("project");
        fs::create_dir_all(&project_root).unwrap();
        init_git_repo(&project_root);

        let story_id = "102_current_story";
        let config = empty_config();

        super::super::create::create_worktree(&project_root, story_id, &config, 3001)
            .await
            .unwrap();

        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        assert!(wt_dir.exists());

        let removed = sweep_with_lookup(&project_root, &config, |id| {
            if id == story_id {
                Some(Stage::Coding {
                    claim: None,
                    plan: Default::default(),
                    retries: 0,
                })
            } else {
                None
            }
        })
        .await;

        assert_eq!(removed, 0, "sweep must not remove current/coding worktrees");
        assert!(wt_dir.exists(), "worktree directory must still exist");
    }

    #[tokio::test]
    async fn sweep_does_not_remove_qa_worktree() {
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("project");
        fs::create_dir_all(&project_root).unwrap();
        init_git_repo(&project_root);

        let story_id = "103_qa_story";
        let config = empty_config();

        super::super::create::create_worktree(&project_root, story_id, &config, 3001)
            .await
            .unwrap();

        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);

        let removed = sweep_with_lookup(&project_root, &config, |id| {
            if id == story_id {
                Some(Stage::Qa)
            } else {
                None
            }
        })
        .await;

        assert_eq!(removed, 0, "sweep must not remove qa worktrees");
        assert!(wt_dir.exists());
    }

    #[tokio::test]
    async fn sweep_does_not_remove_merge_worktree() {
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("project");
        fs::create_dir_all(&project_root).unwrap();
        init_git_repo(&project_root);

        let story_id = "104_merge_story";
        let config = empty_config();

        super::super::create::create_worktree(&project_root, story_id, &config, 3001)
            .await
            .unwrap();

        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);

        let removed = sweep_with_lookup(&project_root, &config, |id| {
            if id == story_id {
                Some(Stage::Merge {
                    feature_branch: crate::pipeline_state::BranchName(
                        "feature/story-104_merge_story".to_string(),
                    ),
                    commits_ahead: NonZeroU32::new(1).unwrap(),
                    claim: None,
                    retries: 0,
                    server_start_time: None,
                })
            } else {
                None
            }
        })
        .await;

        assert_eq!(removed, 0, "sweep must not remove merge worktrees");
        assert!(wt_dir.exists());
    }

    #[tokio::test]
    async fn sweep_removes_worktree_not_in_crdt() {
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("project");
        fs::create_dir_all(&project_root).unwrap();
        init_git_repo(&project_root);

        let story_id = "105_absent_story";
        let config = empty_config();

        super::super::create::create_worktree(&project_root, story_id, &config, 3001)
            .await
            .unwrap();

        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        assert!(wt_dir.exists());

        // lookup returns None → story not in CRDT
        let removed = sweep_with_lookup(&project_root, &config, |_| None).await;

        assert_eq!(
            removed, 1,
            "sweep should remove worktree with no CRDT entry"
        );
        assert!(!wt_dir.exists());
    }

    #[tokio::test]
    async fn sweep_continues_on_individual_failure() {
        // One worktree that's a bare directory (not a real git worktree — removal fails)
        // and one real worktree. The sweep should skip the failed one and remove the real one.
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("project");
        fs::create_dir_all(&project_root).unwrap();
        init_git_repo(&project_root);

        let bad_id = "106_bad_wt";
        let good_id = "107_good_wt";
        let config = empty_config();

        // Bad: bare directory, not a real git worktree.
        fs::create_dir_all(project_root.join(".huskies").join("worktrees").join(bad_id)).unwrap();

        // Good: real worktree.
        super::super::create::create_worktree(&project_root, good_id, &config, 3001)
            .await
            .unwrap();

        let removed = sweep_with_lookup(&project_root, &config, |_| None).await;

        // The good one was removed; bad one failed but sweep continued.
        assert!(
            removed >= 1,
            "at least the real worktree should be removed; got {removed}"
        );

        let good_wt = project_root
            .join(".huskies")
            .join("worktrees")
            .join(good_id);
        assert!(!good_wt.exists(), "real worktree should be gone");
    }
}
