//! Orphaned-worktree cleanup: dry-run discovery and confirmed removal.
//!
//! Both the chat-bot command and the MCP tool delegate to [`run_cleanup`], which
//! walks `.huskies/worktrees/`, compares each directory against the CRDT, and
//! optionally removes those whose story is missing or in `Done`/`Archived`.

use crate::config::ProjectConfig;
use crate::pipeline_state::{Stage, read_typed};
use crate::worktree::sweep::worktree_should_be_swept;
use crate::worktree::{WorktreeListEntry, list_worktrees, remove_worktree_by_story_id};
use std::path::Path;

/// Result of a cleanup pass — describes what was found and what was done.
pub struct CleanupReport {
    /// Story IDs of all orphaned worktrees that were found.
    pub orphaned: Vec<String>,
    /// Story IDs that were successfully removed (`confirm = true` only).
    pub removed: Vec<String>,
    /// Story IDs that failed to remove, paired with the error message.
    pub failed: Vec<(String, String)>,
}

/// Run an orphaned-worktree cleanup using the live CRDT state.
///
/// When `confirm` is `false`, worktrees are only discovered — nothing is
/// removed. When `confirm` is `true`, every orphaned worktree is removed via
/// [`remove_worktree_by_story_id`].
///
/// A worktree is considered orphaned when its story is absent from the CRDT,
/// or is in the `Done` or `Archived` stage.
pub async fn run_cleanup(
    project_root: &Path,
    config: &ProjectConfig,
    confirm: bool,
) -> CleanupReport {
    run_cleanup_with_lookup(project_root, config, confirm, |story_id| {
        read_typed(story_id).ok().flatten().map(|item| item.stage)
    })
    .await
}

/// Internal implementation that accepts an injectable CRDT lookup for testing.
pub(crate) async fn run_cleanup_with_lookup<F>(
    project_root: &Path,
    config: &ProjectConfig,
    confirm: bool,
    lookup: F,
) -> CleanupReport
where
    F: Fn(&str) -> Option<Stage>,
{
    let all_entries = match list_worktrees(project_root) {
        Ok(e) => e,
        Err(err) => {
            crate::slog_error!("[worktree-cleanup] Failed to list worktrees: {err}");
            return CleanupReport {
                orphaned: Vec::new(),
                removed: Vec::new(),
                failed: Vec::new(),
            };
        }
    };

    let orphaned: Vec<WorktreeListEntry> = all_entries
        .into_iter()
        .filter(|e| {
            let stage = lookup(&e.story_id);
            worktree_should_be_swept(stage.as_ref())
        })
        .collect();

    let orphaned_ids: Vec<String> = orphaned.iter().map(|e| e.story_id.clone()).collect();

    if !confirm {
        return CleanupReport {
            orphaned: orphaned_ids,
            removed: Vec::new(),
            failed: Vec::new(),
        };
    }

    let mut removed = Vec::new();
    let mut failed = Vec::new();

    for entry in orphaned {
        match remove_worktree_by_story_id(project_root, &entry.story_id, config).await {
            Ok(()) => {
                crate::slog!(
                    "[worktree-cleanup] Removed orphaned worktree '{}'",
                    entry.story_id
                );
                removed.push(entry.story_id);
            }
            Err(err) => {
                crate::slog_error!(
                    "[worktree-cleanup] Failed to remove worktree '{}': {err}",
                    entry.story_id
                );
                failed.push((entry.story_id, err));
            }
        }
    }

    CleanupReport {
        orphaned: orphaned_ids,
        removed,
        failed,
    }
}

/// Format a [`CleanupReport`] as a Markdown string suitable for chat or MCP output.
pub fn format_report(report: &CleanupReport, confirm: bool) -> String {
    if report.orphaned.is_empty() {
        return "No orphaned worktrees found.".to_string();
    }

    if !confirm {
        let list = report
            .orphaned
            .iter()
            .map(|id| format!("- `{id}`"))
            .collect::<Vec<_>>()
            .join("\n");
        return format!(
            "Found {} orphaned worktree(s):\n{list}\n\nRun with `--confirm` to remove them.",
            report.orphaned.len()
        );
    }

    let mut parts = Vec::new();

    if !report.removed.is_empty() {
        let list = report
            .removed
            .iter()
            .map(|id| format!("- `{id}`"))
            .collect::<Vec<_>>()
            .join("\n");
        parts.push(format!(
            "Removed {} worktree(s):\n{list}",
            report.removed.len()
        ));
    }

    if !report.failed.is_empty() {
        let list = report
            .failed
            .iter()
            .map(|(id, err)| format!("- `{id}`: {err}"))
            .collect::<Vec<_>>()
            .join("\n");
        parts.push(format!(
            "Failed to remove {} worktree(s):\n{list}",
            report.failed.len()
        ));
    }

    if parts.is_empty() {
        "Nothing to do.".to_string()
    } else {
        parts.join("\n\n")
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn init_git_repo(dir: &std::path::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()),
        }
    }

    async fn setup_project_with_real_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);

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

        (tmp, project_root)
    }

    // -- dry-run (confirm = false) -------------------------------------------

    #[tokio::test]
    async fn dry_run_lists_orphaned_without_removing() {
        let story_id = "200_done_story";
        let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await;
        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        assert!(wt_dir.exists());

        let config = empty_config();
        let report = run_cleanup_with_lookup(&project_root, &config, false, |id| {
            if id == story_id {
                Some(done_stage())
            } else {
                None
            }
        })
        .await;

        assert!(
            report.orphaned.contains(&story_id.to_string()),
            "orphaned list should include the done story"
        );
        assert!(
            report.removed.is_empty(),
            "dry run should not remove anything"
        );
        assert!(report.failed.is_empty());
        assert!(wt_dir.exists(), "worktree must still exist after dry run");
    }

    // -- confirmed removal ---------------------------------------------------

    #[tokio::test]
    async fn confirm_removes_orphaned_worktree() {
        let story_id = "201_purged_story";
        let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await;
        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        assert!(wt_dir.exists());

        let config = empty_config();
        // lookup returns None → story not in CRDT (purged)
        let report = run_cleanup_with_lookup(&project_root, &config, true, |_| None).await;

        assert!(
            report.orphaned.contains(&story_id.to_string()),
            "purged story should be in orphaned list"
        );
        assert!(
            report.removed.contains(&story_id.to_string()),
            "purged story worktree should be removed"
        );
        assert!(report.failed.is_empty());
        assert!(!wt_dir.exists(), "worktree directory should be gone");
    }

    // -- running story preserved ---------------------------------------------

    #[tokio::test]
    async fn confirm_preserves_running_story_worktree() {
        let story_id = "202_running_story";
        let (_tmp, project_root) = setup_project_with_real_worktree(story_id).await;
        let wt_dir = project_root
            .join(".huskies")
            .join("worktrees")
            .join(story_id);
        assert!(wt_dir.exists());

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

        assert!(
            report.orphaned.is_empty(),
            "coding story should not be orphaned"
        );
        assert!(
            report.removed.is_empty(),
            "coding story worktree must not be removed"
        );
        assert!(wt_dir.exists(), "worktree must still exist");
    }

    // -- format_report -------------------------------------------------------

    #[test]
    fn format_report_no_orphans() {
        let report = CleanupReport {
            orphaned: vec![],
            removed: vec![],
            failed: vec![],
        };
        let out = format_report(&report, false);
        assert!(out.contains("No orphaned"));
    }

    #[test]
    fn format_report_dry_run_lists_orphans() {
        let report = CleanupReport {
            orphaned: vec!["100_old_story".to_string()],
            removed: vec![],
            failed: vec![],
        };
        let out = format_report(&report, false);
        assert!(out.contains("100_old_story"));
        assert!(out.contains("--confirm"));
    }

    #[test]
    fn format_report_confirm_lists_removed() {
        let report = CleanupReport {
            orphaned: vec!["101_removed".to_string()],
            removed: vec!["101_removed".to_string()],
            failed: vec![],
        };
        let out = format_report(&report, true);
        assert!(out.contains("Removed"));
        assert!(out.contains("101_removed"));
    }
}
