//! Canonical work-item deletion — stops agents, removes the worktree, evicts
//! from the CRDT, and cleans up all database and filesystem traces.
//!
//! Both `chat::transport::matrix::delete` and
//! `http::mcp::story_tools::story::delete` delegate here so the full deletion
//! sequence is defined in exactly one place.

use crate::agents::AgentPool;
use crate::service::timer::TimerStore;
use crate::slog_warn;
use std::path::Path;

/// Outcome of a successful [`delete_work_item`] call.
///
/// `failed_steps` is non-empty when the item was found but some cleanup steps
/// could not be completed (e.g. worktree removal failed).  Callers decide
/// whether partial failures should be surfaced as errors.
#[derive(Debug)]
#[allow(dead_code)]
pub struct DeleteOutcome {
    /// `true` when a pending rate-limit retry timer was cancelled.
    pub timer_cancelled: bool,
    /// Display names of agents that were stopped during the delete.
    pub agents_stopped: Vec<String>,
    /// `true` when the item was found and removed from the database.
    pub found_in_db: bool,
    /// `true` when the filesystem shadow file was found and deleted.
    pub deleted_from_fs: bool,
    /// Descriptions of cleanup steps that failed (non-fatal individually).
    pub failed_steps: Vec<String>,
}

/// Delete a work item completely from the pipeline.
///
/// Performs the canonical deletion sequence:
/// 1. Cancel any pending rate-limit retry timer (when `timer_store` is `Some`).
/// 2. Stop all running or pending agents for the story.
/// 3. Remove the agent pool entries.
/// 4. Remove the git worktree (best-effort).
/// 5. Write a CRDT tombstone op so the deletion survives a restart.
/// 6. Delete from the database content store and shadow table.
/// 7. Remove the filesystem shadow file from `.huskies/work/N_stage/`.
///
/// Returns [`Err`] when the item was not found in any location (no DB entry,
/// no filesystem shadow, and no timer was cancelled).  Returns [`Ok`]
/// otherwise; inspect [`DeleteOutcome::failed_steps`] for partial failures.
pub async fn delete_work_item(
    story_id: &str,
    project_root: &Path,
    agents: &AgentPool,
    timer_store: Option<&TimerStore>,
) -> Result<DeleteOutcome, String> {
    let mut failed_steps: Vec<String> = Vec::new();

    // Pre-flight: check whether the item exists in any store before doing
    // destructive work.  We probe the content store, CRDT, and filesystem
    // shadow dirs so we can return a clear "not found" when nothing matches.
    let found_in_content =
        crate::db::read_content(crate::db::ContentKey::Story(story_id)).is_some();
    let found_in_crdt = crate::pipeline_state::read_typed(story_id)
        .ok()
        .flatten()
        .is_some();
    let shadow_root = project_root.join(".huskies").join("work");
    let stage_dirs = [
        "1_backlog",
        "2_current",
        "3_qa",
        "4_merge",
        "5_done",
        "6_archived",
    ];
    let found_on_fs = stage_dirs
        .iter()
        .any(|s| shadow_root.join(s).join(format!("{story_id}.md")).exists());

    // 0. Cancel any pending rate-limit retry timer (bug 514).
    //    Must happen before stopping agents so the tick loop cannot re-spawn
    //    an agent after we tear everything else down.
    let timer_cancelled = if let Some(ts) = timer_store {
        let removed = ts.remove(story_id);
        if removed {
            slog_warn!("[delete_work_item] Cancelled pending timer for '{story_id}'");
        }
        removed
    } else {
        false
    };

    if !found_in_content && !found_in_crdt && !found_on_fs && !timer_cancelled {
        return Err(format!(
            "Story '{story_id}' not found in any pipeline stage."
        ));
    }

    // 1. Stop any running/pending agents (best-effort).
    let mut agents_stopped: Vec<String> = Vec::new();
    if let Ok(agent_list) = agents.list_agents().await {
        for agent in agent_list.iter().filter(|a| a.story_id == story_id) {
            match agents
                .stop_agent(project_root, story_id, &agent.agent_name)
                .await
            {
                Ok(()) => {
                    slog_warn!(
                        "[delete_work_item] Stopped agent '{}' for '{story_id}'",
                        agent.agent_name
                    );
                    agents_stopped.push(agent.agent_name.clone());
                }
                Err(e) => {
                    slog_warn!(
                        "[delete_work_item] Failed to stop agent '{}' for '{story_id}': {e}",
                        agent.agent_name
                    );
                    failed_steps.push(format!("stop_agent({}): {e}", agent.agent_name));
                }
            }
        }
    }

    // 2. Remove agent pool entries.
    let removed_count = agents.remove_agents_for_story(story_id).await;
    slog_warn!("[delete_work_item] Removed {removed_count} agent pool entries for '{story_id}'");

    // 3. Remove worktree (best-effort).
    if let Ok(config) = crate::config::ProjectConfig::load(project_root) {
        match crate::worktree::remove_worktree_by_story_id(project_root, story_id, &config).await {
            Ok(()) => slog_warn!("[delete_work_item] Removed worktree for '{story_id}'"),
            Err(e) => slog_warn!("[delete_work_item] Worktree removal for '{story_id}': {e}"),
        }
    }

    // 4. Write a CRDT tombstone op so the deletion is persisted to crdt_ops
    //    and survives a restart.  Legacy filesystem-only stories may not have
    //    a CRDT entry, so a "not found" error is expected and non-fatal.
    match crate::crdt_state::evict_item(story_id) {
        Ok(()) => {
            slog_warn!("[delete_work_item] Evicted '{story_id}' from CRDT (tombstone persisted)")
        }
        Err(e) => slog_warn!("[delete_work_item] CRDT eviction for '{story_id}': {e}"),
    }

    // 5. Delete from database content store and shadow table.
    // Use the synchronous variant so the pipeline_items row is gone before we
    // return — the fire-and-forget channel cannot guarantee the DELETE commits
    // before a restart, which leaves zombie rows (bug 1094).
    crate::db::delete_item_sync(story_id).await;
    slog_warn!("[delete_work_item] Deleted '{story_id}' from content store / shadow table");

    // 6. Remove the filesystem shadow file from work/N_stage/.
    let mut deleted_from_fs = false;
    for stage in &stage_dirs {
        let path = shadow_root.join(stage).join(format!("{story_id}.md"));
        if path.exists() {
            match std::fs::remove_file(&path) {
                Ok(()) => {
                    slog_warn!(
                        "[delete_work_item] Deleted filesystem shadow '{story_id}' from work/{stage}/"
                    );
                    deleted_from_fs = true;
                }
                Err(e) => {
                    slog_warn!(
                        "[delete_work_item] Failed to delete shadow '{story_id}' from work/{stage}/: {e}"
                    );
                    failed_steps.push(format!("delete_filesystem({stage}): {e}"));
                }
            }
            break;
        }
    }

    Ok(DeleteOutcome {
        timer_cancelled,
        agents_stopped,
        found_in_db: found_in_content || found_in_crdt,
        deleted_from_fs,
        failed_steps,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::sync::Arc;

    fn make_agents() -> Arc<AgentPool> {
        Arc::new(AgentPool::new_test(3000))
    }

    /// After calling [`delete_work_item`] the CRDT shows `is_deleted: true`,
    /// the content store has no entry, and the agent pool has no entries for
    /// the story.  This is the structural regression test called for by bug 874.
    #[tokio::test]
    async fn delete_work_item_clears_crdt_db_and_agents() {
        crate::crdt_state::init_for_test();
        let story_id = "8750_story_service_delete_regression";

        // Seed CRDT.
        crate::crdt_state::write_item_str(
            story_id,
            "1_backlog",
            Some("Service Delete Regression"),
            None,
            None,
            None,
        );

        // Seed content store.
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            story_id,
            "1_backlog",
            "---\nname: Service Delete Regression\n---\n",
            crate::db::ItemMeta::named("Service Delete Regression"),
        );

        let tmp = tempfile::tempdir().unwrap();
        let agents = make_agents();

        let result = delete_work_item(story_id, tmp.path(), &agents, None).await;
        assert!(result.is_ok(), "expected Ok: {result:?}");

        // CRDT must show is_deleted = true.
        let dump = crate::crdt_state::dump_crdt_state(Some(story_id));
        let is_deleted = dump
            .items
            .iter()
            .any(|i| i.story_id.as_deref() == Some(story_id) && i.is_deleted);
        assert!(is_deleted, "CRDT must show is_deleted=true after delete");

        // Content store must be empty.
        assert!(
            crate::db::read_content(crate::db::ContentKey::Story(story_id)).is_none(),
            "content store must not contain the story after delete"
        );

        // Agent pool must have no entries for this story.
        let pool_entries = agents
            .list_agents()
            .await
            .unwrap_or_default()
            .into_iter()
            .filter(|a| a.story_id == story_id)
            .count();
        assert_eq!(
            pool_entries, 0,
            "agent pool must have no entries for the deleted story"
        );
    }

    #[tokio::test]
    async fn delete_work_item_not_found_returns_err() {
        let tmp = tempfile::tempdir().unwrap();
        let agents = make_agents();
        let result = delete_work_item("99_nonexistent", tmp.path(), &agents, None).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("not found"));
    }

    #[tokio::test]
    async fn delete_work_item_removes_filesystem_shadow() {
        let tmp = tempfile::tempdir().unwrap();
        let backlog = tmp.path().join(".huskies/work/1_backlog");
        fs::create_dir_all(&backlog).unwrap();
        let story_file = backlog.join("8751_story_fs_delete.md");
        fs::write(&story_file, "---\nname: FS Delete\n---\n").unwrap();

        let agents = make_agents();
        let result = delete_work_item("8751_story_fs_delete", tmp.path(), &agents, None).await;
        assert!(result.is_ok(), "expected Ok: {result:?}");
        assert!(!story_file.exists(), "shadow file must be deleted");
        let outcome = result.unwrap();
        assert!(outcome.deleted_from_fs, "deleted_from_fs must be true");
    }

    #[tokio::test]
    async fn delete_work_item_cancels_timer() {
        use crate::service::timer::TimerStore;
        use chrono::Utc;

        let tmp = tempfile::tempdir().unwrap();
        let backlog = tmp.path().join(".huskies/work/1_backlog");
        fs::create_dir_all(&backlog).unwrap();
        fs::write(
            backlog.join("8752_story_timer.md"),
            "---\nname: Timer\n---\n",
        )
        .unwrap();

        let timer_store = TimerStore::load(tmp.path().join(".huskies/timers.json"));
        let future_time = Utc::now() + chrono::Duration::minutes(5);
        timer_store
            .add("8752_story_timer".to_string(), future_time)
            .unwrap();

        let agents = make_agents();
        let result =
            delete_work_item("8752_story_timer", tmp.path(), &agents, Some(&timer_store)).await;
        assert!(result.is_ok(), "expected Ok: {result:?}");
        let outcome = result.unwrap();
        assert!(outcome.timer_cancelled, "timer_cancelled must be true");
        assert!(
            timer_store.list().is_empty(),
            "timer store must be empty after delete"
        );
    }
}
