//! Dependency command implementation.

use super::{
    RoutedWorkspaceWriteLock, acquire_routed_workspace_write_lock,
    auto_import_storage_ctx_if_stale, cli_for_routed_workspace,
    external_project_db_paths_after_auto_import_if_needed, finalize_batched_blocked_cache_refresh,
    report_auto_flush_failure, resolve_issue_id, retry_mutation_with_jsonl_recovery,
};
use crate::cli::{
    DepAddArgs, DepCommands, DepCyclesArgs, DepDirection, DepListArgs, DepRemoveArgs, DepTreeArgs,
    OutputFormat, resolve_output_format_basic_with_outer_mode,
};
use crate::config;
use crate::error::{BeadsError, Result};
use crate::format::{sanitize_terminal_inline, truncate_title};
use crate::model::DependencyType;
use crate::output::{OutputContext, OutputMode, Theme};
use crate::storage::SqliteStorage;
use crate::util::id::{IdResolver, ResolverConfig};
use rich_rust::prelude::*;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// Execute the dep command.
///
/// # Errors
///
/// Returns an error if database operations fail or if inputs are invalid.
pub fn execute(
    command: &DepCommands,
    json: bool,
    cli: &config::CliOverrides,
    ctx: &OutputContext,
) -> Result<()> {
    let beads_dir = config::discover_beads_dir_with_cli(cli)?;
    match command {
        DepCommands::Add(args) => execute_dep_add(args, json, cli, ctx, &beads_dir),
        DepCommands::Remove(args) => execute_dep_remove(args, json, cli, ctx, &beads_dir),
        DepCommands::List(args) => execute_dep_list(args, cli, ctx, &beads_dir),
        DepCommands::Tree(args) => execute_dep_tree(args, json, cli, ctx, &beads_dir),
        DepCommands::Cycles(args) => {
            let storage_ctx = config::open_storage_with_cli(&beads_dir, cli)?;
            dep_cycles(args, &storage_ctx.storage, json, ctx)
        }
    }
}

/// Execute a read-only dep command using storage that was already opened by the caller.
///
/// Returns `Ok(false)` when the command needs the normal routed or mutating path.
///
/// # Errors
///
/// Returns an error if database operations fail or if inputs are invalid.
pub fn execute_with_storage_ctx(
    command: &DepCommands,
    json: bool,
    cli: &config::CliOverrides,
    ctx: &OutputContext,
    local_beads_dir: &Path,
    storage_ctx: &config::OpenStorageResult,
) -> Result<bool> {
    match command {
        DepCommands::List(args) => {
            execute_local_dep_list_with_storage_ctx(args, cli, ctx, local_beads_dir, storage_ctx)
        }
        DepCommands::Tree(args) => {
            execute_local_dep_tree_with_storage_ctx(args, cli, ctx, local_beads_dir, storage_ctx)
        }
        DepCommands::Cycles(args) => {
            dep_cycles(args, &storage_ctx.storage, json, ctx)?;
            Ok(true)
        }
        DepCommands::Add(_) | DepCommands::Remove(_) => Ok(false),
    }
}

fn execute_dep_add(
    args: &DepAddArgs,
    _json: bool,
    cli: &config::CliOverrides,
    ctx: &OutputContext,
    local_beads_dir: &Path,
) -> Result<()> {
    validate_dependency_target_route(local_beads_dir, &args.issue, &args.depends_on)?;
    let (mut storage_ctx, route_cli, auto_flush_external, _routed_write_lock) =
        open_routed_storage_for_input(local_beads_dir, cli, &args.issue)?;
    let config_layer = storage_ctx.load_config(&route_cli)?;
    let id_config = config::id_config_from_layer(&config_layer);
    let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
    let actor = config::resolve_actor(&config_layer);
    dep_add(
        args,
        &mut storage_ctx,
        &resolver,
        &actor,
        ctx,
        local_beads_dir,
        auto_flush_external,
    )
}

fn execute_dep_remove(
    args: &DepRemoveArgs,
    _json: bool,
    cli: &config::CliOverrides,
    ctx: &OutputContext,
    local_beads_dir: &Path,
) -> Result<()> {
    validate_dependency_target_route(local_beads_dir, &args.issue, &args.depends_on)?;
    let (mut storage_ctx, route_cli, auto_flush_external, _routed_write_lock) =
        open_routed_storage_for_input(local_beads_dir, cli, &args.issue)?;
    let config_layer = storage_ctx.load_config(&route_cli)?;
    let id_config = config::id_config_from_layer(&config_layer);
    let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
    let actor = config::resolve_actor(&config_layer);
    dep_remove(
        args,
        &mut storage_ctx,
        &resolver,
        &actor,
        ctx,
        local_beads_dir,
        auto_flush_external,
    )
}

fn execute_dep_list(
    args: &DepListArgs,
    cli: &config::CliOverrides,
    ctx: &OutputContext,
    local_beads_dir: &Path,
) -> Result<()> {
    let (storage_ctx, route_cli, _, _routed_write_lock) =
        open_routed_storage_for_input(local_beads_dir, cli, &args.issue)?;
    let config_layer = storage_ctx.load_config(&route_cli)?;
    let use_color = config::should_use_color(&config_layer);
    let quiet = route_cli.quiet.unwrap_or(false);
    let id_config = config::id_config_from_layer(&config_layer);
    let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
    let external_db_paths = external_project_db_paths_after_auto_import_if_needed(
        &storage_ctx.storage,
        &config_layer,
        &storage_ctx.paths.beads_dir,
        &route_cli,
    )?;

    dep_list(
        args,
        &storage_ctx.storage,
        &resolver,
        &external_db_paths,
        ctx,
        quiet,
        !use_color,
    )
}

fn execute_local_dep_list_with_storage_ctx(
    args: &DepListArgs,
    cli: &config::CliOverrides,
    ctx: &OutputContext,
    local_beads_dir: &Path,
    storage_ctx: &config::OpenStorageResult,
) -> Result<bool> {
    if config::routing::resolve_route(&args.issue, local_beads_dir)?.is_external {
        return Ok(false);
    }

    let config_layer = storage_ctx.load_config(cli)?;
    let use_color = config::should_use_color(&config_layer);
    let quiet = cli.quiet.unwrap_or(false);
    let id_config = config::id_config_from_layer(&config_layer);
    let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
    let external_db_paths = external_project_db_paths_after_auto_import_if_needed(
        &storage_ctx.storage,
        &config_layer,
        &storage_ctx.paths.beads_dir,
        cli,
    )?;

    dep_list(
        args,
        &storage_ctx.storage,
        &resolver,
        &external_db_paths,
        ctx,
        quiet,
        !use_color,
    )?;
    Ok(true)
}

fn execute_dep_tree(
    args: &DepTreeArgs,
    _json: bool,
    cli: &config::CliOverrides,
    ctx: &OutputContext,
    local_beads_dir: &Path,
) -> Result<()> {
    let (storage_ctx, route_cli, _, _routed_write_lock) =
        open_routed_storage_for_input(local_beads_dir, cli, &args.issue)?;
    let config_layer = storage_ctx.load_config(&route_cli)?;
    let id_config = config::id_config_from_layer(&config_layer);
    let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
    let external_db_paths = external_project_db_paths_after_auto_import_if_needed(
        &storage_ctx.storage,
        &config_layer,
        &storage_ctx.paths.beads_dir,
        &route_cli,
    )?;

    dep_tree(
        args,
        &storage_ctx.storage,
        &resolver,
        &external_db_paths,
        false,
        ctx,
    )
}

fn execute_local_dep_tree_with_storage_ctx(
    args: &DepTreeArgs,
    cli: &config::CliOverrides,
    ctx: &OutputContext,
    local_beads_dir: &Path,
    storage_ctx: &config::OpenStorageResult,
) -> Result<bool> {
    if config::routing::resolve_route(&args.issue, local_beads_dir)?.is_external {
        return Ok(false);
    }

    let config_layer = storage_ctx.load_config(cli)?;
    let id_config = config::id_config_from_layer(&config_layer);
    let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
    let external_db_paths = external_project_db_paths_after_auto_import_if_needed(
        &storage_ctx.storage,
        &config_layer,
        &storage_ctx.paths.beads_dir,
        cli,
    )?;

    dep_tree(
        args,
        &storage_ctx.storage,
        &resolver,
        &external_db_paths,
        false,
        ctx,
    )?;
    Ok(true)
}

fn open_routed_storage_for_input(
    local_beads_dir: &Path,
    cli: &config::CliOverrides,
    issue_input: &str,
) -> Result<(
    config::OpenStorageResult,
    config::CliOverrides,
    bool,
    RoutedWorkspaceWriteLock,
)> {
    let route = config::routing::resolve_route(issue_input, local_beads_dir)?;
    let mut route_cli = cli_for_routed_workspace(cli, route.is_external);
    let routed_write_lock = acquire_routed_workspace_write_lock(
        &route.beads_dir,
        route.is_external,
        route_cli.lock_timeout,
    )?;
    routed_write_lock.mark_cli_write_lock_held(&mut route_cli);
    let mut storage_ctx = config::open_storage_with_cli(&route.beads_dir, &route_cli)?;
    auto_import_storage_ctx_if_stale(&mut storage_ctx, &route_cli)?;
    Ok((storage_ctx, route_cli, route.is_external, routed_write_lock))
}

fn validate_dependency_target_route(
    local_beads_dir: &Path,
    issue_input: &str,
    depends_on_input: &str,
) -> Result<()> {
    if depends_on_input.starts_with("external:") {
        return Ok(());
    }

    let issue_route = config::routing::resolve_route(issue_input, local_beads_dir)?;
    let depends_on_route = config::routing::resolve_route(depends_on_input, local_beads_dir)?;

    if issue_route.beads_dir == depends_on_route.beads_dir {
        return Ok(());
    }

    Err(BeadsError::validation(
        "depends_on",
        format!(
            "issue '{issue_input}' and dependency target '{depends_on_input}' resolve to different projects; use an explicit external:... dependency for cross-project links"
        ),
    ))
}

/// JSON output for dep add/remove operations
#[derive(Serialize)]
struct DepActionResult {
    status: String,
    issue_id: String,
    depends_on_id: String,
    #[serde(rename = "type")]
    dep_type: String,
    action: String,
}

fn finalize_dep_mutation(
    storage_ctx: &mut config::OpenStorageResult,
    cache_dirty: bool,
    command: &str,
) -> Result<()> {
    finalize_batched_blocked_cache_refresh(&mut storage_ctx.storage, cache_dirty, command)?;
    storage_ctx.flush_no_db_if_dirty()
}

/// JSON output for dep list
#[derive(Serialize)]
struct DepListItem {
    issue_id: String,
    depends_on_id: String,
    #[serde(rename = "type")]
    dep_type: String,
    title: String,
    status: String,
    priority: i32,
}

/// JSON output for dep tree
#[derive(Serialize)]
struct TreeNode {
    #[serde(skip_serializing)]
    node_key: String,
    id: String,
    title: String,
    depth: usize,
    parent_id: Option<String>,
    #[serde(skip_serializing)]
    parent_key: Option<String>,
    priority: i32,
    status: String,
    truncated: bool,
}

/// JSON output for dep cycles
#[derive(Serialize)]
struct CyclesResult {
    cycles: Vec<Vec<String>>,
    count: usize,
    active_count: usize,
    archived_closed_count: usize,
    total_count: usize,
    blocking_only: bool,
    include_closed: bool,
    scope: &'static str,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    active_cycles: Vec<Vec<String>>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    archived_closed_cycles: Vec<Vec<String>>,
}

fn dep_add(
    args: &DepAddArgs,
    storage_ctx: &mut config::OpenStorageResult,
    resolver: &IdResolver,
    actor: &str,
    ctx: &OutputContext,
    local_beads_dir: &Path,
    auto_flush_external: bool,
) -> Result<()> {
    let issue_id = resolve_issue_id(&storage_ctx.storage, resolver, &args.issue)?;

    // External dependencies don't need resolution
    let depends_on_id = if args.depends_on.starts_with("external:") {
        args.depends_on.clone()
    } else {
        resolve_issue_id(&storage_ctx.storage, resolver, &args.depends_on)?
    };

    let dep_type = parse_dependency_type(&args.dep_type)?;

    // Self-dependency check
    if issue_id == depends_on_id {
        return Err(BeadsError::SelfDependency { id: issue_id });
    }

    // Cycle check for blocking types only
    if dep_type.is_blocking()
        && !depends_on_id.starts_with("external:")
        && storage_ctx
            .storage
            .would_create_cycle(&issue_id, &depends_on_id, true)?
    {
        return Err(BeadsError::DependencyCycle {
            path: format!("{issue_id} -> {depends_on_id}"),
        });
    }

    let added = retry_mutation_with_jsonl_recovery(
        storage_ctx,
        true,
        "dep add",
        Some(issue_id.as_str()),
        |storage| {
            storage.add_dependency_with_metadata(
                &issue_id,
                &depends_on_id,
                dep_type.as_str(),
                actor,
                args.metadata.as_deref(),
            )
        },
    )?;

    finalize_dep_mutation(storage_ctx, added, "dep add")?;
    if auto_flush_external && let Err(error) = storage_ctx.auto_flush_if_enabled() {
        report_auto_flush_failure(
            ctx,
            &storage_ctx.paths.beads_dir,
            &storage_ctx.paths.jsonl_path,
            &error,
        );
    }
    crate::util::set_last_touched_id(local_beads_dir, &issue_id);

    if ctx.is_json() || ctx.is_toon() {
        let result = DepActionResult {
            status: if added { "ok" } else { "exists" }.to_string(),
            issue_id: issue_id.clone(),
            depends_on_id: depends_on_id.clone(),
            dep_type: dep_type.as_str().to_string(),
            action: if added { "added" } else { "already_exists" }.to_string(),
        };
        if ctx.is_toon() {
            ctx.toon(&result);
        } else {
            ctx.json_pretty(&result);
        }
    } else if matches!(ctx.mode(), OutputMode::Quiet) {
        return Ok(());
    } else if added {
        let issue_id_display = dep_display_text(&issue_id);
        let depends_on_id_display = dep_display_text(&depends_on_id);
        let dep_type_display = dep_display_text(dep_type.as_str());
        if ctx.is_rich() {
            // Rich mode: Show detailed visual feedback
            ctx.success(&format!(
                "Added dependency: {} → {}",
                issue_id_display, depends_on_id_display
            ));
            let relationship = match dep_type {
                DependencyType::Blocks => format!(
                    "  {} now blocks {}",
                    depends_on_id_display, issue_id_display
                ),
                DependencyType::ParentChild => {
                    format!(
                        "  {} is parent of {}",
                        depends_on_id_display, issue_id_display
                    )
                }
                DependencyType::WaitsFor => {
                    format!("  {} waits for {}", issue_id_display, depends_on_id_display)
                }
                _ => format!("  Relationship: {}", dep_type_display),
            };
            ctx.print_line(&relationship);
        } else {
            ctx.success(&format!(
                "Added dependency: {} -> {} ({})",
                issue_id_display, depends_on_id_display, dep_type_display
            ));
        }
    } else {
        let issue_id_display = dep_display_text(&issue_id);
        let depends_on_id_display = dep_display_text(&depends_on_id);
        ctx.info(&format!(
            "Dependency already exists: {issue_id_display} → {depends_on_id_display}"
        ));
    }

    Ok(())
}

fn dep_remove(
    args: &DepRemoveArgs,
    storage_ctx: &mut config::OpenStorageResult,
    resolver: &IdResolver,
    actor: &str,
    ctx: &OutputContext,
    local_beads_dir: &Path,
    auto_flush_external: bool,
) -> Result<()> {
    let issue_id = resolve_issue_id(&storage_ctx.storage, resolver, &args.issue)?;

    // External dependencies don't need resolution
    let depends_on_id = if args.depends_on.starts_with("external:") {
        args.depends_on.clone()
    } else {
        resolve_issue_id(&storage_ctx.storage, resolver, &args.depends_on)?
    };

    let dep_type = dependency_type_for_pair(&storage_ctx.storage, &issue_id, &depends_on_id)?
        .unwrap_or_else(|| "unknown".to_string());
    let removed = retry_mutation_with_jsonl_recovery(
        storage_ctx,
        true,
        "dep remove",
        Some(issue_id.as_str()),
        |storage| storage.remove_dependency(&issue_id, &depends_on_id, actor),
    )?;

    finalize_dep_mutation(storage_ctx, removed, "dep remove")?;
    if auto_flush_external && let Err(error) = storage_ctx.auto_flush_if_enabled() {
        report_auto_flush_failure(
            ctx,
            &storage_ctx.paths.beads_dir,
            &storage_ctx.paths.jsonl_path,
            &error,
        );
    }
    crate::util::set_last_touched_id(local_beads_dir, &issue_id);

    if ctx.is_json() || ctx.is_toon() {
        let result = DepActionResult {
            status: if removed { "ok" } else { "not_found" }.to_string(),
            issue_id: issue_id.clone(),
            depends_on_id: depends_on_id.clone(),
            dep_type,
            action: if removed { "removed" } else { "not_found" }.to_string(),
        };
        if ctx.is_toon() {
            ctx.toon(&result);
        } else {
            ctx.json_pretty(&result);
        }
    } else if matches!(ctx.mode(), OutputMode::Quiet) {
        return Ok(());
    } else if removed {
        let issue_id_display = dep_display_text(&issue_id);
        let depends_on_id_display = dep_display_text(&depends_on_id);
        if ctx.is_rich() {
            ctx.success(&format!(
                "Removed dependency: {} → {}",
                issue_id_display, depends_on_id_display
            ));
            ctx.print_line(&format!(
                "  {} no longer depends on {}",
                issue_id_display, depends_on_id_display
            ));
        } else {
            ctx.success(&format!(
                "Removed dependency: {issue_id_display} -> {depends_on_id_display}"
            ));
        }
    } else {
        let issue_id_display = dep_display_text(&issue_id);
        let depends_on_id_display = dep_display_text(&depends_on_id);
        ctx.warning(&format!(
            "Dependency not found: {issue_id_display} → {depends_on_id_display}"
        ));
    }

    Ok(())
}

fn dependency_type_for_pair(
    storage: &SqliteStorage,
    issue_id: &str,
    depends_on_id: &str,
) -> Result<Option<String>> {
    Ok(storage
        .get_dependencies_full(issue_id)?
        .into_iter()
        .find(|dep| dep.depends_on_id == depends_on_id)
        .map(|dep| dep.dep_type.as_str().to_string()))
}

fn dep_display_text(value: &str) -> String {
    sanitize_terminal_inline(value).into_owned()
}

fn parse_dependency_type(dep_type: &str) -> Result<DependencyType> {
    let parsed: DependencyType = dep_type.parse().map_err(|_| BeadsError::Validation {
        field: "type".to_string(),
        reason: format!("Invalid dependency type: {dep_type}"),
    })?;

    if let DependencyType::Custom(_) = parsed {
        return Err(BeadsError::Validation {
            field: "type".to_string(),
            reason: format!(
                "Unknown dependency type: '{dep_type}'. \
                 Allowed types: blocks, parent-child, conditional-blocks, waits-for, \
                 related, discovered-from, replies-to, relates-to, duplicates, \
                 supersedes, caused-by"
            ),
        });
    }

    Ok(parsed)
}

fn normalize_dep_type_filter(dep_type: &str) -> Result<String> {
    Ok(parse_dependency_type(dep_type)?.as_str().to_string())
}

#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn dep_list(
    args: &DepListArgs,
    storage: &SqliteStorage,
    resolver: &IdResolver,
    external_db_paths: &HashMap<String, PathBuf>,
    outer_ctx: &OutputContext,
    quiet: bool,
    no_color: bool,
) -> Result<()> {
    let output_format = resolve_output_format_basic_with_outer_mode(
        args.format,
        outer_ctx.inherited_output_mode(),
        false,
    );
    let ctx = OutputContext::from_output_format(output_format, quiet, no_color);
    let issue_id = resolve_issue_id(storage, resolver, &args.issue)?;
    let dep_type_filter = args
        .dep_type
        .as_deref()
        .map(normalize_dep_type_filter)
        .transpose()?;

    let mut items = Vec::new();

    // Get dependencies (what this issue depends on)
    if matches!(args.direction, DepDirection::Down | DepDirection::Both) {
        let deps = storage.get_dependencies_with_metadata(&issue_id)?;
        for dep in deps {
            if let Some(ref filter_type) = dep_type_filter
                && dep.dep_type != *filter_type
            {
                continue;
            }
            items.push(DepListItem {
                issue_id: issue_id.clone(),
                depends_on_id: dep.id.clone(),
                dep_type: dep.dep_type.clone(),
                title: dep.title.clone(),
                status: dep.status.as_str().to_string(),
                priority: dep.priority.0,
            });
        }
    }

    // Get dependents (what depends on this issue)
    if matches!(args.direction, DepDirection::Up | DepDirection::Both) {
        let deps = storage.get_dependents_with_metadata(&issue_id)?;
        for dep in deps {
            if let Some(ref filter_type) = dep_type_filter
                && dep.dep_type != *filter_type
            {
                continue;
            }
            items.push(DepListItem {
                issue_id: dep.id.clone(),
                depends_on_id: issue_id.clone(),
                dep_type: dep.dep_type.clone(),
                title: dep.title.clone(),
                status: dep.status.as_str().to_string(),
                priority: dep.priority.0,
            });
        }
    }

    if !items.is_empty()
        && items.iter().any(|item| {
            item.depends_on_id.starts_with("external:") || item.issue_id.starts_with("external:")
        })
    {
        let external_statuses =
            storage.resolve_external_dependency_statuses(external_db_paths, false)?;
        apply_external_dep_list_metadata(&mut items, &external_statuses);
    }

    if matches!(ctx.mode(), OutputMode::Quiet) {
        return Ok(());
    }

    match output_format {
        OutputFormat::Json => {
            ctx.json_pretty(&items);
            return Ok(());
        }
        OutputFormat::Toon => {
            ctx.toon_with_stats(&items, args.stats);
            return Ok(());
        }
        OutputFormat::Text | OutputFormat::Csv => {}
    }

    sort_dep_list_items_for_human(&mut items);

    if items.is_empty() {
        let direction_str = match args.direction {
            DepDirection::Down => "dependencies",
            DepDirection::Up => "dependents",
            DepDirection::Both => "dependencies or dependents",
        };
        ctx.info(&format!(
            "No {direction_str} for {}",
            dep_display_text(&issue_id)
        ));
        return Ok(());
    }

    if ctx.is_rich() {
        // Rich mode: Use panel with tree-like display
        render_dep_list_rich(&ctx, &issue_id, &items, args.direction);
    } else {
        // Plain mode: Simple text output
        let display_issue_id = sanitize_terminal_inline(&issue_id);
        let header = match args.direction {
            DepDirection::Down => {
                format!("Dependencies of {} ({}):", display_issue_id, items.len())
            }
            DepDirection::Up => {
                format!("Dependents of {} ({}):", display_issue_id, items.len())
            }
            DepDirection::Both => format!(
                "Dependencies and dependents of {} ({}):",
                display_issue_id,
                items.len()
            ),
        };
        ctx.info(&header);

        for item in &items {
            let dep_type = sanitize_terminal_inline(&item.dep_type);
            let arrow = if item.issue_id == issue_id {
                format!(
                    "  -> {} ({dep_type})",
                    sanitize_terminal_inline(&item.depends_on_id)
                )
            } else {
                format!(
                    "  <- {} ({dep_type})",
                    sanitize_terminal_inline(&item.issue_id)
                )
            };
            ctx.print_line(&format!(
                "{}: {} [P{}] [{}]",
                arrow,
                sanitize_terminal_inline(&item.title),
                item.priority,
                sanitize_terminal_inline(&item.status)
            ));
        }
    }

    Ok(())
}

/// Render dependency list in rich mode with panel and tree-like display
fn render_dep_list_rich(
    ctx: &OutputContext,
    issue_id: &str,
    items: &[DepListItem],
    direction: DepDirection,
) {
    let theme = ctx.theme();

    // Separate items into dependencies (this issue depends on) and dependents (depend on this)
    let (deps, dependents): (Vec<_>, Vec<_>) =
        items.iter().partition(|item| item.issue_id == issue_id);

    let mut content = Text::new("");

    // Show dependencies (what this issue depends on)
    if !deps.is_empty() && matches!(direction, DepDirection::Down | DepDirection::Both) {
        append_dep_list_section(
            &mut content,
            &dep_list_section_title(true, deps.len()),
            &deps,
            true,
            theme,
        );
    }

    // Add separator if showing both directions
    if !deps.is_empty() && !dependents.is_empty() && matches!(direction, DepDirection::Both) {
        content.append("\n");
    }

    // Show dependents (what depends on this issue)
    if !dependents.is_empty() && matches!(direction, DepDirection::Up | DepDirection::Both) {
        append_dep_list_section(
            &mut content,
            &dep_list_section_title(false, dependents.len()),
            &dependents,
            false,
            theme,
        );
    }

    let panel = Panel::from_rich_text(&content, ctx.width())
        .title(Text::new(dep_list_panel_title(direction, issue_id)))
        .box_style(theme.box_style)
        .border_style(theme.panel_border.clone());

    ctx.render(&panel);
}

fn dep_list_panel_title(direction: DepDirection, issue_id: &str) -> String {
    let issue_id = sanitize_terminal_inline(issue_id);
    match direction {
        DepDirection::Down => format!("Dependencies for {issue_id}"),
        DepDirection::Up => format!("Dependents for {issue_id}"),
        DepDirection::Both => format!("Dependency relations for {issue_id}"),
    }
}

fn dep_list_section_title(is_dependency_section: bool, count: usize) -> String {
    let label = if is_dependency_section {
        "Dependencies"
    } else {
        "Dependents"
    };
    format!("{label} ({count}):")
}

fn append_dep_list_section(
    content: &mut Text,
    title: &str,
    items: &[&DepListItem],
    use_depends_on_id: bool,
    theme: &Theme,
) {
    content.append_styled(&format!("{title}\n"), theme.emphasis.clone());

    for (i, item) in items.iter().enumerate() {
        let prefix = if i == items.len() - 1 {
            "└── "
        } else {
            "├── "
        };
        let target_id = if use_depends_on_id {
            &item.depends_on_id
        } else {
            &item.issue_id
        };

        content.append_styled(prefix, theme.dimmed.clone());
        content.append_styled(
            sanitize_terminal_inline(target_id).as_ref(),
            theme.issue_id.clone(),
        );
        content.append(" ");
        content.append_styled(
            &format!("({}) ", sanitize_terminal_inline(&item.dep_type)),
            theme.muted.clone(),
        );
        append_dep_list_status(content, &item.status, theme);
        content.append(" ");
        content.append_styled(
            sanitize_terminal_inline(&item.title).as_ref(),
            theme.issue_title.clone(),
        );
        content.append("\n");
    }
}

fn dep_list_status_label(status: &str) -> String {
    match status {
        "open" => "[open]".to_string(),
        "in_progress" => "[in-progress]".to_string(),
        "closed" => "[closed] ✓".to_string(),
        "blocked" => "[blocked]".to_string(),
        _ => sanitize_terminal_inline(status).into_owned(),
    }
}

fn append_dep_list_status(content: &mut Text, status: &str, theme: &Theme) {
    let style = match status {
        "open" => theme.status_open.clone(),
        "in_progress" => theme.status_in_progress.clone(),
        "closed" => theme.status_closed.clone(),
        "blocked" => theme.status_blocked.clone(),
        _ => theme.dimmed.clone(),
    };
    content.append_styled(&dep_list_status_label(status), style);
}

fn apply_external_dep_list_metadata(
    items: &mut [DepListItem],
    external_statuses: &HashMap<String, bool>,
) {
    for item in items {
        let external_id = if item.depends_on_id.starts_with("external:") {
            Some(item.depends_on_id.as_str())
        } else if item.issue_id.starts_with("external:") {
            Some(item.issue_id.as_str())
        } else {
            None
        };

        let Some(external_id) = external_id else {
            continue;
        };

        let satisfied = external_statuses.get(external_id).copied().unwrap_or(false);
        item.status = if satisfied {
            "closed".to_string()
        } else {
            "blocked".to_string()
        };

        let placeholder_title = external_id.strip_prefix("external:").unwrap_or(external_id);
        if item.title.is_empty() || item.title == placeholder_title {
            let prefix = if satisfied { "✓" } else { "⏳" };
            item.title = parse_external_dep_id(external_id).map_or_else(
                || format!("{prefix} {external_id}"),
                |(project, capability)| format!("{prefix} {project}:{capability}"),
            );
        }
    }
}

fn sort_dep_list_items_for_human(items: &mut [DepListItem]) {
    items.sort_by(|left, right| {
        left.priority
            .cmp(&right.priority)
            .then_with(|| left.issue_id.cmp(&right.issue_id))
            .then_with(|| left.depends_on_id.cmp(&right.depends_on_id))
            .then_with(|| left.dep_type.cmp(&right.dep_type))
    });
}

fn resolve_dep_tree_node_metadata(
    storage: &SqliteStorage,
    root_id: &str,
    root_issue: &crate::model::Issue,
    node_id: &str,
    external_statuses: &HashMap<String, bool>,
) -> Result<(String, i32, String)> {
    if node_id == root_id {
        return Ok((
            root_issue.title.clone(),
            root_issue.priority.0,
            root_issue.status.as_str().to_string(),
        ));
    }

    if node_id.starts_with("external:") {
        let satisfied = external_statuses.get(node_id).copied().unwrap_or(false);
        let status = if satisfied { "closed" } else { "blocked" };
        let prefix = if satisfied { "✓" } else { "⏳" };
        let title = if let Some((project, capability)) = parse_external_dep_id(node_id) {
            format!("{prefix} {project}:{capability}")
        } else {
            format!("{prefix} {node_id}")
        };
        return Ok((title, 2, status.to_string()));
    }

    let issue_opt = storage.get_issue(node_id)?;
    if let Some(issue) = issue_opt {
        return Ok((
            issue.title.clone(),
            issue.priority.0,
            issue.status.as_str().to_string(),
        ));
    }

    // Handle missing/deleted issues gracefully instead of failing the whole tree
    Ok((
        format!("[missing issue: {}]", sanitize_terminal_inline(node_id)),
        2,
        "deleted".to_string(),
    ))
}

fn dep_tree_truncated(depth: usize, max_depth: usize, dependency_count: usize) -> bool {
    depth >= max_depth && dependency_count > 0
}

type DepTreeAdjacency = HashMap<String, Vec<String>>;
type DepTreeMetadataCache = HashMap<String, (String, i32, String)>;

const LOCAL_DEP_TREE_NODE_LIMIT: usize = 256;

fn load_dep_tree_adjacency(
    storage: &SqliteStorage,
) -> Result<(DepTreeAdjacency, DepTreeAdjacency)> {
    let dependency_records = storage.get_all_dependency_records()?;
    let mut dependencies_by_issue: DepTreeAdjacency =
        HashMap::with_capacity(dependency_records.len());
    let mut dependents_by_issue: HashMap<String, Vec<String>> = HashMap::new();

    for (issue_id, dependencies) in dependency_records {
        let dependency_ids = dependencies_by_issue.entry(issue_id.clone()).or_default();
        for dependency in dependencies {
            dependency_ids.push(dependency.depends_on_id.clone());
            dependents_by_issue
                .entry(dependency.depends_on_id)
                .or_default()
                .push(issue_id.clone());
        }
    }

    for dependency_ids in dependencies_by_issue.values_mut() {
        dependency_ids.sort();
        dependency_ids.dedup();
    }
    for dependent_ids in dependents_by_issue.values_mut() {
        dependent_ids.sort();
        dependent_ids.dedup();
    }

    Ok((dependencies_by_issue, dependents_by_issue))
}

fn dep_tree_neighbors(
    direction: DepDirection,
    issue_id: &str,
    dependencies_by_issue: &DepTreeAdjacency,
    dependents_by_issue: &DepTreeAdjacency,
) -> Vec<String> {
    match direction {
        DepDirection::Down => dependencies_by_issue
            .get(issue_id)
            .map_or_else(Vec::new, Clone::clone),
        DepDirection::Up => dependents_by_issue
            .get(issue_id)
            .map_or_else(Vec::new, Clone::clone),
        DepDirection::Both => {
            let mut neighbors = dependencies_by_issue
                .get(issue_id)
                .map_or_else(Vec::new, Clone::clone);
            if let Some(dependents) = dependents_by_issue.get(issue_id) {
                neighbors.extend(dependents.iter().cloned());
            }
            neighbors.sort();
            neighbors.dedup();
            neighbors
        }
    }
}

fn dep_tree_neighbors_from_storage(
    storage: &SqliteStorage,
    direction: DepDirection,
    issue_id: &str,
) -> Result<Vec<String>> {
    let mut neighbors = match direction {
        DepDirection::Down => storage.get_dependencies(issue_id)?,
        DepDirection::Up => storage.get_dependents(issue_id)?,
        DepDirection::Both => {
            let mut neighbors = storage.get_dependencies(issue_id)?;
            neighbors.extend(storage.get_dependents(issue_id)?);
            neighbors
        }
    };
    neighbors.sort();
    neighbors.dedup();
    Ok(neighbors)
}

fn dep_tree_metadata_for_node(
    storage: &SqliteStorage,
    root_id: &str,
    root_issue: &crate::model::Issue,
    node_id: &str,
    external_statuses: &HashMap<String, bool>,
    metadata_cache: &mut DepTreeMetadataCache,
) -> Result<(String, i32, String)> {
    if let Some(metadata) = metadata_cache.get(node_id) {
        return Ok(metadata.clone());
    }

    let metadata =
        resolve_dep_tree_node_metadata(storage, root_id, root_issue, node_id, external_statuses)?;
    metadata_cache.insert(node_id.to_string(), metadata.clone());
    Ok(metadata)
}

fn hydrate_dep_tree_metadata_for_ids(
    storage: &SqliteStorage,
    root_id: &str,
    root_issue: &crate::model::Issue,
    issue_ids: &[String],
    external_statuses: &HashMap<String, bool>,
    metadata_cache: &mut DepTreeMetadataCache,
) -> Result<()> {
    for issue_id in issue_ids {
        dep_tree_metadata_for_node(
            storage,
            root_id,
            root_issue,
            issue_id,
            external_statuses,
            metadata_cache,
        )?;
    }
    Ok(())
}

struct DepTreeQueueItem {
    id: String,
    depth: usize,
    parent_id: Option<String>,
    parent_key: Option<String>,
    path: Vec<String>,
}

fn dep_tree_root_metadata(root_issue: &crate::model::Issue) -> (String, i32, String) {
    (
        root_issue.title.clone(),
        root_issue.priority.0,
        root_issue.status.as_str().to_string(),
    )
}

#[allow(clippy::too_many_lines)]
fn build_dep_tree_nodes_global(
    args: &DepTreeArgs,
    storage: &SqliteStorage,
    root_id: &str,
    root_issue: &crate::model::Issue,
    external_statuses: &HashMap<String, bool>,
) -> Result<Vec<TreeNode>> {
    let mut metadata_cache = storage.get_active_issues_metadata()?;
    metadata_cache.insert(root_id.to_string(), dep_tree_root_metadata(root_issue));
    let (dependencies_by_issue, dependents_by_issue) = load_dep_tree_adjacency(storage)?;

    let mut nodes = Vec::new();

    let mut queue = vec![DepTreeQueueItem {
        id: root_id.to_string(),
        depth: 0,
        parent_id: None,
        parent_key: None,
        path: Vec::new(),
    }];
    let mut next_node_key = 0usize;

    while let Some(item) = queue.pop() {
        // Cycle detection: check if current ID is already in the path
        if item.path.contains(&item.id) {
            continue;
        }

        let node_key = format!("n{next_node_key}");
        next_node_key += 1;

        let (title, priority, status) = dep_tree_metadata_for_node(
            storage,
            root_id,
            root_issue,
            &item.id,
            external_statuses,
            &mut metadata_cache,
        )?;

        let mut dependencies = Vec::new();
        let truncated = if item.id.starts_with("external:") {
            false
        } else {
            dependencies = dep_tree_neighbors(
                args.direction,
                &item.id,
                &dependencies_by_issue,
                &dependents_by_issue,
            );
            dep_tree_truncated(item.depth, args.max_depth, dependencies.len())
        };

        nodes.push(TreeNode {
            node_key: node_key.clone(),
            id: item.id.clone(),
            title,
            depth: item.depth,
            parent_id: item.parent_id.clone(),
            parent_key: item.parent_key.clone(),
            priority,
            status,
            truncated,
        });

        // Don't expand if at max depth
        if item.depth < args.max_depth && !item.id.starts_with("external:") {
            let mut new_path = item.path.clone();
            new_path.push(item.id.clone());

            hydrate_dep_tree_metadata_for_ids(
                storage,
                root_id,
                root_issue,
                &dependencies,
                external_statuses,
                &mut metadata_cache,
            )?;
            sort_dep_tree_siblings(&mut dependencies, &metadata_cache);
            // Push in reverse order so first sorted item pops first.
            for dep_id in dependencies.into_iter().rev() {
                // No global visited check here
                queue.push(DepTreeQueueItem {
                    id: dep_id,
                    depth: item.depth + 1,
                    parent_id: Some(item.id.clone()),
                    parent_key: Some(node_key.clone()),
                    path: new_path.clone(),
                });
            }
        }
    }

    Ok(nodes)
}

#[allow(clippy::too_many_lines)]
fn try_build_dep_tree_nodes_local(
    args: &DepTreeArgs,
    storage: &SqliteStorage,
    root_id: &str,
    root_issue: &crate::model::Issue,
    external_statuses: &HashMap<String, bool>,
) -> Result<Option<Vec<TreeNode>>> {
    let mut metadata_cache = DepTreeMetadataCache::new();
    metadata_cache.insert(root_id.to_string(), dep_tree_root_metadata(root_issue));

    let mut nodes = Vec::new();
    let mut queue = vec![DepTreeQueueItem {
        id: root_id.to_string(),
        depth: 0,
        parent_id: None,
        parent_key: None,
        path: Vec::new(),
    }];
    let mut next_node_key = 0usize;

    while let Some(item) = queue.pop() {
        if nodes.len() >= LOCAL_DEP_TREE_NODE_LIMIT {
            return Ok(None);
        }

        if item.path.contains(&item.id) {
            continue;
        }

        let node_key = format!("n{next_node_key}");
        next_node_key += 1;

        let (title, priority, status) = dep_tree_metadata_for_node(
            storage,
            root_id,
            root_issue,
            &item.id,
            external_statuses,
            &mut metadata_cache,
        )?;

        let mut dependencies = Vec::new();
        let truncated = if item.id.starts_with("external:") {
            false
        } else {
            dependencies = dep_tree_neighbors_from_storage(storage, args.direction, &item.id)?;
            dep_tree_truncated(item.depth, args.max_depth, dependencies.len())
        };

        nodes.push(TreeNode {
            node_key: node_key.clone(),
            id: item.id.clone(),
            title,
            depth: item.depth,
            parent_id: item.parent_id.clone(),
            parent_key: item.parent_key.clone(),
            priority,
            status,
            truncated,
        });

        if item.depth < args.max_depth && !item.id.starts_with("external:") {
            if nodes.len().saturating_add(dependencies.len()) > LOCAL_DEP_TREE_NODE_LIMIT {
                return Ok(None);
            }

            let mut new_path = item.path.clone();
            new_path.push(item.id.clone());

            hydrate_dep_tree_metadata_for_ids(
                storage,
                root_id,
                root_issue,
                &dependencies,
                external_statuses,
                &mut metadata_cache,
            )?;
            sort_dep_tree_siblings(&mut dependencies, &metadata_cache);

            for dep_id in dependencies.into_iter().rev() {
                queue.push(DepTreeQueueItem {
                    id: dep_id,
                    depth: item.depth + 1,
                    parent_id: Some(item.id.clone()),
                    parent_key: Some(node_key.clone()),
                    path: new_path.clone(),
                });
            }
        }
    }

    Ok(Some(nodes))
}

#[allow(clippy::too_many_lines)]
fn dep_tree(
    args: &DepTreeArgs,
    storage: &SqliteStorage,
    resolver: &IdResolver,
    external_db_paths: &HashMap<String, PathBuf>,
    _json: bool,
    ctx: &OutputContext,
) -> Result<()> {
    let root_id = resolve_issue_id(storage, resolver, &args.issue)?;
    let root_issue = storage
        .get_issue(&root_id)?
        .ok_or_else(|| BeadsError::IssueNotFound {
            id: root_id.clone(),
        })?;

    let external_statuses =
        storage.resolve_external_dependency_statuses(external_db_paths, false)?;
    let nodes = match try_build_dep_tree_nodes_local(
        args,
        storage,
        &root_id,
        &root_issue,
        &external_statuses,
    )? {
        Some(nodes) => nodes,
        None => {
            build_dep_tree_nodes_global(args, storage, &root_id, &root_issue, &external_statuses)?
        }
    };

    if ctx.is_json() || ctx.is_toon() {
        if ctx.is_toon() {
            ctx.toon(&nodes);
        } else {
            ctx.json_pretty(&nodes);
        }
        return Ok(());
    }

    if matches!(ctx.mode(), OutputMode::Quiet) {
        return Ok(());
    }

    // Mermaid format output
    if args.format.eq_ignore_ascii_case("mermaid") {
        render_dep_tree_mermaid(&nodes);
        return Ok(());
    }

    // Text tree output
    if nodes.is_empty() {
        ctx.info(&format!("No dependency tree for {root_id}"));
        return Ok(());
    }

    if ctx.is_rich() {
        // Rich mode: Use tree component with styled output
        render_dep_tree_rich(ctx, &nodes);
    } else {
        // Plain mode: Simple indented text
        for node in &nodes {
            let indent = "  ".repeat(node.depth);
            let prefix = if node.depth == 0 {
                ""
            } else if node.truncated {
                "├── (truncated) "
            } else {
                "├── "
            };
            ctx.print_line(&format!(
                "{}{}{}: {} [P{}] [{}]",
                indent,
                prefix,
                sanitize_terminal_inline(&node.id),
                sanitize_terminal_inline(&node.title),
                node.priority,
                sanitize_terminal_inline(&node.status)
            ));
        }
    }

    Ok(())
}

fn sanitize_mermaid_label(text: &str) -> String {
    sanitize_terminal_inline(text)
        .replace('"', "'")
        .replace(['\n', '\r'], " ")
}

fn render_dep_tree_mermaid(nodes: &[TreeNode]) {
    // Use println! directly to avoid rich_rust markup interpretation
    println!("graph TD");

    for node in nodes {
        let escaped_id = sanitize_mermaid_label(&node.id);
        let escaped_title = sanitize_mermaid_label(&node.title);
        println!(
            "    {}[\"{}: {} [P{}]\"]",
            node.node_key, escaped_id, escaped_title, node.priority
        );
    }

    for node in nodes {
        if let Some(parent_key) = node.parent_key.as_deref() {
            println!("    {parent_key} --> {}", node.node_key);
        }
    }
}

fn sort_dep_tree_siblings(
    dependencies: &mut [String],
    metadata_cache: &HashMap<String, (String, i32, String)>,
) {
    dependencies.sort_by(|left, right| {
        let left_meta = metadata_cache.get(left);
        let right_meta = metadata_cache.get(right);

        dep_tree_sibling_priority(left_meta)
            .cmp(&dep_tree_sibling_priority(right_meta))
            .then_with(|| {
                dep_tree_sibling_status_rank(left_meta)
                    .cmp(&dep_tree_sibling_status_rank(right_meta))
            })
            .then_with(|| dep_tree_sibling_title(left_meta).cmp(dep_tree_sibling_title(right_meta)))
            .then_with(|| left.cmp(right))
    });
}

fn dep_tree_sibling_priority(meta: Option<&(String, i32, String)>) -> i32 {
    meta.map_or(i32::MAX, |(_, priority, _)| *priority)
}

fn dep_tree_sibling_title(meta: Option<&(String, i32, String)>) -> &str {
    meta.map_or("", |(title, _, _)| title.as_str())
}

fn dep_tree_sibling_status_rank(meta: Option<&(String, i32, String)>) -> u8 {
    let Some((_, _, status)) = meta else {
        return u8::MAX;
    };

    match status.as_str() {
        "open" => 0,
        "in_progress" => 1,
        "blocked" => 2,
        "deferred" => 3,
        "closed" => 4,
        "deleted" | "tombstone" => 5,
        _ => 6,
    }
}

/// Render dependency tree in rich mode using Tree component
fn render_dep_tree_rich(ctx: &OutputContext, nodes: &[TreeNode]) {
    if nodes.is_empty() {
        return;
    }

    let theme = ctx.theme();

    // Group nodes by parent_key for O(1) lookups
    let mut children_map: std::collections::HashMap<Option<&str>, Vec<&TreeNode>> =
        std::collections::HashMap::new();
    for node in nodes {
        children_map
            .entry(node.parent_key.as_deref())
            .or_default()
            .push(node);
    }

    // Build tree structure from flat nodes list
    let root = build_tree_node_rich(&nodes[0], &children_map, theme);
    let tree = Tree::new(root)
        .guides(TreeGuides::Rounded)
        .guide_style(theme.dimmed.clone());

    ctx.render(&tree);
}

/// Recursively build a tree node for rich rendering
fn build_tree_node_rich<'a>(
    node: &'a TreeNode,
    children_map: &std::collections::HashMap<Option<&'a str>, Vec<&'a TreeNode>>,
    theme: &Theme,
) -> rich_rust::renderables::TreeNode {
    let mut tree_node = rich_rust::renderables::TreeNode::new(build_tree_node_label(node, theme));

    // Find and add children using the pre-computed map
    if let Some(children) = children_map.get(&Some(node.node_key.as_str())) {
        for child in children {
            let child_node = build_tree_node_rich(child, children_map, theme);
            tree_node = tree_node.child(child_node);
        }
    }

    tree_node
}

fn build_tree_node_label(node: &TreeNode, theme: &Theme) -> Text {
    let mut label = Text::new("");
    label.append_styled(
        sanitize_terminal_inline(&node.id).as_ref(),
        theme.issue_id.clone(),
    );
    label.append(" [");
    label.append_styled(
        sanitize_terminal_inline(&node.status).as_ref(),
        dep_tree_status_style(&node.status, theme),
    );
    label.append("]");
    if let Some(indicator) = dep_tree_status_indicator(&node.status) {
        label.append_styled(indicator, dep_tree_status_style(&node.status, theme));
    }
    label.append(" ");
    label.append_styled(
        &truncate_title(&node.title, if node.truncated { 35 } else { 40 }),
        theme.issue_title.clone(),
    );
    if node.truncated {
        label.append_styled(" (truncated)", theme.dimmed.clone());
    }
    label
}

fn dep_tree_status_style(status: &str, theme: &Theme) -> Style {
    match status {
        "open" => theme.status_open.clone(),
        "in_progress" => theme.status_in_progress.clone(),
        "closed" | "deleted" | "tombstone" => theme.status_closed.clone(),
        "blocked" => theme.status_blocked.clone(),
        "deferred" => theme.status_deferred.clone(),
        _ => theme.muted.clone(),
    }
}

fn dep_tree_status_indicator(status: &str) -> Option<&'static str> {
    match status {
        "closed" => Some(" ✓"),
        "blocked" => Some(" ⚠"),
        _ => None,
    }
}

fn parse_external_dep_id(dep_id: &str) -> Option<(String, String)> {
    let mut parts = dep_id.splitn(3, ':');
    let prefix = parts.next()?;
    if prefix != "external" {
        return None;
    }
    let project = parts.next()?.to_string();
    let capability = parts.next()?.to_string();
    if project.is_empty() || capability.is_empty() {
        return None;
    }
    Some((project, capability))
}

fn dep_cycles(
    args: &DepCyclesArgs,
    storage: &SqliteStorage,
    _json: bool,
    ctx: &OutputContext,
) -> Result<()> {
    let report = storage.detect_dependency_cycle_report(args.blocking_only)?;
    let active_count = report.active_cycles.len();
    let archived_closed_count = report.archived_closed_cycles.len();
    let total_count = active_count + archived_closed_count;
    let mut cycles = report.active_cycles.clone();
    let mut active_cycles = Vec::new();
    let mut archived_closed_cycles = Vec::new();

    if args.include_closed {
        active_cycles.clone_from(&report.active_cycles);
        archived_closed_cycles.clone_from(&report.archived_closed_cycles);
        cycles.extend(report.archived_closed_cycles.clone());
        cycles.sort();
    }
    let count = cycles.len();
    let scope = if args.include_closed {
        "active_and_archived"
    } else {
        "active"
    };

    if ctx.is_json() || ctx.is_toon() {
        let result = CyclesResult {
            cycles,
            count,
            active_count,
            archived_closed_count,
            total_count,
            blocking_only: args.blocking_only,
            include_closed: args.include_closed,
            scope,
            active_cycles,
            archived_closed_cycles,
        };
        if ctx.is_toon() {
            ctx.toon(&result);
        } else {
            ctx.json_pretty(&result);
        }
        return Ok(());
    }

    if matches!(ctx.mode(), OutputMode::Quiet) {
        return Ok(());
    }

    let cycle_scope = cycle_scope_label(args.blocking_only);
    if count == 0 {
        if archived_closed_count > 0 && !args.include_closed {
            ctx.success(&format!(
                "No active {cycle_scope} cycles detected. {archived_closed_count} archived closed-only cycle(s) hidden; rerun with --include-closed to inspect them."
            ));
        } else {
            ctx.success(&format!("No {cycle_scope} cycles detected."));
        }
    } else if ctx.is_rich() {
        // Rich mode: Show cycles with red highlighting in a panel
        render_cycles_rich(ctx, &cycles, count, args.blocking_only);
    } else {
        // Plain mode: Simple text output
        ctx.warning(&format!("Found {count} {cycle_scope} cycle(s):"));
        for (i, cycle) in cycles.iter().enumerate() {
            ctx.print_line(&format!("  {}. {}", i + 1, format_cycle_plain(cycle)));
        }
    }

    Ok(())
}

/// Render cycles in rich mode with red highlighting
fn render_cycles_rich(
    ctx: &OutputContext,
    cycles: &[Vec<String>],
    count: usize,
    blocking_only: bool,
) {
    let theme = ctx.theme();
    let content = build_cycles_rich_text(cycles, count, theme, blocking_only);
    let title = if blocking_only {
        "Blocking Dependency Cycles"
    } else {
        "Dependency Cycles"
    };
    let panel = Panel::from_rich_text(&content, ctx.width())
        .title(Text::new(title))
        .border_style(theme.error.clone());

    ctx.render(&panel);
}

fn cycle_scope_label(blocking_only: bool) -> &'static str {
    if blocking_only {
        "blocking dependency"
    } else {
        "dependency"
    }
}

fn build_cycles_rich_text(
    cycles: &[Vec<String>],
    count: usize,
    theme: &Theme,
    blocking_only: bool,
) -> Text {
    let mut content = Text::new("");
    let cycle_scope = cycle_scope_label(blocking_only);
    content.append_styled(
        &format!("⚠ {count} {cycle_scope} cycle(s) detected:\n\n"),
        theme.error.clone().bold(),
    );

    for (i, cycle) in cycles.iter().enumerate() {
        content.append_styled(&format!("Cycle {}:\n", i + 1), theme.emphasis.clone());
        content.append("  ");
        append_cycle_path_rich(&mut content, cycle, theme);
        content.append("\n");

        // Add underline visual
        let path_len = format_cycle_plain(cycle).chars().count();
        content.append_styled(
            &format!("  {}\n", "^".repeat(path_len.min(60))),
            theme.error.clone(),
        );

        if i < cycles.len() - 1 {
            content.append("\n");
        }
    }

    content.append("\n");
    content.append_styled(
        "Suggestion: Remove one dependency from each cycle to break it.",
        theme.dimmed.clone(),
    );

    content
}

fn append_cycle_path_rich(content: &mut Text, cycle: &[String], theme: &Theme) {
    for (index, id) in cycle.iter().enumerate() {
        if index > 0 {
            content.append_styled(" → ", theme.error.clone());
        }
        content.append_styled(sanitize_terminal_inline(id).as_ref(), theme.error.clone());
    }
}

fn format_cycle_plain(cycle: &[String]) -> String {
    cycle
        .iter()
        .map(|id| sanitize_terminal_inline(id).into_owned())
        .collect::<Vec<_>>()
        .join(" -> ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::logging::init_test_logging;
    use crate::model::{Issue, IssueType, Priority, Status};
    use chrono::{TimeZone, Utc};
    use std::collections::HashMap;
    use tracing::info;

    fn make_test_issue(id: &str, title: &str) -> Issue {
        Issue {
            id: id.to_string(),
            content_hash: None,
            title: title.to_string(),
            description: None,
            design: None,
            acceptance_criteria: None,
            notes: None,
            status: Status::Open,
            priority: Priority::MEDIUM,
            issue_type: IssueType::Task,
            assignee: None,
            owner: None,
            estimated_minutes: None,
            created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
            created_by: None,
            updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
            closed_at: None,
            close_reason: None,
            closed_by_session: None,
            due_at: None,
            defer_until: None,
            external_ref: None,
            source_system: None,
            source_repo: None,
            source_repo_path: None,
            deleted_at: None,
            deleted_by: None,
            delete_reason: None,
            original_type: None,
            compaction_level: None,
            compacted_at: None,
            compacted_at_commit: None,
            original_size: None,
            sender: None,
            ephemeral: false,
            pinned: false,
            is_template: false,
            labels: vec![],
            dependencies: vec![],
            comments: vec![],
        }
    }

    fn test_dep_list_item(issue_id: &str, depends_on_id: &str, priority: i32) -> DepListItem {
        DepListItem {
            issue_id: issue_id.to_string(),
            depends_on_id: depends_on_id.to_string(),
            dep_type: "blocks".to_string(),
            title: depends_on_id.to_string(),
            status: "open".to_string(),
            priority,
        }
    }

    #[test]
    fn test_dependency_type_parsing() {
        init_test_logging();
        info!("test_dependency_type_parsing: starting");
        assert_eq!(
            "blocks".parse::<DependencyType>().unwrap(),
            DependencyType::Blocks
        );
        assert_eq!(
            "parent-child".parse::<DependencyType>().unwrap(),
            DependencyType::ParentChild
        );
        assert_eq!(
            "related".parse::<DependencyType>().unwrap(),
            DependencyType::Related
        );
        assert_eq!(
            "duplicates".parse::<DependencyType>().unwrap(),
            DependencyType::Duplicates
        );
        info!("test_dependency_type_parsing: assertions passed");
    }

    #[test]
    fn test_blocking_dependency_types() {
        init_test_logging();
        info!("test_blocking_dependency_types: starting");
        assert!(DependencyType::Blocks.is_blocking());
        assert!(DependencyType::ParentChild.is_blocking());
        assert!(!DependencyType::Related.is_blocking());
        assert!(!DependencyType::Duplicates.is_blocking());
        info!("test_blocking_dependency_types: assertions passed");
    }

    #[test]
    fn test_normalize_dep_type_filter_canonicalizes_standard_types() {
        assert_eq!(
            normalize_dep_type_filter("Parent-Child").unwrap(),
            "parent-child"
        );
        assert_eq!(normalize_dep_type_filter("BLOCKS").unwrap(), "blocks");
    }

    #[test]
    fn test_normalize_dep_type_filter_rejects_unknown_types() {
        let err = normalize_dep_type_filter("parent_child").unwrap_err();
        assert!(matches!(err, BeadsError::Validation { field, .. } if field == "type"));
    }

    #[test]
    fn test_add_dependency() {
        init_test_logging();
        info!("test_add_dependency: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        let issue1 = make_test_issue("bd-001", "Issue 1");
        let issue2 = make_test_issue("bd-002", "Issue 2");
        storage.create_issue(&issue1, "tester").unwrap();
        storage.create_issue(&issue2, "tester").unwrap();

        // Add dependency: bd-001 depends on bd-002 (blocks)
        let added = storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();
        assert!(added);

        // Adding same dependency again should return false
        let added_again = storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();
        assert!(!added_again);
        info!("test_add_dependency: assertions passed");
    }

    #[test]
    fn test_remove_dependency() {
        init_test_logging();
        info!("test_remove_dependency: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        let issue1 = make_test_issue("bd-001", "Issue 1");
        let issue2 = make_test_issue("bd-002", "Issue 2");
        storage.create_issue(&issue1, "tester").unwrap();
        storage.create_issue(&issue2, "tester").unwrap();

        storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();

        let removed = storage
            .remove_dependency("bd-001", "bd-002", "tester")
            .unwrap();
        assert!(removed);

        // Removing again should return false
        let removed_again = storage
            .remove_dependency("bd-001", "bd-002", "tester")
            .unwrap();
        assert!(!removed_again);
        info!("test_remove_dependency: assertions passed");
    }

    #[test]
    fn test_get_dependencies() {
        init_test_logging();
        info!("test_get_dependencies: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        let issue1 = make_test_issue("bd-001", "Issue 1");
        let issue2 = make_test_issue("bd-002", "Issue 2");
        let issue3 = make_test_issue("bd-003", "Issue 3");
        storage.create_issue(&issue1, "tester").unwrap();
        storage.create_issue(&issue2, "tester").unwrap();
        storage.create_issue(&issue3, "tester").unwrap();

        // bd-001 depends on bd-002 and bd-003
        storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();
        storage
            .add_dependency("bd-001", "bd-003", "blocks", "tester")
            .unwrap();

        let deps = storage.get_dependencies("bd-001").unwrap();
        assert_eq!(deps.len(), 2);
        assert!(deps.contains(&"bd-002".to_string()));
        assert!(deps.contains(&"bd-003".to_string()));
        info!("test_get_dependencies: assertions passed");
    }

    #[test]
    fn test_get_dependents() {
        init_test_logging();
        info!("test_get_dependents: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        let issue1 = make_test_issue("bd-001", "Issue 1");
        let issue2 = make_test_issue("bd-002", "Issue 2");
        let issue3 = make_test_issue("bd-003", "Issue 3");
        storage.create_issue(&issue1, "tester").unwrap();
        storage.create_issue(&issue2, "tester").unwrap();
        storage.create_issue(&issue3, "tester").unwrap();

        // bd-002 and bd-003 depend on bd-001
        storage
            .add_dependency("bd-002", "bd-001", "blocks", "tester")
            .unwrap();
        storage
            .add_dependency("bd-003", "bd-001", "blocks", "tester")
            .unwrap();

        let dependents = storage.get_dependents("bd-001").unwrap();
        assert_eq!(dependents.len(), 2);
        assert!(dependents.contains(&"bd-002".to_string()));
        assert!(dependents.contains(&"bd-003".to_string()));
        info!("test_get_dependents: assertions passed");
    }

    #[test]
    fn test_dep_tree_adjacency_prefetch_matches_direct_queries() {
        init_test_logging();
        info!("test_dep_tree_adjacency_prefetch_matches_direct_queries: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        for issue in [
            make_test_issue("bd-001", "Issue 1"),
            make_test_issue("bd-002", "Issue 2"),
            make_test_issue("bd-003", "Issue 3"),
            make_test_issue("bd-004", "Issue 4"),
        ] {
            storage.create_issue(&issue, "tester").unwrap();
        }

        storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();
        storage
            .add_dependency("bd-001", "bd-003", "related", "tester")
            .unwrap();
        storage
            .add_dependency("bd-004", "bd-001", "blocks", "tester")
            .unwrap();

        let (dependencies_by_issue, dependents_by_issue) =
            load_dep_tree_adjacency(&storage).unwrap();

        let mut direct_down = storage.get_dependencies("bd-001").unwrap();
        direct_down.sort();
        let down = dep_tree_neighbors(
            DepDirection::Down,
            "bd-001",
            &dependencies_by_issue,
            &dependents_by_issue,
        );
        assert_eq!(down, direct_down);

        let mut direct_up = storage.get_dependents("bd-001").unwrap();
        direct_up.sort();
        let up = dep_tree_neighbors(
            DepDirection::Up,
            "bd-001",
            &dependencies_by_issue,
            &dependents_by_issue,
        );
        assert_eq!(up, direct_up);

        let both = dep_tree_neighbors(
            DepDirection::Both,
            "bd-001",
            &dependencies_by_issue,
            &dependents_by_issue,
        );
        assert_eq!(
            both,
            vec![
                "bd-002".to_string(),
                "bd-003".to_string(),
                "bd-004".to_string(),
            ]
        );
        info!("test_dep_tree_adjacency_prefetch_matches_direct_queries: assertions passed");
    }

    fn dep_tree_test_args(issue: &str, direction: DepDirection, max_depth: usize) -> DepTreeArgs {
        DepTreeArgs {
            issue: issue.to_string(),
            direction,
            max_depth,
            format: "text".to_string(),
        }
    }

    type TreeNodeProjection = (
        String,
        String,
        String,
        usize,
        Option<String>,
        Option<String>,
        i32,
        String,
        bool,
    );

    fn tree_node_projection(nodes: &[TreeNode]) -> Vec<TreeNodeProjection> {
        nodes
            .iter()
            .map(|node| {
                (
                    node.node_key.clone(),
                    node.id.clone(),
                    node.title.clone(),
                    node.depth,
                    node.parent_id.clone(),
                    node.parent_key.clone(),
                    node.priority,
                    node.status.clone(),
                    node.truncated,
                )
            })
            .collect()
    }

    #[test]
    fn test_dep_tree_local_traversal_matches_global_nodes() {
        init_test_logging();
        info!("test_dep_tree_local_traversal_matches_global_nodes: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        for issue in [
            make_test_issue("bd-001", "Issue 1"),
            make_test_issue("bd-002", "Issue 2"),
            make_test_issue("bd-003", "Issue 3"),
            make_test_issue("bd-004", "Issue 4"),
        ] {
            storage.create_issue(&issue, "tester").unwrap();
        }
        let mut low_priority = make_test_issue("bd-005", "Issue 5");
        low_priority.priority = Priority(3);
        storage.create_issue(&low_priority, "tester").unwrap();

        storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();
        storage
            .add_dependency("bd-001", "bd-003", "related", "tester")
            .unwrap();
        storage
            .add_dependency("bd-001", "bd-005", "blocks", "tester")
            .unwrap();
        storage
            .add_dependency("bd-001", "external:ext:cap", "blocks", "tester")
            .unwrap();
        storage
            .add_dependency("bd-004", "bd-001", "blocks", "tester")
            .unwrap();

        let args = dep_tree_test_args("bd-001", DepDirection::Both, 2);
        let root_issue = storage.get_issue("bd-001").unwrap().unwrap();
        let external_statuses = HashMap::new();
        let local = try_build_dep_tree_nodes_local(
            &args,
            &storage,
            "bd-001",
            &root_issue,
            &external_statuses,
        )
        .unwrap()
        .expect("small tree should use local traversal");
        let global =
            build_dep_tree_nodes_global(&args, &storage, "bd-001", &root_issue, &external_statuses)
                .unwrap();

        assert_eq!(tree_node_projection(&local), tree_node_projection(&global));
        info!("test_dep_tree_local_traversal_matches_global_nodes: assertions passed");
    }

    #[test]
    fn test_dep_tree_local_traversal_falls_back_for_wide_roots() {
        init_test_logging();
        info!("test_dep_tree_local_traversal_falls_back_for_wide_roots: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        let root = make_test_issue("bd-root", "Root");
        storage.create_issue(&root, "tester").unwrap();
        for index in 0..LOCAL_DEP_TREE_NODE_LIMIT {
            let child_id = format!("bd-child-{index:03}");
            let child = make_test_issue(&child_id, &format!("Child {index:03}"));
            storage.create_issue(&child, "tester").unwrap();
            storage
                .add_dependency("bd-root", &child_id, "blocks", "tester")
                .unwrap();
        }

        let args = dep_tree_test_args("bd-root", DepDirection::Down, 10);
        let root_issue = storage.get_issue("bd-root").unwrap().unwrap();
        let external_statuses = HashMap::new();
        let local = try_build_dep_tree_nodes_local(
            &args,
            &storage,
            "bd-root",
            &root_issue,
            &external_statuses,
        )
        .unwrap();

        assert!(local.is_none());
        info!("test_dep_tree_local_traversal_falls_back_for_wide_roots: assertions passed");
    }

    #[test]
    fn test_cycle_detection_simple() {
        init_test_logging();
        info!("test_cycle_detection_simple: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        let issue1 = make_test_issue("bd-001", "Issue 1");
        let issue2 = make_test_issue("bd-002", "Issue 2");
        storage.create_issue(&issue1, "tester").unwrap();
        storage.create_issue(&issue2, "tester").unwrap();

        // bd-001 depends on bd-002
        storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();

        // bd-002 depends on bd-001 would create a cycle
        let would_cycle = storage
            .would_create_cycle("bd-002", "bd-001", true)
            .unwrap();
        assert!(would_cycle);
        info!("test_cycle_detection_simple: assertions passed");
    }

    #[test]
    fn test_cycle_detection_transitive() {
        init_test_logging();
        info!("test_cycle_detection_transitive: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        let issue1 = make_test_issue("bd-001", "Issue 1");
        let issue2 = make_test_issue("bd-002", "Issue 2");
        let issue3 = make_test_issue("bd-003", "Issue 3");
        storage.create_issue(&issue1, "tester").unwrap();
        storage.create_issue(&issue2, "tester").unwrap();
        storage.create_issue(&issue3, "tester").unwrap();

        // bd-001 -> bd-002 -> bd-003
        storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();
        storage
            .add_dependency("bd-002", "bd-003", "blocks", "tester")
            .unwrap();

        // bd-003 -> bd-001 would create a cycle
        let would_cycle = storage
            .would_create_cycle("bd-003", "bd-001", true)
            .unwrap();
        assert!(would_cycle);

        // bd-003 -> bd-002 would also create a cycle
        let would_cycle = storage
            .would_create_cycle("bd-003", "bd-002", true)
            .unwrap();
        assert!(would_cycle);
        info!("test_cycle_detection_transitive: assertions passed");
    }

    #[test]
    fn test_no_false_positive_cycle() {
        init_test_logging();
        info!("test_no_false_positive_cycle: starting");
        let mut storage = SqliteStorage::open_memory().unwrap();

        let issue1 = make_test_issue("bd-001", "Issue 1");
        let issue2 = make_test_issue("bd-002", "Issue 2");
        let issue3 = make_test_issue("bd-003", "Issue 3");
        storage.create_issue(&issue1, "tester").unwrap();
        storage.create_issue(&issue2, "tester").unwrap();
        storage.create_issue(&issue3, "tester").unwrap();

        // bd-001 -> bd-002
        storage
            .add_dependency("bd-001", "bd-002", "blocks", "tester")
            .unwrap();

        // bd-003 -> bd-002 should NOT be a cycle
        let would_cycle = storage
            .would_create_cycle("bd-003", "bd-002", true)
            .unwrap();
        assert!(!would_cycle);
        info!("test_no_false_positive_cycle: assertions passed");
    }

    #[test]
    fn test_dep_action_result_json() {
        init_test_logging();
        info!("test_dep_action_result_json: starting");
        let result = DepActionResult {
            status: "ok".to_string(),
            issue_id: "bd-001".to_string(),
            depends_on_id: "bd-002".to_string(),
            dep_type: "blocks".to_string(),
            action: "added".to_string(),
        };

        let json = serde_json::to_string(&result).unwrap();
        assert!(json.contains("\"status\":\"ok\""));
        assert!(json.contains("\"issue_id\":\"bd-001\""));
        assert!(json.contains("\"type\":\"blocks\"")); // Note: renamed field
        info!("test_dep_action_result_json: assertions passed");
    }

    #[test]
    fn test_dep_list_item_json() {
        init_test_logging();
        info!("test_dep_list_item_json: starting");
        let item = DepListItem {
            issue_id: "bd-001".to_string(),
            depends_on_id: "bd-002".to_string(),
            dep_type: "blocks".to_string(),
            title: "Test Issue".to_string(),
            status: "open".to_string(),
            priority: 2,
        };

        let json = serde_json::to_string(&item).unwrap();
        assert!(json.contains("\"type\":\"blocks\"")); // Renamed field
        assert!(json.contains("\"priority\":2"));
        info!("test_dep_list_item_json: assertions passed");
    }

    #[test]
    fn test_sort_dep_list_items_for_human_orders_by_priority() {
        init_test_logging();
        info!("test_sort_dep_list_items_for_human_orders_by_priority: starting");
        let mut items = vec![
            test_dep_list_item("bd-root", "bd-low", 4),
            test_dep_list_item("bd-root", "bd-critical", 0),
            test_dep_list_item("bd-root", "bd-medium", 2),
        ];

        sort_dep_list_items_for_human(&mut items);

        let sorted_ids: Vec<_> = items
            .iter()
            .map(|item| item.depends_on_id.as_str())
            .collect();
        assert_eq!(sorted_ids, ["bd-critical", "bd-medium", "bd-low"]);
        info!("test_sort_dep_list_items_for_human_orders_by_priority: assertions passed");
    }

    #[test]
    fn test_sort_dep_list_items_for_human_uses_ids_as_tiebreakers() {
        init_test_logging();
        info!("test_sort_dep_list_items_for_human_uses_ids_as_tiebreakers: starting");
        let mut items = vec![
            test_dep_list_item("bd-root", "bd-b", 1),
            test_dep_list_item("bd-root", "bd-a", 1),
            test_dep_list_item("bd-root", "bd-c", 1),
        ];

        sort_dep_list_items_for_human(&mut items);

        let sorted_ids: Vec<_> = items
            .iter()
            .map(|item| item.depends_on_id.as_str())
            .collect();
        assert_eq!(sorted_ids, ["bd-a", "bd-b", "bd-c"]);
        info!("test_sort_dep_list_items_for_human_uses_ids_as_tiebreakers: assertions passed");
    }

    #[test]
    fn test_cycles_result_json() {
        init_test_logging();
        info!("test_cycles_result_json: starting");
        let result = CyclesResult {
            cycles: vec![
                vec!["bd-001".to_string(), "bd-002".to_string()],
                vec![
                    "bd-003".to_string(),
                    "bd-004".to_string(),
                    "bd-005".to_string(),
                ],
            ],
            count: 2,
            active_count: 2,
            archived_closed_count: 0,
            total_count: 2,
            blocking_only: false,
            include_closed: false,
            scope: "active",
            active_cycles: Vec::new(),
            archived_closed_cycles: Vec::new(),
        };

        let json = serde_json::to_string(&result).unwrap();
        assert!(json.contains("\"count\":2"));
        assert!(json.contains("bd-001"));
        info!("test_cycles_result_json: assertions passed");
    }

    #[test]
    fn dep_cycles_human_output_sanitizes_ids_and_omits_literal_markup() {
        let cycles = vec![vec!["bd-a\x1b[2J".to_string(), "bd-b\x07bell".to_string()]];

        let plain = format_cycle_plain(&cycles[0]);
        assert!(!plain.contains('\x1b'));
        assert!(!plain.contains('\x07'));
        assert!(plain.contains("bd-a\\u{1b}[2J -> bd-b\\u{7}bell"));

        let theme = Theme::default();
        let rich_text = build_cycles_rich_text(&cycles, 1, &theme, false);
        let rendered = Panel::from_rich_text(&rich_text, 100).render_plain(100);

        assert!(!rendered.contains("[bold"));
        assert!(!rendered.contains("[red"));
        assert!(!rendered.contains("[/]"));
        assert!(!rendered.contains('\x1b'));
        assert!(!rendered.contains('\x07'));
        assert!(rendered.contains("bd-a\\u{1b}[2J"));
        assert!(rendered.contains("bd-b\\u{7}bell"));
        assert!(rich_text.spans().len() > 1, "rich text should carry styles");

        let blocking_rich_text = build_cycles_rich_text(&cycles, 1, &theme, true);
        let blocking_rendered = Panel::from_rich_text(&blocking_rich_text, 100).render_plain(100);

        assert!(blocking_rendered.contains("blocking dependency cycle(s)"));
    }

    #[test]
    fn dep_display_and_mermaid_labels_escape_terminal_controls() {
        let display = dep_display_text("bd-a\x1b]52;c;bad\x07");
        assert!(!display.chars().any(char::is_control));
        assert_eq!(display, "bd-a\\u{1b}]52;c;bad\\u{7}");

        let mermaid = sanitize_mermaid_label("bd-a\x1b[2J\n\"quoted\"\r\x07");
        assert!(!mermaid.chars().any(char::is_control));
        assert!(mermaid.contains("bd-a\\u{1b}[2J\\n'quoted'\\r\\u{7}"));
    }

    #[test]
    fn dep_list_human_output_sanitizes_relation_ids() {
        let item = DepListItem {
            issue_id: "bd-parent\x1b[2J".to_string(),
            depends_on_id: "external:proj:\x07cap".to_string(),
            dep_type: "blocks\x1b[type".to_string(),
            title: "Title\x1b[31m".to_string(),
            status: "custom\x07status".to_string(),
            priority: 1,
        };
        let refs = vec![&item];
        let theme = Theme::default();
        let mut content = Text::new("");

        append_dep_list_section(&mut content, "Dependencies (1):", &refs, true, &theme);

        let rendered = content.plain();
        assert!(!rendered.contains('\x1b'));
        assert!(!rendered.contains('\x07'));
        assert!(rendered.contains("external:proj:\\u{7}cap"));
        assert!(rendered.contains("blocks\\u{1b}[type"));
        assert!(rendered.contains("Title\\u{1b}[31m"));
        assert!(rendered.contains("custom\\u{7}status"));

        let title = dep_list_panel_title(DepDirection::Down, "bd-root\x1b[2J");
        assert!(!title.contains('\x1b'));
        assert!(title.contains("bd-root\\u{1b}[2J"));
    }

    #[test]
    fn dep_tree_rich_label_sanitizes_text_and_omits_literal_markup() {
        let node = TreeNode {
            node_key: "n1".to_string(),
            id: "bd-node\x1b[2J".to_string(),
            title: "Tree title\x07bell".to_string(),
            depth: 0,
            parent_id: None,
            parent_key: None,
            priority: 1,
            status: "blocked".to_string(),
            truncated: true,
        };
        let theme = Theme::default();
        let label = build_tree_node_label(&node, &theme);
        let rendered = label.plain();

        assert!(!rendered.contains("[red]"));
        assert!(!rendered.contains("[/]"));
        assert!(!rendered.contains('\x1b'));
        assert!(!rendered.contains('\x07'));
        assert!(rendered.contains("bd-node\\u{1b}[2J"));
        assert!(rendered.contains("Tree title\\u{7}bell"));
        assert!(rendered.contains("[blocked] ⚠"));
        assert!(rendered.contains("(truncated)"));
        assert!(label.spans().len() > 1, "tree label should carry styles");
    }

    #[test]
    fn test_external_dependency_prefix_check() {
        init_test_logging();
        info!("test_external_dependency_prefix_check: starting");
        let external = "external:jira-123";
        assert!(external.starts_with("external:"));

        let normal = "bd-001";
        assert!(!normal.starts_with("external:"));
        info!("test_external_dependency_prefix_check: assertions passed");
    }

    #[test]
    fn test_dep_direction_default() {
        init_test_logging();
        info!("test_dep_direction_default: starting");
        let direction = DepDirection::default();
        assert_eq!(direction, DepDirection::Down);
        info!("test_dep_direction_default: assertions passed");
    }

    #[test]
    fn test_apply_external_dep_list_metadata_sets_status_and_title() {
        init_test_logging();
        info!("test_apply_external_dep_list_metadata_sets_status_and_title: starting");
        let mut items = vec![
            DepListItem {
                issue_id: "bd-001".to_string(),
                depends_on_id: "external:proj:cap".to_string(),
                dep_type: "blocks".to_string(),
                title: String::new(),
                status: "open".to_string(),
                priority: 2,
            },
            DepListItem {
                issue_id: "bd-002".to_string(),
                depends_on_id: "external:proj:cap2".to_string(),
                dep_type: "blocks".to_string(),
                title: String::new(),
                status: "open".to_string(),
                priority: 2,
            },
        ];

        let mut statuses = HashMap::new();
        statuses.insert("external:proj:cap".to_string(), true);
        statuses.insert("external:proj:cap2".to_string(), false);

        apply_external_dep_list_metadata(&mut items, &statuses);

        assert_eq!(items[0].status, "closed");
        assert_eq!(items[0].title, "✓ proj:cap");
        assert_eq!(items[1].status, "blocked");
        assert_eq!(items[1].title, "⏳ proj:cap2");
        info!("test_apply_external_dep_list_metadata_sets_status_and_title: assertions passed");
    }

    #[test]
    fn test_apply_external_dep_list_metadata_preserves_title() {
        init_test_logging();
        info!("test_apply_external_dep_list_metadata_preserves_title: starting");
        let mut items = vec![DepListItem {
            issue_id: "bd-001".to_string(),
            depends_on_id: "external:proj:cap".to_string(),
            dep_type: "blocks".to_string(),
            title: "Already set".to_string(),
            status: "open".to_string(),
            priority: 2,
        }];
        let mut statuses = HashMap::new();
        statuses.insert("external:proj:cap".to_string(), false);

        apply_external_dep_list_metadata(&mut items, &statuses);

        assert_eq!(items[0].status, "blocked");
        assert_eq!(items[0].title, "Already set");
        info!("test_apply_external_dep_list_metadata_preserves_title: assertions passed");
    }

    #[test]
    fn test_apply_external_dep_list_metadata_rewrites_generated_placeholder_title() {
        init_test_logging();
        info!(
            "test_apply_external_dep_list_metadata_rewrites_generated_placeholder_title: starting"
        );
        let mut items = vec![DepListItem {
            issue_id: "bd-001".to_string(),
            depends_on_id: "external:proj:cap".to_string(),
            dep_type: "blocks".to_string(),
            title: "proj:cap".to_string(),
            status: "open".to_string(),
            priority: 2,
        }];
        let mut statuses = HashMap::new();
        statuses.insert("external:proj:cap".to_string(), false);

        apply_external_dep_list_metadata(&mut items, &statuses);

        assert_eq!(items[0].status, "blocked");
        assert_eq!(items[0].title, "⏳ proj:cap");
        info!(
            "test_apply_external_dep_list_metadata_rewrites_generated_placeholder_title: assertions passed"
        );
    }

    #[test]
    fn test_apply_external_dep_list_metadata_external_issue_id() {
        init_test_logging();
        info!("test_apply_external_dep_list_metadata_external_issue_id: starting");
        let mut items = vec![DepListItem {
            issue_id: "external:proj:cap".to_string(),
            depends_on_id: "bd-001".to_string(),
            dep_type: "blocks".to_string(),
            title: String::new(),
            status: "open".to_string(),
            priority: 2,
        }];
        let mut statuses = HashMap::new();
        statuses.insert("external:proj:cap".to_string(), true);

        apply_external_dep_list_metadata(&mut items, &statuses);

        assert_eq!(items[0].status, "closed");
        assert_eq!(items[0].title, "✓ proj:cap");
        info!("test_apply_external_dep_list_metadata_external_issue_id: assertions passed");
    }

    #[test]
    fn test_dep_list_section_title_uses_neutral_dependents_label() {
        init_test_logging();
        info!("test_dep_list_section_title_uses_neutral_dependents_label: starting");
        assert_eq!(dep_list_section_title(true, 2), "Dependencies (2):");
        assert_eq!(dep_list_section_title(false, 3), "Dependents (3):");
        info!("test_dep_list_section_title_uses_neutral_dependents_label: assertions passed");
    }

    #[test]
    fn test_dep_list_panel_title_matches_direction() {
        init_test_logging();
        info!("test_dep_list_panel_title_matches_direction: starting");
        assert_eq!(
            dep_list_panel_title(DepDirection::Down, "bd-1"),
            "Dependencies for bd-1"
        );
        assert_eq!(
            dep_list_panel_title(DepDirection::Up, "bd-1"),
            "Dependents for bd-1"
        );
        assert_eq!(
            dep_list_panel_title(DepDirection::Both, "bd-1"),
            "Dependency relations for bd-1"
        );
        info!("test_dep_list_panel_title_matches_direction: assertions passed");
    }

    #[test]
    fn test_dep_list_status_label_formats_known_statuses() {
        init_test_logging();
        info!("test_dep_list_status_label_formats_known_statuses: starting");
        assert_eq!(dep_list_status_label("open"), "[open]");
        assert_eq!(dep_list_status_label("closed"), "[closed] ✓");
        assert_eq!(dep_list_status_label("custom"), "custom");
        info!("test_dep_list_status_label_formats_known_statuses: assertions passed");
    }

    #[test]
    fn test_dep_tree_truncated_only_when_children_are_omitted() {
        init_test_logging();
        info!("test_dep_tree_truncated_only_when_children_are_omitted: starting");
        assert!(!dep_tree_truncated(2, 2, 0));
        assert!(dep_tree_truncated(2, 2, 1));
        assert!(!dep_tree_truncated(1, 2, 3));
        info!("test_dep_tree_truncated_only_when_children_are_omitted: assertions passed");
    }

    #[test]
    fn test_sort_dep_tree_siblings_uses_metadata_cache() {
        init_test_logging();
        info!("test_sort_dep_tree_siblings_uses_metadata_cache: starting");
        let mut dependencies = vec![
            "bd-low".to_string(),
            "bd-missing".to_string(),
            "bd-active".to_string(),
            "bd-alpha".to_string(),
            "bd-high".to_string(),
        ];
        let mut metadata_cache = HashMap::new();
        metadata_cache.insert(
            "bd-low".to_string(),
            ("Low priority".to_string(), 3, "open".to_string()),
        );
        metadata_cache.insert(
            "bd-high".to_string(),
            ("High priority".to_string(), 0, "open".to_string()),
        );
        metadata_cache.insert(
            "bd-active".to_string(),
            ("Active task".to_string(), 1, "in_progress".to_string()),
        );
        metadata_cache.insert(
            "bd-alpha".to_string(),
            ("Alpha task".to_string(), 1, "open".to_string()),
        );

        sort_dep_tree_siblings(&mut dependencies, &metadata_cache);

        assert_eq!(
            dependencies,
            vec![
                "bd-high".to_string(),
                "bd-alpha".to_string(),
                "bd-active".to_string(),
                "bd-low".to_string(),
                "bd-missing".to_string(),
            ]
        );
        info!("test_sort_dep_tree_siblings_uses_metadata_cache: assertions passed");
    }

    #[test]
    fn test_resolve_dep_tree_node_metadata_missing_internal_issue() {
        init_test_logging();
        info!("test_resolve_dep_tree_node_metadata_missing_internal_issue: starting");
        let storage = SqliteStorage::open_memory().unwrap();
        let root_issue = make_test_issue("bd-root", "Root");
        let statuses = HashMap::new();

        let (title, priority, status) = resolve_dep_tree_node_metadata(
            &storage,
            "bd-root",
            &root_issue,
            "bd-missing",
            &statuses,
        )
        .unwrap();

        assert_eq!(title, "[missing issue: bd-missing]");
        assert_eq!(priority, 2);
        assert_eq!(status, "deleted");
        info!("test_resolve_dep_tree_node_metadata_missing_internal_issue: assertions passed");
    }

    #[test]
    fn test_dep_direction_variants() {
        init_test_logging();
        info!("test_dep_direction_variants: starting");
        assert!(matches!(DepDirection::Down, DepDirection::Down));
        assert!(matches!(DepDirection::Up, DepDirection::Up));
        assert!(matches!(DepDirection::Both, DepDirection::Both));
        info!("test_dep_direction_variants: assertions passed");
    }
}
