//! Workspace management with Git Worktree and Docker isolation
//!
//! Provides repository isolation primitives:
//! - git worktree status and provisioning
//! - protected-branch safeguards
//! - optional containerized execution for reproducible builds

use crate::core::container_runtime;
use crate::core::db;
use crate::core::error::DecapodError;
use crate::core::rpc::{AllowedOp, Blocker, BlockerKind};
use crate::core::todo;
use crate::core::workunit::{self, WorkUnitStatus};
use crate::plugins::eval;
use fancy_regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;

/// Workspace status information
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkspaceStatus {
    /// Whether workspace is valid for work
    pub can_work: bool,
    /// Git workspace context
    pub git: GitStatus,
    /// Docker container context
    pub container: ContainerStatus,
    /// Blockers preventing work
    pub blockers: Vec<Blocker>,
    /// Required actions before working
    pub required_actions: Vec<String>,
}

/// Git status
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitStatus {
    /// Current branch name
    pub current_branch: String,
    /// Whether branch is protected
    pub is_protected: bool,
    /// Whether in git worktree
    pub in_worktree: bool,
    /// Worktree path (if in worktree)
    pub worktree_path: Option<PathBuf>,
    /// Whether this is the main repository checkout
    pub is_main_repo: bool,
    /// Has local modifications
    pub has_local_mods: bool,
}

/// Container/Docker status
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ContainerStatus {
    /// Whether running inside a Docker container
    pub in_container: bool,
    /// Container ID (if in container)
    pub container_id: Option<String>,
    /// Container image name
    pub image: Option<String>,
    /// Whether Docker is available on host
    pub docker_available: bool,
}

/// Workspace configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkspaceConfig {
    /// Git branch name
    pub branch: Option<String>,
    /// Whether to use container
    pub use_container: bool,
    /// Base image for container (if use_container is true)
    pub base_image: Option<String>,
}

#[derive(Debug, Clone)]
struct AssignedTodoRef {
    id: String,
    hash: String,
}

/// Protected branch patterns
const PROTECTED_PATTERNS: &[&str] = &[
    "main",
    "master",
    "production",
    "stable",
    "release/*",
    "hotfix/*",
];

/// Prune stale git worktree metadata and remove stale worktree sections from .git/config.
///
/// This is a best-effort maintenance operation to keep worktree state healthy after
/// merged PRs, deleted branches, or manually removed worktree directories.
/// Returns the number of stale `worktree.<name>` sections removed from `.git/config`.
pub fn prune_stale_worktree_config(repo_root: &Path) -> Result<usize, DecapodError> {
    let main_repo = get_main_repo_root(repo_root)?;
    let dir = main_repo.to_str().unwrap_or(".");

    // 1) Let git clean known stale admin entries first.
    let prune_output = Command::new("git")
        .args(["-C", dir, "worktree", "prune", "--expire", "now"])
        .output()
        .map_err(DecapodError::IoError)?;
    if !prune_output.status.success() {
        return Err(DecapodError::ValidationError(format!(
            "Failed to prune worktrees: {}",
            String::from_utf8_lossy(&prune_output.stderr)
        )));
    }

    let config_path = main_repo.join(".git").join("config");
    if !config_path.exists() {
        return Ok(0);
    }

    let registered_paths = registered_worktree_paths(&main_repo)?;
    let keys_output = Command::new("git")
        .args([
            "-C",
            dir,
            "config",
            "--file",
            config_path.to_str().unwrap_or(".git/config"),
            "--name-only",
            "--get-regexp",
            r"^worktree\..*\.path$",
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    // No worktree sections to process.
    if !keys_output.status.success() && keys_output.stdout.is_empty() {
        return Ok(0);
    }

    let mut removed = 0usize;
    for key in String::from_utf8_lossy(&keys_output.stdout)
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
    {
        let Some(section_name) = key.strip_suffix(".path") else {
            continue;
        };
        let value_output = Command::new("git")
            .args([
                "-C",
                dir,
                "config",
                "--file",
                config_path.to_str().unwrap_or(".git/config"),
                "--get",
                key,
            ])
            .output()
            .map_err(DecapodError::IoError)?;
        if !value_output.status.success() {
            continue;
        }
        let raw_path = String::from_utf8_lossy(&value_output.stdout)
            .trim()
            .to_string();
        if raw_path.is_empty() {
            continue;
        }
        let candidate = resolve_worktree_candidate_path(&main_repo, &raw_path);
        let normalized = normalize_path_for_compare(&candidate);
        let is_stale = !candidate.exists() || !registered_paths.contains(&normalized);
        if !is_stale {
            continue;
        }

        let remove_output = Command::new("git")
            .args([
                "-C",
                dir,
                "config",
                "--file",
                config_path.to_str().unwrap_or(".git/config"),
                "--remove-section",
                section_name,
            ])
            .output()
            .map_err(DecapodError::IoError)?;
        if remove_output.status.success() {
            removed += 1;
        }
    }

    Ok(removed)
}

/// Get workspace status
pub fn get_workspace_status(repo_root: &Path) -> Result<WorkspaceStatus, DecapodError> {
    let git = check_git_status(repo_root)?;
    let container = check_container_status(repo_root)?;

    let mut blockers = vec![];
    let mut required_actions = vec![];

    // Mandate: Must not work on protected branch
    if git.is_protected {
        blockers.push(Blocker {
            kind: BlockerKind::ProtectedBranch,
            message: format!("Currently on protected branch '{}'. Decapod prohibits implementation work on protected refs.", git.current_branch),
            resolve_hint: "Run `decapod todo claim --id <task-id>` then `decapod workspace ensure` to create a todo-scoped isolated worktree.".to_string(),
        });
        required_actions
            .push("Run `decapod workspace ensure` and cd into the created worktree".to_string());
        if git.has_local_mods {
            blockers.push(Blocker {
                kind: BlockerKind::WorkspaceRequired,
                message: "Protected branch has local modifications. Creating an isolated worktree from a dirty protected branch is blocked.".to_string(),
                resolve_hint: "Commit/stash/discard local changes on protected branch, then run `decapod workspace ensure`.".to_string(),
            });
            required_actions.push("Commit/stash/discard local modifications".to_string());
        }
    }

    // Mandate: Should use worktree for isolation
    if git.is_main_repo && !git.is_protected {
        blockers.push(Blocker {
            kind: BlockerKind::WorkspaceRequired,
            message: "Currently in the main repository checkout. Agentic work MUST be done in an isolated worktree to prevent disrupting the human user's environment.".to_string(),
            resolve_hint: "Run `decapod workspace ensure` and cd into the created worktree.".to_string(),
        });
        required_actions
            .push("Run `decapod workspace ensure` and cd into the created worktree".to_string());
    }

    let can_work = !git.is_main_repo && !git.is_protected;

    Ok(WorkspaceStatus {
        can_work,
        git,
        container,
        blockers,
        required_actions,
    })
}

fn check_git_status(repo_root: &Path) -> Result<GitStatus, DecapodError> {
    if !repo_root.join(".git").exists() {
        return Ok(GitStatus {
            current_branch: "none".to_string(),
            is_protected: false,
            in_worktree: false,
            worktree_path: None,
            is_main_repo: false,
            has_local_mods: false,
        });
    }

    let current_branch = get_current_branch(repo_root)?;
    let is_protected = is_branch_protected(&current_branch);
    let in_worktree = is_worktree(repo_root)?;
    let has_local_mods = has_local_modifications(repo_root)?;

    // Check if this is the main repository checkout (not an isolated workspace)
    let is_main_repo = !repo_root.to_string_lossy().contains(".decapod/workspaces");

    Ok(GitStatus {
        current_branch,
        is_protected,
        in_worktree,
        worktree_path: if in_worktree {
            Some(repo_root.to_path_buf())
        } else {
            None
        },
        is_main_repo,
        has_local_mods,
    })
}

fn check_container_status(_repo_root: &Path) -> Result<ContainerStatus, DecapodError> {
    let in_container = Path::new("/.dockerenv").exists() || std::env::var("CONTAINER_ID").is_ok();

    let container_id = if in_container {
        std::fs::read_to_string("/etc/hostname")
            .ok()
            .map(|s| s.trim().to_string())
    } else {
        None
    };

    let docker_available = container_runtime::container_runtime_available();

    Ok(ContainerStatus {
        in_container,
        container_id,
        image: std::env::var("DECAPOD_WORKSPACE_IMAGE").ok(),
        docker_available,
    })
}

fn external_task_ref() -> String {
    [
        "DECAPOD_TASK_ID",
        "DECAPOD_EXTERNAL_TASK_ID",
        "BD_TASK_ID",
        "BEADS_TASK_ID",
    ]
    .iter()
    .find_map(|key| {
        std::env::var(key)
            .ok()
            .filter(|value| !value.trim().is_empty())
    })
    .unwrap_or_default()
}

fn create_and_claim_coordination_todo(
    repo_root: &Path,
    agent_id: &str,
) -> Result<AssignedTodoRef, DecapodError> {
    let main_repo = get_main_repo_root(repo_root)?;
    let store_root = main_repo.join(".decapod").join("data");
    let external_ref = external_task_ref();
    if !external_ref.is_empty() {
        let tasks = todo::list_tasks(
            &store_root,
            Some("open".to_string()),
            None,
            None,
            None,
            None,
        )?;
        if let Some(task) = tasks.into_iter().find(|task| task.r#ref == external_ref) {
            let claim =
                todo::claim_task(&store_root, &task.id, agent_id, todo::ClaimMode::Exclusive)?;
            if claim.get("status").and_then(|value| value.as_str()) != Some("ok") {
                return Err(DecapodError::ValidationError(format!(
                    "AUTOREMEDIABLE_VALIDATION_ERROR code=WORKSPACE_TODO_CLAIM_CONFLICT severity=transient auto_remediable=true audience=agent agent_action=\"inspect `decapod todo list`; Decapod already captured external task {external_ref} as a coordination todo, so coordinate with the current claimant or wait for release before launching another workspace\" user_note=\"Decapod is protecting this external task with an exclusive todo claim; no work is lost, but another agent already owns the isolated workspace slot.\"\n{claim}"
                )));
            }
            return Ok(AssignedTodoRef {
                id: task.id,
                hash: task.hash,
            });
        }
    }
    let title = if external_ref.is_empty() {
        format!("Decapod workspace coordination for {agent_id}")
    } else {
        format!("Decapod workspace coordination for {external_ref}")
    };
    let description = if external_ref.is_empty() {
        "Auto-created by decapod workspace ensure so Decapod can enforce exclusive agent ownership while an external todo system may also be in use.".to_string()
    } else {
        format!(
            "Auto-created by decapod workspace ensure to coordinate exclusive Decapod ownership for external task {external_ref}."
        )
    };
    let command = todo::TodoCommand::Add {
        title,
        description,
        priority: "medium".to_string(),
        tags: "workspace,coordination,auto-generated".to_string(),
        owner: String::new(),
        due: None,
        r#ref: external_ref,
        scope: "workspace".to_string(),
        dir: Some(main_repo.to_string_lossy().to_string()),
        depends_on: String::new(),
        blocks: String::new(),
        parent: None,
        one_shot: 1,
    };
    let added = todo::add_task(&store_root, &command)?;
    let id = added
        .get("id")
        .and_then(|value| value.as_str())
        .ok_or_else(|| {
            DecapodError::ValidationError("workspace auto-created todo without id".to_string())
        })?
        .to_string();
    let hash = added
        .get("hash")
        .and_then(|value| value.as_str())
        .unwrap_or("")
        .to_string();
    let claim = todo::claim_task(&store_root, &id, agent_id, todo::ClaimMode::Exclusive)?;
    if claim.get("status").and_then(|value| value.as_str()) != Some("ok") {
        return Err(DecapodError::ValidationError(format!(
            "AUTOREMEDIABLE_VALIDATION_ERROR code=WORKSPACE_TODO_CLAIM_CONFLICT severity=transient auto_remediable=true audience=agent agent_action=\"inspect `decapod todo list`; Decapod created a workspace coordination todo and is waiting for an exclusive claim before container launch continues\" user_note=\"Decapod has captured the workspace intent as a todo; resolve the claim conflict and rerun the command.\"\n{claim}"
        )));
    }
    Ok(AssignedTodoRef { id, hash })
}

fn ensure_assigned_open_tasks(
    repo_root: &Path,
    agent_id: &str,
    current_branch: &str,
) -> Result<Vec<AssignedTodoRef>, DecapodError> {
    let mut assigned_todos = get_assigned_open_tasks(repo_root, agent_id)?;
    claim_branch_scoped_open_tasks(repo_root, agent_id, current_branch, &mut assigned_todos)?;
    if assigned_todos.is_empty() {
        assigned_todos.push(create_and_claim_coordination_todo(repo_root, agent_id)?);
    }
    assigned_todos.sort_by(|a, b| a.id.cmp(&b.id));
    assigned_todos.dedup_by(|a, b| a.id == b.id);
    Ok(assigned_todos)
}

fn claim_branch_scoped_open_tasks(
    repo_root: &Path,
    agent_id: &str,
    current_branch: &str,
    assigned_todos: &mut Vec<AssignedTodoRef>,
) -> Result<(), DecapodError> {
    let main_repo = get_main_repo_root(repo_root)?;
    let store_root = main_repo.join(".decapod").join("data");
    let tasks = todo::list_tasks(
        &store_root,
        Some("open".to_string()),
        None,
        None,
        None,
        None,
    )?;
    for task in tasks {
        let todo_ref = AssignedTodoRef {
            id: task.id.clone(),
            hash: task.hash.clone(),
        };
        if !branch_contains_any_todo_id_or_hash(current_branch, std::slice::from_ref(&todo_ref)) {
            continue;
        }
        if task.assigned_to == agent_id {
            assigned_todos.push(todo_ref);
            continue;
        }
        if !task.assigned_to.is_empty() {
            return Err(DecapodError::ValidationError(format!(
                "AUTOREMEDIABLE_VALIDATION_ERROR code=WORKSPACE_BRANCH_TODO_CLAIM_CONFLICT severity=transient auto_remediable=true audience=agent agent_action=\"switch to the agent that owns todo {} or choose a different todo-scoped workspace; Decapod is preventing cross-work while preserving the captured todo\" user_note=\"This branch already belongs to another agent's Decapod todo claim; use the owner or a different todo-scoped workspace.\"\nBranch '{}' is scoped to todo {} but it is already claimed by {}.",
                task.id, current_branch, task.id, task.assigned_to
            )));
        }
        let claim = todo::claim_task(&store_root, &task.id, agent_id, todo::ClaimMode::Exclusive)?;
        if claim.get("status").and_then(|value| value.as_str()) != Some("ok") {
            return Err(DecapodError::ValidationError(format!(
                "AUTOREMEDIABLE_VALIDATION_ERROR code=WORKSPACE_BRANCH_TODO_CLAIM_CONFLICT severity=transient auto_remediable=true audience=agent agent_action=\"inspect `decapod todo list`; Decapod found the branch-scoped todo {} and needs its exclusive claim before container launch continues\" user_note=\"The branch todo is captured; resolve the claim conflict and rerun the workspace command.\"\n{}",
                task.id, claim
            )));
        }
        assigned_todos.push(todo_ref);
    }
    Ok(())
}

/// Ensure/create isolated workspace
pub fn ensure_workspace(
    repo_root: &Path,
    config: Option<WorkspaceConfig>,
    agent_id: &str,
) -> Result<WorkspaceStatus, DecapodError> {
    let main_repo = get_main_repo_root(repo_root)?;
    let store_root = main_repo.join(".decapod").join("data");
    db::storage_health_preflight(&store_root).map_err(|e| {
        DecapodError::ValidationError(format!(
            "AUTOREMEDIABLE_VALIDATION_ERROR code=WORKSPACE_STORAGE_PREFLIGHT_FAILED severity=transient auto_remediable=true audience=agent agent_action=\"verify .decapod/data directory is accessible and has correct permissions; if storage is full, free up space or use a different store root\" user_note=\"Workspace storage preflight failed; the agent should verify storage health or report the concrete blocker.\"\n{e}"
        ))
    })?;

    let mut status = get_workspace_status(repo_root)?;
    if status.git.is_protected && status.git.has_local_mods && !status.git.in_worktree {
        return Err(DecapodError::ValidationError(
            "AUTOREMEDIABLE_VALIDATION_ERROR code=WORKSPACE_INTERLOCK_DIRTY_PROTECTED severity=transient auto_remediable=true audience=agent agent_action=\"commit, stash, or discard local changes on the protected branch, then retry workspace creation\" user_note=\"Protected branch has local modifications; the agent should resolve this before creating an isolated worktree.\"\nprotected branch has local modifications. Agent must commit, stash, or discard changes before creating a Decapod worktree.".to_string(),
        ));
    }
    let upgrade_container = config.as_ref().map(|c| c.use_container).unwrap_or(false);
    let assigned_todos =
        ensure_assigned_open_tasks(repo_root, agent_id, &status.git.current_branch)?;

    // If we're already in a valid worktree, on todo-scoped branch, and no upgrade needed, we're good.
    // Relaxation for Issue #586: Allow non-scoped branches in worktrees if using external tracker
    // or if the project has explicitly opted into external tracker compatibility in config.toml.
    let allow_unscoped = !external_task_ref().is_empty() || is_external_tracker_config(repo_root);

    if status.git.in_worktree
        && !assigned_todos.is_empty()
        && !branch_contains_any_todo_id_or_hash(&status.git.current_branch, &assigned_todos)
        && !allow_unscoped
    {
        return Err(DecapodError::ValidationError(format!(
            "AUTOREMEDIABLE_VALIDATION_ERROR code=WORKSPACE_BRANCH_NOT_TODO_SCOPED severity=transient auto_remediable=true audience=agent agent_action=\"switch to a branch that includes one of the assigned todo IDs or hashes: {}\" user_note=\"Current branch is not todo-scoped; the agent should switch to a properly scoped branch or create one.\"\nCurrent worktree branch '{}' is not todo-scoped. Branch must include one of assigned todo IDs or hashes: {}.",
            render_todo_refs(&assigned_todos),
            status.git.current_branch,
            render_todo_refs(&assigned_todos)
        )));
    }

    if status.can_work
        && status.git.in_worktree
        && !status.git.is_protected
        && (!upgrade_container || status.container.in_container)
    {
        return Ok(status);
    }

    let todo_scope = build_todo_scope_component(&assigned_todos);
    let config = if let Some(cfg) = config {
        if let Some(branch) = cfg.branch.as_ref()
            && !branch_contains_any_todo_id_or_hash(branch, &assigned_todos)
        {
            return Err(DecapodError::ValidationError(format!(
                "Requested branch '{}' must include an assigned todo ID/hash (one of: {}).",
                branch,
                render_todo_refs(&assigned_todos)
            )));
        }
        let branch = cfg.branch.unwrap_or_else(|| {
            let ts = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs();
            format!(
                "agent/{}/{}-{}",
                sanitize_agent_id(agent_id),
                todo_scope,
                ts
            )
        });
        WorkspaceConfig {
            branch: Some(branch),
            use_container: cfg.use_container,
            base_image: cfg.base_image,
        }
    } else {
        let ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        WorkspaceConfig {
            branch: Some(format!(
                "agent/{}/{}-{}",
                sanitize_agent_id(agent_id),
                todo_scope,
                ts
            )),
            use_container: false,
            base_image: None,
        }
    };
    let branch = config.branch.as_deref().ok_or_else(|| {
        DecapodError::ValidationError("workspace branch resolution failed".to_string())
    })?;

    // 1. Ensure git worktree
    let worktree_path = if status.git.in_worktree {
        repo_root.to_path_buf()
    } else {
        create_worktree(repo_root, branch, agent_id, &todo_scope)?
    };

    // 2. Ensure container (if requested)
    if config.use_container {
        ensure_dockerfile(&worktree_path)?;
        let image_tag = format!(
            "localhost/decapod-workspace:{}-{}",
            sanitize_agent_id(agent_id),
            branch.replace('/', "-")
        );
        build_workspace_image(&worktree_path, &image_tag)?;

        // Return blocker telling agent to enter container
        // We re-read status but override the blocker/container info
        let runtime = container_runtime::find_container_runtime()?;
        status = get_workspace_status(&worktree_path)?;
        status.blockers.push(Blocker {
            kind: BlockerKind::WorkspaceRequired,
            message: "Container environment prepared.".to_string(),
            resolve_hint: format!(
                "{} run -it -e DECAPOD_CONTAINER=1 -v {main_repo}:{main_repo} -w {} {} bash",
                runtime,
                worktree_path.display(),
                image_tag,
                main_repo = main_repo.display(),
            ),
        });
        status
            .required_actions
            .push("Enter containerized workspace".to_string());
        return Ok(status);
    }

    // Re-check status in the new worktree
    get_workspace_status(&worktree_path)
}

fn create_worktree(
    repo_root: &Path,
    branch: &str,
    agent_id: &str,
    todo_scope: &str,
) -> Result<PathBuf, DecapodError> {
    let main_repo = get_main_repo_root(repo_root)?;
    let workspaces_dir = main_repo.join(".decapod").join("workspaces");
    std::fs::create_dir_all(&workspaces_dir).map_err(DecapodError::IoError)?;

    let worktree_name = format!(
        "{}-{}-{}",
        sanitize_agent_id(agent_id),
        todo_scope,
        branch.replace('/', "-")
    );
    let worktree_path = workspaces_dir.join(&worktree_name);

    if worktree_path.exists() {
        return Ok(worktree_path);
    }

    // git worktree add <path> -b <branch>
    let output = Command::new("git")
        .args([
            "-C",
            main_repo.to_str().unwrap_or("."),
            "worktree",
            "add",
            "-b",
            branch,
            worktree_path.to_str().unwrap_or("."),
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    if !output.status.success() {
        // Fallback: try adding without -b if branch might exist
        let output2 = Command::new("git")
            .args([
                "-C",
                main_repo.to_str().unwrap_or("."),
                "worktree",
                "add",
                worktree_path.to_str().unwrap_or("."),
                branch,
            ])
            .output()
            .map_err(DecapodError::IoError)?;

        if !output2.status.success() {
            let stderr = String::from_utf8_lossy(&output2.stderr);
            return Err(DecapodError::ValidationError(format!(
                "Failed to create worktree: {stderr}"
            )));
        }
    }

    Ok(worktree_path)
}

fn registered_worktree_paths(main_repo: &Path) -> Result<HashSet<String>, DecapodError> {
    let output = Command::new("git")
        .args([
            "-C",
            main_repo.to_str().unwrap_or("."),
            "worktree",
            "list",
            "--porcelain",
        ])
        .output()
        .map_err(DecapodError::IoError)?;
    if !output.status.success() {
        return Err(DecapodError::ValidationError(format!(
            "Failed to list git worktrees: {}",
            String::from_utf8_lossy(&output.stderr)
        )));
    }

    let mut out = HashSet::new();
    for line in String::from_utf8_lossy(&output.stdout).lines() {
        let Some(path) = line.strip_prefix("worktree ") else {
            continue;
        };
        out.insert(normalize_path_for_compare(Path::new(path.trim())));
    }
    Ok(out)
}

fn resolve_worktree_candidate_path(main_repo: &Path, raw: &str) -> PathBuf {
    let p = PathBuf::from(raw);
    if p.is_absolute() {
        p
    } else {
        main_repo.join(p)
    }
}

fn normalize_path_for_compare(path: &Path) -> String {
    std::fs::canonicalize(path)
        .unwrap_or_else(|_| path.to_path_buf())
        .to_string_lossy()
        .to_string()
}

/// Ensure Dockerfile exists in workspace
fn ensure_dockerfile(workspace_path: &Path) -> Result<(), DecapodError> {
    let generated_dir = workspace_path.join(".decapod").join("generated");
    std::fs::create_dir_all(&generated_dir).map_err(DecapodError::IoError)?;
    let dockerfile_path = generated_dir.join("Dockerfile");

    // Dynamically generate and maintain the Dockerfile over time
    let dockerfile_content =
        crate::plugins::container::generated_dockerfile_for_repo(workspace_path);
    std::fs::write(&dockerfile_path, dockerfile_content).map_err(DecapodError::IoError)?;

    Ok(())
}

/// Build workspace container image
fn build_workspace_image(workspace_path: &Path, image_tag: &str) -> Result<(), DecapodError> {
    let runtime = container_runtime::find_container_runtime()?;
    let dockerfile_path = workspace_path
        .join(".decapod")
        .join("generated")
        .join("Dockerfile");
    let output = Command::new(runtime)
        .args([
            "build",
            "-t",
            image_tag,
            "-f",
            dockerfile_path.to_str().unwrap_or("Dockerfile"),
            workspace_path.to_str().unwrap_or("."),
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(DecapodError::ValidationError(format!(
            "Failed to build container image: {stderr}"
        )));
    }

    Ok(())
}

pub fn get_main_repo_root(current_dir: &Path) -> Result<PathBuf, DecapodError> {
    let output = Command::new("git")
        .args([
            "-C",
            current_dir.to_str().unwrap_or("."),
            "rev-parse",
            "--git-common-dir",
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    if !output.status.success() {
        // Not in a worktree, return current toplevel
        return get_repo_root(current_dir);
    }

    let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();

    // Canonicalize to handle relative paths from git
    let common_path = if Path::new(&common_dir).is_absolute() {
        PathBuf::from(common_dir)
    } else {
        current_dir.join(common_dir)
    };

    let common_path = std::fs::canonicalize(&common_path).unwrap_or(common_path);

    // If common_path ends in .git, the root is its parent
    if common_path.file_name().and_then(|n| n.to_str()) == Some(".git") {
        return Ok(common_path.parent().unwrap_or(&common_path).to_path_buf());
    }

    Ok(common_path)
}

/// Discover the Decapod repository root by searching upwards from a directory.
///
/// If `start_dir` is None, it starts from the current working directory.
pub fn discover_repo_root(start_dir: Option<&Path>) -> Result<PathBuf, DecapodError> {
    let start = match start_dir {
        Some(p) => p.to_path_buf(),
        None => std::env::current_dir()?,
    };
    get_repo_root(&start)
}

fn get_repo_root(start_dir: &Path) -> Result<PathBuf, DecapodError> {
    let output = Command::new("git")
        .args([
            "-C",
            start_dir.to_str().unwrap_or("."),
            "rev-parse",
            "--show-toplevel",
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    if !output.status.success() {
        return Err(DecapodError::ValidationError(
            "Not in a git repository".to_string(),
        ));
    }

    Ok(PathBuf::from(
        String::from_utf8_lossy(&output.stdout).trim(),
    ))
}

fn is_branch_protected(branch: &str) -> bool {
    let branch_lower = branch.to_lowercase();
    for pattern in PROTECTED_PATTERNS {
        if let Some(prefix) = pattern.strip_suffix("/*") {
            if branch_lower.starts_with(prefix) {
                return true;
            }
        } else if branch_lower == *pattern {
            return true;
        }
    }
    false
}

fn clean_branch_name(name: &str) -> &str {
    let mut s = name.trim();
    if s.starts_with('*') {
        s = s[1..].trim();
    }
    if let Some(suffix) = s.strip_prefix("remotes/origin/") {
        s = suffix;
    } else if let Some(suffix) = s.strip_prefix("remotes/") {
        s = suffix;
    } else if let Some(suffix) = s.strip_prefix("origin/") {
        s = suffix;
    }
    s
}

fn is_commit_in_protected_branch(main_repo: &Path, commit_hash: &str) -> bool {
    let output = Command::new("git")
        .args([
            "-C",
            main_repo.to_str().unwrap_or("."),
            "branch",
            "-a",
            "--contains",
            commit_hash,
        ])
        .output();

    let Ok(output) = output else {
        return false;
    };
    if !output.status.success() {
        return false;
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    for line in stdout.lines() {
        let clean = clean_branch_name(line);
        if is_branch_protected(clean) {
            return true;
        }
    }
    false
}

fn is_external_tracker_config(repo_root: &Path) -> bool {
    let main_repo = get_main_repo_root(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
    let config_path = main_repo.join(".decapod").join("config.toml");
    if !config_path.exists() {
        return false;
    }
    match std::fs::read_to_string(config_path) {
        Ok(content) => content.contains("external_tracker = true"),
        Err(_) => false,
    }
}

fn get_current_branch(repo_root: &Path) -> Result<String, DecapodError> {
    let output = Command::new("git")
        .args([
            "-C",
            repo_root.to_str().unwrap_or("."),
            "branch",
            "--show-current",
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if branch.is_empty() {
        // Fallback for detached HEAD
        let output = Command::new("git")
            .args([
                "-C",
                repo_root.to_str().unwrap_or("."),
                "rev-parse",
                "--short",
                "HEAD",
            ])
            .output()
            .map_err(DecapodError::IoError)?;
        return Ok(format!(
            "detached-{}",
            String::from_utf8_lossy(&output.stdout).trim()
        ));
    }
    Ok(branch)
}

pub fn is_worktree(repo_root: &Path) -> Result<bool, DecapodError> {
    let output = Command::new("git")
        .args([
            "-C",
            repo_root.to_str().unwrap_or("."),
            "rev-parse",
            "--git-dir",
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
    // In a worktree, git-dir is usually <main-repo>/.git/worktrees/<name>
    // A local-clone workspace under .decapod/workspaces is also treated as a worktree context
    Ok(git_dir.contains("/worktrees/")
        || repo_root.to_string_lossy().contains(".decapod/workspaces"))
}

fn has_local_modifications(repo_root: &Path) -> Result<bool, DecapodError> {
    let output = Command::new("git")
        .args([
            "-C",
            repo_root.to_str().unwrap_or("."),
            "status",
            "--porcelain",
            "-z",
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut saw_non_ignorable = false;
    for entry in stdout.split('\0').filter(|entry| !entry.is_empty()) {
        if entry.len() < 4 {
            continue;
        }
        let path = &entry[3..];
        if path == ".decapod/OVERRIDE.md" {
            continue;
        }
        saw_non_ignorable = true;
        break;
    }

    Ok(saw_non_ignorable)
}

fn sanitize_agent_id(agent_id: &str) -> String {
    agent_id
        .to_lowercase()
        .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
        .replace("--", "-")
        .trim_matches('-')
        .to_string()
}

fn sanitize_todo_component(todo_id: &str) -> String {
    todo_id
        .to_lowercase()
        .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
        .replace("--", "-")
        .trim_matches('-')
        .to_string()
}

fn build_todo_scope_component(todo_refs: &[AssignedTodoRef]) -> String {
    if todo_refs.is_empty() {
        return "todo-unassigned".to_string();
    }
    let head = sanitize_todo_component(&todo_refs[0].hash);
    if todo_refs.len() == 1 {
        return format!("todo-{head}");
    }
    format!("todo-{}-plus-{}", head, todo_refs.len() - 1)
}

fn branch_contains_any_todo_id_or_hash(branch: &str, todo_refs: &[AssignedTodoRef]) -> bool {
    let branch_lower = branch.to_lowercase();
    todo_refs.iter().any(|todo| {
        let id = &todo.id;
        let id_lower = id.to_lowercase();
        let id_sanitized = sanitize_todo_component(id);
        let hash_lower = todo.hash.to_lowercase();
        branch_lower.contains(&id_lower)
            || branch_lower.contains(&id_sanitized)
            || branch_lower.contains(&hash_lower)
    })
}

fn get_assigned_open_tasks(
    repo_root: &Path,
    agent_id: &str,
) -> Result<Vec<AssignedTodoRef>, DecapodError> {
    let main_repo = get_main_repo_root(repo_root)?;
    let store_root = main_repo.join(".decapod").join("data");
    let mut tasks = todo::list_tasks(
        &store_root,
        Some("open".to_string()),
        None,
        None,
        None,
        None,
    )?;
    tasks.retain(|t| t.assigned_to == agent_id);
    let mut refs: Vec<AssignedTodoRef> = tasks
        .into_iter()
        .map(|t| AssignedTodoRef {
            id: t.id,
            hash: t.hash,
        })
        .collect();
    refs.sort_by(|a, b| a.id.cmp(&b.id));
    refs.dedup_by(|a, b| a.id == b.id);
    Ok(refs)
}

fn render_todo_refs(todo_refs: &[AssignedTodoRef]) -> String {
    todo_refs
        .iter()
        .map(|t| format!("{} ({})", t.id, t.hash))
        .collect::<Vec<_>>()
        .join(", ")
}

/// Result from publishing a workspace
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PublishResult {
    /// Branch that was published
    pub branch: String,
    /// Commit hash of the published changes
    pub commit_hash: String,
    /// Remote URL the branch was pushed to
    pub remote_url: String,
    /// PR URL if one was created
    pub pr_url: Option<String>,
}

/// Publish workspace changes: commit, push, and optionally create a PR
pub fn publish_workspace(
    repo_root: &Path,
    title: Option<String>,
    description: Option<String>,
) -> Result<PublishResult, DecapodError> {
    let status = get_workspace_status(repo_root)?;

    // 1. Must be in a worktree on an unprotected branch
    if !status.git.in_worktree {
        return Err(DecapodError::ValidationError(
            "Cannot publish: not in a git worktree. Run `decapod workspace ensure` first."
                .to_string(),
        ));
    }
    if status.git.is_protected {
        return Err(DecapodError::ValidationError(format!(
            "Cannot publish: on protected branch '{}'. Work must be on a feature branch.",
            status.git.current_branch
        )));
    }
    let artifact_manifest =
        repo_root.join(".decapod/generated/artifacts/provenance/artifact_manifest.json");
    let proof_manifest =
        repo_root.join(".decapod/generated/artifacts/provenance/proof_manifest.json");
    if !artifact_manifest.exists() || !proof_manifest.exists() {
        return Err(DecapodError::ValidationError(
            "Cannot publish: provenance manifests are required for promotion. Missing `.decapod/generated/artifacts/provenance/artifact_manifest.json` and/or `.decapod/generated/artifacts/provenance/proof_manifest.json`."
                .to_string(),
        ));
    }
    verify_workunit_gate_for_publish(repo_root, &status.git.current_branch)?;
    eval::verify_eval_gate_for_publish(&repo_root.join(".decapod").join("data"))?;

    let dir = repo_root.to_str().unwrap_or(".");

    // 2. Stage and commit any uncommitted changes
    if status.git.has_local_mods {
        let add_output = Command::new("git")
            .args(["-C", dir, "add", "-A"])
            .output()
            .map_err(DecapodError::IoError)?;
        if !add_output.status.success() {
            return Err(DecapodError::ValidationError(format!(
                "Failed to stage changes: {}",
                String::from_utf8_lossy(&add_output.stderr)
            )));
        }

        let commit_msg = title
            .as_deref()
            .unwrap_or("decapod: publish workspace changes");
        let commit_output = Command::new("git")
            .args(["-C", dir, "commit", "-m", commit_msg])
            .output()
            .map_err(DecapodError::IoError)?;
        if !commit_output.status.success() {
            let stderr = String::from_utf8_lossy(&commit_output.stderr);
            // Allow "nothing to commit" as non-fatal
            if !stderr.contains("nothing to commit") {
                return Err(DecapodError::ValidationError(format!(
                    "Failed to commit: {stderr}"
                )));
            }
        }
    }

    // Get current commit hash
    let hash_output = Command::new("git")
        .args(["-C", dir, "rev-parse", "HEAD"])
        .output()
        .map_err(DecapodError::IoError)?;
    let commit_hash = String::from_utf8_lossy(&hash_output.stdout)
        .trim()
        .to_string();

    // 3. Push branch to origin
    let push_output = Command::new("git")
        .args([
            "-C",
            dir,
            "push",
            "-u",
            "origin",
            &status.git.current_branch,
        ])
        .output()
        .map_err(DecapodError::IoError)?;
    if !push_output.status.success() {
        return Err(DecapodError::ValidationError(format!(
            "Failed to push: {}",
            String::from_utf8_lossy(&push_output.stderr)
        )));
    }

    // Get remote URL
    let remote_output = Command::new("git")
        .args(["-C", dir, "remote", "get-url", "origin"])
        .output()
        .map_err(DecapodError::IoError)?;
    let remote_url = String::from_utf8_lossy(&remote_output.stdout)
        .trim()
        .to_string();

    // 4. If gh CLI is available, create a PR
    let pr_url = if Command::new("gh")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
    {
        let pr_title = title.as_deref().unwrap_or(&status.git.current_branch);
        let mut pr_args = vec![
            "-C",
            dir,
            "pr",
            "create",
            "--title",
            pr_title,
            "--head",
            &status.git.current_branch,
        ];
        let desc;
        if let Some(ref d) = description {
            desc = d.clone();
            pr_args.push("--body");
            pr_args.push(&desc);
        }
        let pr_output = Command::new("gh")
            .args(&pr_args)
            .output()
            .map_err(DecapodError::IoError)?;
        if pr_output.status.success() {
            Some(
                String::from_utf8_lossy(&pr_output.stdout)
                    .trim()
                    .to_string(),
            )
        } else {
            None
        }
    } else {
        None
    };

    Ok(PublishResult {
        branch: status.git.current_branch,
        commit_hash,
        remote_url,
        pr_url,
    })
}

fn extract_task_ids_from_branch(branch: &str) -> Vec<String> {
    let re = Regex::new(r"(?i)(?:feat|fend|lang|perf|plat|proj|refa|root|secu|spec|test|bugs|docs|cicd|infr|bend|reft|tool|arch|apis|data|r|todo)[_-][a-z0-9]{6,}").expect("static regex");
    let mut out: Vec<String> = re
        .find_iter(branch)
        .filter_map(|m| m.ok())
        .map(|m| m.as_str().replace('-', "_").to_lowercase())
        .collect();
    out.sort();
    out.dedup();
    out
}

pub fn verify_workunit_gate_for_publish(
    repo_root: &Path,
    branch: &str,
) -> Result<(), DecapodError> {
    let task_ids = extract_task_ids_from_branch(branch);
    if task_ids.is_empty() {
        return Ok(());
    }

    for task_id in task_ids {
        let path = workunit::workunit_path(repo_root, &task_id)?;
        if !path.exists() {
            return Err(DecapodError::ValidationError(format!(
                "Cannot publish: missing required workunit manifest for task '{}' at {}.",
                task_id,
                path.display()
            )));
        }
        let manifest = workunit::load_workunit(repo_root, &task_id)?;
        if manifest.status != WorkUnitStatus::Verified {
            return Err(DecapodError::ValidationError(format!(
                "Cannot publish: workunit '{}' is not VERIFIED (current {:?}).",
                task_id, manifest.status
            )));
        }
        workunit::verify_capsule_policy_lineage_for_task(repo_root, &manifest)?;
    }

    Ok(())
}

pub fn get_allowed_ops(status: &WorkspaceStatus) -> Vec<AllowedOp> {
    let mut ops = vec![];

    if status.git.is_protected {
        ops.push(AllowedOp {
            op: "workspace.ensure".to_string(),
            reason: "Create isolated working branch (cannot work on protected branch)".to_string(),
            required_params: vec!["branch".to_string()],
        });
    } else {
        ops.push(AllowedOp {
            op: "todo.list".to_string(),
            reason: "Workspace ready for work".to_string(),
            required_params: vec![],
        });
    }

    ops.push(AllowedOp {
        op: "workspace.status".to_string(),
        reason: "Check workspace state".to_string(),
        required_params: vec![],
    });

    ops
}

/// Workspace pruned record
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PrunedWorkspace {
    /// Absolute path of the pruned workspace
    pub path: String,
    /// Reason for pruning: not_registered, branch_deleted, no_matching_task, task_completed, no_active_claim
    pub reason: String,
}

/// Prune stale/unused agent workspaces
pub fn prune_workspaces(
    repo_root: &Path,
    force: bool,
) -> Result<Vec<PrunedWorkspace>, DecapodError> {
    let main_repo = get_main_repo_root(repo_root)?;
    let workspaces_dir = main_repo.join(".decapod").join("workspaces");
    if !workspaces_dir.is_dir() {
        return Ok(vec![]);
    }

    // 1) Parse all current git worktrees
    let worktrees_output = Command::new("git")
        .args([
            "-C",
            main_repo.to_str().unwrap_or("."),
            "worktree",
            "list",
            "--porcelain",
        ])
        .output()
        .map_err(DecapodError::IoError)?;

    if !worktrees_output.status.success() {
        return Err(DecapodError::ValidationError(format!(
            "Failed to list git worktrees: {}",
            String::from_utf8_lossy(&worktrees_output.stderr)
        )));
    }

    struct WorktreeInfo {
        path: PathBuf,
        branch: Option<String>,
        head: Option<String>,
    }

    let mut worktrees = Vec::new();
    let mut current_path = None;
    let mut current_branch = None;
    let mut current_head = None;

    for line in String::from_utf8_lossy(&worktrees_output.stdout).lines() {
        if let Some(p) = line.strip_prefix("worktree ") {
            if let Some(path) = current_path.take() {
                worktrees.push(WorktreeInfo {
                    path,
                    branch: current_branch.take(),
                    head: current_head.take(),
                });
            }
            current_path = Some(PathBuf::from(p.trim()));
        } else if let Some(b) = line.strip_prefix("branch ") {
            current_branch = Some(b.trim().to_string());
        } else if let Some(h) = line.strip_prefix("HEAD ") {
            current_head = Some(h.trim().to_string());
        }
    }
    if let Some(path) = current_path {
        worktrees.push(WorktreeInfo {
            path,
            branch: current_branch,
            head: current_head,
        });
    }

    // 2) Get all tasks from todo database
    let store_root = main_repo.join(".decapod").join("data");
    let tasks = if store_root.exists() {
        todo::list_tasks(&store_root, None, None, None, None, None).unwrap_or_default()
    } else {
        vec![]
    };

    let mut pruned = Vec::new();

    // 3) Iterate over the directory entries under .decapod/workspaces/
    for entry in std::fs::read_dir(&workspaces_dir).map_err(DecapodError::IoError)? {
        let entry = entry.map_err(DecapodError::IoError)?;
        let dir_path = entry.path();
        if !dir_path.is_dir() {
            continue;
        }

        // Safety Safeguard: Do not prune if repo_root is inside or equal to dir_path.
        let normalized_dir = normalize_path_for_compare(&dir_path);
        let normalized_repo = normalize_path_for_compare(repo_root);
        if normalized_repo == normalized_dir || repo_root.starts_with(&dir_path) {
            continue;
        }

        let dir_name = dir_path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("")
            .to_string();

        // Check if registered as a worktree in git
        let matching_wt = worktrees
            .iter()
            .find(|wt| normalize_path_for_compare(&wt.path) == normalized_dir);

        let mut is_stale = false;
        let mut prune_reason = String::new();

        if let Some(wt) = matching_wt {
            // Check branch existence
            if let Some(ref_name) = &wt.branch {
                let show_ref_out = Command::new("git")
                    .args([
                        "-C",
                        main_repo.to_str().unwrap_or("."),
                        "show-ref",
                        "--verify",
                        ref_name,
                    ])
                    .output();

                let branch_exists = match show_ref_out {
                    Ok(out) => out.status.success(),
                    Err(_) => false,
                };

                if !branch_exists {
                    is_stale = true;
                    prune_reason = "branch_deleted".to_string();
                } else {
                    // Branch exists, check matching tasks
                    let mut matched_tasks = Vec::new();
                    for t in &tasks {
                        if !t.hash.is_empty() {
                            let hash_lower = t.hash.to_lowercase();
                            let dir_lower = dir_name.to_lowercase();
                            let branch_lower = ref_name.to_lowercase();
                            if dir_lower.contains(&hash_lower) || branch_lower.contains(&hash_lower)
                            {
                                matched_tasks.push(t);
                            }
                        }
                    }

                    let mut potential_stale = false;
                    if matched_tasks.is_empty() {
                        potential_stale = true;
                        prune_reason = "no_matching_task".to_string();
                    } else {
                        // Check status of matched tasks
                        let all_completed = matched_tasks
                            .iter()
                            .all(|t| t.status == "done" || t.status == "archived");
                        let no_active_claim =
                            matched_tasks.iter().all(|t| t.assigned_to.is_empty());

                        if all_completed {
                            potential_stale = true;
                            prune_reason = "task_completed".to_string();
                        } else if no_active_claim {
                            potential_stale = true;
                            prune_reason = "no_active_claim".to_string();
                        }
                    }

                    if potential_stale {
                        let is_merged = Command::new("git")
                            .args([
                                "-C",
                                main_repo.to_str().unwrap_or("."),
                                "rev-parse",
                                ref_name,
                            ])
                            .output()
                            .ok()
                            .filter(|o| o.status.success())
                            .map(|o| {
                                let commit_hash =
                                    String::from_utf8_lossy(&o.stdout).trim().to_string();
                                is_commit_in_protected_branch(&main_repo, &commit_hash)
                            })
                            .unwrap_or(false);
                        if is_merged {
                            is_stale = true;
                        }
                    }
                }
            } else {
                // No branch associated (detached HEAD) -> if no task matches it, prune it
                let mut matched_tasks = Vec::new();
                for t in &tasks {
                    if !t.hash.is_empty() {
                        let hash_lower = t.hash.to_lowercase();
                        let dir_lower = dir_name.to_lowercase();
                        if dir_lower.contains(&hash_lower) {
                            matched_tasks.push(t);
                        }
                    }
                }
                let mut potential_stale = false;
                if matched_tasks.is_empty() {
                    potential_stale = true;
                    prune_reason = "no_matching_task".to_string();
                } else {
                    let all_completed = matched_tasks
                        .iter()
                        .all(|t| t.status == "done" || t.status == "archived");
                    let no_active_claim = matched_tasks.iter().all(|t| t.assigned_to.is_empty());
                    if all_completed {
                        potential_stale = true;
                        prune_reason = "task_completed".to_string();
                    } else if no_active_claim {
                        potential_stale = true;
                        prune_reason = "no_active_claim".to_string();
                    }
                }

                if potential_stale {
                    let is_merged = wt.head.as_ref().is_some_and(|commit_hash| {
                        is_commit_in_protected_branch(&main_repo, commit_hash)
                    });
                    if is_merged {
                        is_stale = true;
                    }
                }
            }
        } else {
            // Case A: Not registered in git worktrees
            is_stale = true;
            prune_reason = "not_registered".to_string();
        }

        if is_stale {
            // Attempt to remove git worktree if registered
            if matching_wt.is_some() {
                let mut args = vec!["worktree", "remove"];
                if force {
                    args.push("--force");
                }
                args.push(dir_path.to_str().unwrap_or("."));

                let _ = Command::new("git")
                    .args(["-C", main_repo.to_str().unwrap_or(".")])
                    .args(&args)
                    .output();
            }

            // Fallback: forcefully remove from disk if it still exists
            if dir_path.exists() {
                let _ = std::fs::remove_dir_all(&dir_path);
            }

            pruned.push(PrunedWorkspace {
                path: dir_path.to_string_lossy().to_string(),
                reason: prune_reason,
            });
        }
    }

    // Call prune_stale_worktree_config to scrub .git/config entries
    let _ = prune_stale_worktree_config(repo_root);

    Ok(pruned)
}

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

    #[test]
    fn test_extract_task_ids_from_branch() {
        assert_eq!(
            extract_task_ids_from_branch("agent/unknown/bugs-01kvtvsvteg1t4ds"),
            vec!["bugs_01kvtvsvteg1t4ds".to_string()]
        );
        assert_eq!(
            extract_task_ids_from_branch("agent/unknown/feat-01kvtvsvteg1t4ds-1782239277"),
            vec!["feat_01kvtvsvteg1t4ds".to_string()]
        );
        assert_eq!(
            extract_task_ids_from_branch("agent/unknown/todo-01kvtr-plus-2-1782239277"),
            vec!["todo_01kvtr".to_string()]
        );
        assert_eq!(
            extract_task_ids_from_branch("agent/unknown/bugs_01kvtvsvteg1t4ds"),
            vec!["bugs_01kvtvsvteg1t4ds".to_string()]
        );
        assert_eq!(
            extract_task_ids_from_branch("agent/unknown/some-feature-branch"),
            Vec::<String>::new()
        );
    }
}
