use super::*;
use crate::config::interpolation::InterpolationContext;
use crate::config::schema::StepOverride;
use crate::config::BivvyConfig;
use crate::logging::EventBus;
use crate::runner::display::MockWorkflowDisplay;
use crate::state::SatisfactionCache;
use crate::steps::{
    ResolvedBehavior, ResolvedEnvironmentVars, ResolvedExecution, ResolvedHooks, ResolvedOutput,
    ResolvedScoping, ResolvedStep,
};
use crate::ui::MockUI;
use std::collections::HashMap;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;

fn make_step(name: &str, command: &str, depends_on: Vec<String>) -> ResolvedStep {
    ResolvedStep {
        name: name.to_string(),
        title: name.to_string(),
        description: None,
        depends_on,
        requires: vec![],
        inputs: HashMap::new(),
        satisfied_when: vec![],
        execution: ResolvedExecution {
            command: command.to_string(),
            ..Default::default()
        },
        env_vars: ResolvedEnvironmentVars::default(),
        behavior: ResolvedBehavior::default(),
        hooks: ResolvedHooks::default(),
        output: ResolvedOutput::default(),
        scoping: ResolvedScoping::default(),
    }
}

#[test]
fn workflow_result_aborted_defaults_false() {
    let result = WorkflowResult {
        workflow: "default".to_string(),
        steps: vec![],
        skipped: vec![],
        duration: Duration::from_secs(0),
        success: true,
        aborted: false,
    };
    assert!(!result.aborted);
}

#[test]
fn run_workflow_in_order() {
    let temp = TempDir::new().unwrap();
    let order_file = temp.path().join("order.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [first, second]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "first".to_string(),
        make_step(
            "first",
            &format!("echo first >> {}", order_file.display()),
            vec![],
        ),
    );
    steps.insert(
        "second".to_string(),
        make_step(
            "second",
            &format!("echo second >> {}", order_file.display()),
            vec!["first".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    assert!(result.success);

    let content = fs::read_to_string(&order_file).unwrap();
    let lines: Vec<_> = content.lines().map(|l| l.trim()).collect();
    assert_eq!(lines, vec!["first", "second"]);
}

#[test]
fn run_workflow_with_skip() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [first, second]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "first".to_string(),
        make_step("first", "echo first", vec![]),
    );
    steps.insert(
        "second".to_string(),
        make_step("second", "echo second", vec!["first".to_string()]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);

    let options = RunOptions {
        skip: {
            let mut s = HashSet::new();
            s.insert("first".to_string());
            s
        },
        skip_behavior: SkipBehavior::SkipWithDependents,
        ..Default::default()
    };

    let ctx = InterpolationContext::new();
    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    assert!(result.skipped.contains(&"first".to_string()));
    assert!(result.skipped.contains(&"second".to_string()));
}

#[test]
fn dry_run_does_not_execute() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("ran.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [test]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "test".to_string(),
        make_step("test", &format!("touch {}", marker.display()), vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);

    let options = RunOptions {
        dry_run: true,
        ..Default::default()
    };

    let ctx = InterpolationContext::new();
    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    assert!(result.success);
    assert!(!marker.exists());
}

#[test]
fn run_with_progress_emits_events() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [step_a]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "step_a".to_string(),
        make_step("step_a", "echo hello", vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut events = Vec::new();
    let result = runner
        .run_with_progress(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            None,
            None,
            |progress| match &progress {
                RunProgress::StepStarting { name, .. } => {
                    events.push(format!("start:{}", name));
                }
                RunProgress::StepFinished { name, .. } => {
                    events.push(format!("finish:{}", name));
                }
                RunProgress::StepSkipped { name } => {
                    events.push(format!("skip:{}", name));
                }
            },
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert_eq!(events, vec!["start:step_a", "finish:step_a"]);
}

#[test]
fn run_with_progress_reports_skips() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [first, second]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "first".to_string(),
        make_step("first", "echo first", vec![]),
    );
    steps.insert(
        "second".to_string(),
        make_step("second", "echo second", vec!["first".to_string()]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        skip: {
            let mut s = HashSet::new();
            s.insert("first".to_string());
            s
        },
        skip_behavior: SkipBehavior::SkipWithDependents,
        ..Default::default()
    };

    let ctx = InterpolationContext::new();
    let mut skipped_names = Vec::new();
    runner
        .run_with_progress(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            None,
            None,
            |progress| {
                if let RunProgress::StepSkipped { name } = progress {
                    skipped_names.push(name.to_string());
                }
            },
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(skipped_names.contains(&"first".to_string()));
    assert!(skipped_names.contains(&"second".to_string()));
}

#[test]
fn build_graph_validates_workflow() {
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [missing]
        "#,
    )
    .unwrap();

    let steps = HashMap::new();
    let runner = WorkflowRunner::new(&config, steps);

    let result = runner.build_graph("default");
    assert!(result.is_err());
}

fn make_step_with_check(
    name: &str,
    command: &str,
    check: Option<crate::checks::Check>,
) -> ResolvedStep {
    ResolvedStep {
        name: name.to_string(),
        title: name.to_string(),
        description: None,
        depends_on: vec![],
        requires: vec![],
        inputs: HashMap::new(),
        satisfied_when: vec![],
        execution: ResolvedExecution {
            command: command.to_string(),
            check,
            ..Default::default()
        },
        env_vars: ResolvedEnvironmentVars::default(),
        behavior: ResolvedBehavior::default(),
        hooks: ResolvedHooks::default(),
        output: ResolvedOutput::default(),
        scoping: ResolvedScoping::default(),
    }
}

#[test]
fn run_with_ui_executes_simple_step() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("ran.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "hello".to_string(),
        make_step("hello", &format!("touch {}", marker.display()), vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert_eq!(result.steps.len(), 1);
    assert!(!result.steps[0].skipped);
    // Verify the command actually ran
    assert!(
        marker.exists(),
        "step command should have created marker file"
    );
}

#[test]
fn run_with_ui_interactive_no_check_auto_runs() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "hello".to_string(),
        make_step("hello", "echo hello", vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // Steps with no checks auto-run without prompting
    assert!(ui.prompts_shown().is_empty());
    assert_eq!(result.steps.len(), 1);
    assert!(!result.steps[0].skipped);
}

#[test]
fn run_with_ui_incomplete_check_auto_runs() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("ran.txt");
    // Don't create marker.txt — so the check will NOT pass

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "install".to_string(),
        make_step_with_check(
            "install",
            &format!("touch {}", marker.display()),
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("marker.txt".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // Unsatisfied steps auto-run without prompting
    assert!(ui.prompts_shown().is_empty());
    // Verify command actually ran
    assert!(marker.exists(), "step should run when check does not pass");
}

#[test]
fn run_with_ui_auto_skips_when_satisfied() {
    let temp = TempDir::new().unwrap();
    // Create the file so the check passes
    fs::write(temp.path().join("marker.txt"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "install".to_string(),
        make_step_with_check(
            "install",
            "echo installed",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("marker.txt".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // Satisfied steps auto-skip without prompting
    assert!(ui.prompts_shown().is_empty());
    // Step should be check-passed (not skipped) since check succeeded
    assert_eq!(result.steps.len(), 1);
    assert!(!result.steps[0].skipped);
    assert!(result.steps[0].check_result.is_some());
}

#[test]
fn run_with_ui_force_reruns_satisfied_step() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("marker.txt"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "install".to_string(),
        make_step_with_check(
            "install",
            "echo reinstalled",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("marker.txt".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let mut options = RunOptions::default();
    options.force.insert("install".to_string());
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // No prompts — force bypasses satisfaction and auto-runs
    assert!(ui.prompts_shown().is_empty());
    // Step should have run (not skipped)
    assert_eq!(result.steps.len(), 1);
    assert!(!result.steps[0].skipped);
}

#[test]
fn run_with_ui_force_all_reruns_every_satisfied_step() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("a.marker"), "done").unwrap();
    fs::write(temp.path().join("b.marker"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [step_a, step_b]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "step_a".to_string(),
        make_step_with_check(
            "step_a",
            "echo a",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("a.marker".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );
    steps.insert(
        "step_b".to_string(),
        make_step_with_check(
            "step_b",
            "echo b",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("b.marker".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        force_all: true,
        ..RunOptions::default()
    };
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from(
                "/tmp/bivvy_test_force_all_sat.json",
            )),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert!(ui.prompts_shown().is_empty());
    assert_eq!(result.steps.len(), 2);
    // Both steps ran despite their checks passing.
    assert!(!result.steps[0].skipped);
    assert!(!result.steps[1].skipped);
}

#[test]
fn run_with_ui_step_level_force_reruns_satisfied_step() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("marker.txt"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [migrate]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    let mut step = make_step_with_check(
        "migrate",
        "echo migrating",
        Some(crate::checks::Check::Presence {
            name: None,
            target: Some("marker.txt".to_string()),
            kind: Some(crate::checks::PresenceKind::File),
            command: None,
        }),
    );
    step.behavior.force = true;
    steps.insert("migrate".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from(
                "/tmp/bivvy_test_step_force_sat.json",
            )),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // Step ran despite the check passing.
    assert_eq!(result.steps.len(), 1);
    assert!(!result.steps[0].skipped);
}

#[test]
fn run_options_should_force_returns_true_for_named_step() {
    let mut options = RunOptions::default();
    options.force.insert("install".to_string());
    assert!(options.should_force("install"));
    assert!(!options.should_force("build"));
}

#[test]
fn workflow_force_all_in_yaml_reruns_every_satisfied_step() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("a.marker"), "done").unwrap();
    fs::write(temp.path().join("b.marker"), "done").unwrap();

    // Workflow declares force_all: true. After merge_force_directives, this
    // should land in RunOptions.force_all and the runner should bypass checks.
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [step_a, step_b]
                force_all: true
        "#,
    )
    .unwrap();

    let args = crate::cli::args::RunArgs {
        workflow: "default".to_string(),
        ..Default::default()
    };
    let (force, force_all) =
        crate::cli::commands::run::merge_force_directives(&args, config.workflows.get("default"));
    assert!(force_all, "workflow.force_all should propagate");
    assert!(force.is_empty());

    let mut steps = HashMap::new();
    steps.insert(
        "step_a".to_string(),
        make_step_with_check(
            "step_a",
            "echo a",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("a.marker".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );
    steps.insert(
        "step_b".to_string(),
        make_step_with_check(
            "step_b",
            "echo b",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("b.marker".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        force,
        force_all,
        ..RunOptions::default()
    };
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from(
                "/tmp/bivvy_test_workflow_force_all_sat.json",
            )),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert_eq!(result.steps.len(), 2);
    assert!(!result.steps[0].skipped);
    assert!(!result.steps[1].skipped);
}

#[test]
fn workflow_force_list_in_yaml_reruns_only_listed_steps() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("install.marker"), "done").unwrap();
    fs::write(temp.path().join("build.marker"), "done").unwrap();

    // Workflow declares force: [build]. After merging, only "build" is
    // forced — "install" still skips when its check passes.
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install, build]
                force: [build]
        "#,
    )
    .unwrap();

    let args = crate::cli::args::RunArgs {
        workflow: "default".to_string(),
        ..Default::default()
    };
    let (force, force_all) =
        crate::cli::commands::run::merge_force_directives(&args, config.workflows.get("default"));
    assert!(!force_all);
    assert!(force.contains("build"));
    assert!(!force.contains("install"));

    let mut steps = HashMap::new();
    steps.insert(
        "install".to_string(),
        make_step_with_check(
            "install",
            "echo install",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("install.marker".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );
    steps.insert(
        "build".to_string(),
        make_step_with_check(
            "build",
            "echo build",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("build.marker".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        force,
        force_all,
        ..RunOptions::default()
    };
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from(
                "/tmp/bivvy_test_workflow_force_list_sat.json",
            )),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert_eq!(result.steps.len(), 2);

    // "install" should be check-passed (check satisfied, step not forced)
    // — that path produces a result with check_result populated and the
    // command never ran.
    let install_result = result.steps.iter().find(|s| s.name == "install").unwrap();
    assert!(install_result.check_result.is_some());
    assert!(install_result.exit_code.is_none());

    // "build" should have run (forced via workflow.force list).
    let build_result = result.steps.iter().find(|s| s.name == "build").unwrap();
    assert!(build_result.check_result.is_none());
    assert!(build_result.exit_code.is_some());
}

#[test]
fn run_options_should_force_returns_true_for_every_step_when_force_all() {
    let options = RunOptions {
        force_all: true,
        ..RunOptions::default()
    };
    assert!(options.should_force("anything"));
    assert!(options.should_force("else"));
}

#[test]
fn run_with_ui_silent_skip_when_not_interactive() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("marker.txt"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "install".to_string(),
        make_step_with_check(
            "install",
            "echo installed",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("marker.txt".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    // Not interactive — should silently skip

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // No prompts should have been shown
    assert!(ui.prompts_shown().is_empty());
    // Step should be check-passed (not skipped)
    assert_eq!(result.steps.len(), 1);
    assert!(!result.steps[0].skipped);
    assert!(result.steps[0].check_result.is_some());
}

#[test]
fn run_with_ui_silent_skip_when_prompt_on_rerun_false() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("marker.txt"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install]
        "#,
    )
    .unwrap();

    let mut step = make_step_with_check(
        "install",
        "echo installed",
        Some(crate::checks::Check::Presence {
            name: None,
            target: Some("marker.txt".to_string()),
            kind: Some(crate::checks::PresenceKind::File),
            command: None,
        }),
    );
    step.behavior.prompt_on_rerun = false;

    let mut steps = HashMap::new();
    steps.insert("install".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // Should NOT have prompted even though interactive
    assert!(ui.prompts_shown().is_empty());
    assert!(!result.steps[0].skipped);
    assert!(result.steps[0].check_result.is_some());
}

#[test]
fn run_with_ui_sensitive_step_prompts() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [deploy]
        "#,
    )
    .unwrap();

    let mut step = make_step("deploy", "echo deployed", vec![]);
    step.behavior.sensitive = true;

    let mut steps = HashMap::new();
    steps.insert("deploy".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    // Only sensitive prompt is shown — no general run prompt
    ui.set_prompt_response("sensitive_deploy", "yes");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // No run prompt — only sensitive prompt
    assert!(!ui.prompts_shown().contains(&"run_deploy".to_string()));
    assert!(ui.prompts_shown().contains(&"sensitive_deploy".to_string()));
    assert!(!result.steps[0].skipped);
}

#[test]
fn run_with_ui_sensitive_not_skippable_declined_errors() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [deploy]
        "#,
    )
    .unwrap();

    let mut step = make_step("deploy", "echo deployed", vec![]);
    step.behavior.sensitive = true;
    step.behavior.skippable = false;

    let mut steps = HashMap::new();
    steps.insert("deploy".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    // User declines
    ui.set_prompt_response("sensitive_deploy", "no");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner.run_with_ui(
        &options,
        &ctx,
        &HashMap::new(),
        &HashMap::new(),
        temp.path(),
        false,
        &HashMap::new(),
        None,
        None,
        &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
        &mut ui,
        &mut workflow_display,
        &mut EventBus::new(),
    );

    assert!(result.is_err());
}

#[test]
fn run_with_ui_workflow_non_interactive_suppresses_prompts() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("marker.txt"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "install".to_string(),
        make_step_with_check(
            "install",
            "echo installed",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("marker.txt".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    // Even though UI is interactive, workflow_non_interactive should suppress prompts
    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            true, // workflow_non_interactive
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert!(ui.prompts_shown().is_empty());
    assert!(!result.steps[0].skipped);
    assert!(result.steps[0].check_result.is_some());
}

#[test]
fn run_with_ui_step_override_disables_prompt_on_rerun() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("marker.txt"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "install".to_string(),
        make_step_with_check(
            "install",
            "echo installed",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("marker.txt".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    // Override prompt_on_rerun to false for this step
    let mut overrides = HashMap::new();
    overrides.insert(
        "install".to_string(),
        StepOverride {
            prompt_on_rerun: Some(false),
            ..Default::default()
        },
    );

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &overrides,
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // Should NOT have prompted because override disables it
    assert!(ui.prompts_shown().is_empty());
    assert!(!result.steps[0].skipped);
    assert!(result.steps[0].check_result.is_some());
}

#[test]
fn failed_step_stops_dependent_step() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("after.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing, after_fail]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "failing".to_string(),
        make_step("failing", "exit 1", vec![]),
    );
    steps.insert(
        "after_fail".to_string(),
        make_step(
            "after_fail",
            &format!("touch {}", marker.display()),
            vec!["failing".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    // Should return Ok (not Err), but marked as failed
    assert!(!result.success);
    // Only the failing step ran; after_fail was blocked by dependency failure
    assert_eq!(result.steps.len(), 1);
    assert_eq!(result.steps[0].name, "failing");
    assert!(!result.steps[0].success);
    assert!(!marker.exists());
}

#[test]
fn allow_failure_continues_to_next_step() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("after.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing, after_fail]
        "#,
    )
    .unwrap();

    let mut failing_step = make_step("failing", "exit 1", vec![]);
    failing_step.behavior.allow_failure = true;

    let mut steps = HashMap::new();
    steps.insert("failing".to_string(), failing_step);
    steps.insert(
        "after_fail".to_string(),
        make_step(
            "after_fail",
            &format!("touch {}", marker.display()),
            vec!["failing".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    // Workflow reports failure (a step failed)
    assert!(!result.success);
    // Both steps ran
    assert_eq!(result.steps.len(), 2);

    let failing = result.steps.iter().find(|s| s.name == "failing").unwrap();
    let after = result
        .steps
        .iter()
        .find(|s| s.name == "after_fail")
        .unwrap();
    assert!(!failing.success);
    assert!(after.success);
    assert!(marker.exists());
}

#[test]
fn step_execution_error_does_not_abort_workflow() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("second.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [broken, healthy]
        "#,
    )
    .unwrap();

    // A step with a before-hook that fails causes execute_step to return Err.
    // With allow_failure, the workflow should continue to the next step.
    let mut broken_step = make_step("broken", "echo main", vec![]);
    broken_step.hooks.before = vec!["exit 1".to_string()];
    broken_step.behavior.allow_failure = true;

    let mut steps = HashMap::new();
    steps.insert("broken".to_string(), broken_step);
    steps.insert(
        "healthy".to_string(),
        make_step(
            "healthy",
            &format!("touch {}", marker.display()),
            vec!["broken".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    // Workflow completed (not Err), but not fully successful
    assert!(!result.success);
    // Both steps were processed
    assert_eq!(result.steps.len(), 2);

    let broken = result.steps.iter().find(|s| s.name == "broken").unwrap();
    let healthy = result.steps.iter().find(|s| s.name == "healthy").unwrap();
    assert!(!broken.success);
    assert!(healthy.success);
    assert!(marker.exists());
}

#[test]
fn step_execution_error_stops_when_not_allow_failure() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("second.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [broken, healthy]
        "#,
    )
    .unwrap();

    // Before-hook failure with allow_failure = false (default).
    // Previously this returned Err and aborted the entire run.
    // Now it should produce a WorkflowResult with success=false.
    let mut broken_step = make_step("broken", "echo main", vec![]);
    broken_step.hooks.before = vec!["exit 1".to_string()];

    let mut steps = HashMap::new();
    steps.insert("broken".to_string(), broken_step);
    steps.insert(
        "healthy".to_string(),
        make_step(
            "healthy",
            &format!("touch {}", marker.display()),
            vec!["broken".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    // Returns Ok (not Err), marked as failed
    assert!(!result.success);
    // Only broken step ran; healthy was blocked
    assert_eq!(result.steps.len(), 1);
    assert!(!result.steps[0].success);
    assert!(!marker.exists());
}

#[test]
fn run_with_ui_step_error_continues_with_allow_failure() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("second.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [broken, healthy]
        "#,
    )
    .unwrap();

    let mut broken_step = make_step("broken", "echo main", vec![]);
    broken_step.hooks.before = vec!["exit 1".to_string()];
    broken_step.behavior.allow_failure = true;

    let mut steps = HashMap::new();
    steps.insert("broken".to_string(), broken_step);
    steps.insert(
        "healthy".to_string(),
        make_step(
            "healthy",
            &format!("touch {}", marker.display()),
            vec!["broken".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(!result.success);
    assert_eq!(result.steps.len(), 2);

    let broken = result.steps.iter().find(|s| s.name == "broken").unwrap();
    let healthy = result.steps.iter().find(|s| s.name == "healthy").unwrap();
    assert!(!broken.success);
    assert!(healthy.success);
    assert!(marker.exists());
}

#[test]
fn run_with_ui_shows_error_output_on_failure() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [broken]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "broken".to_string(),
        make_step("broken", "echo 'something went wrong' >&2; exit 1", vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(!result.success);
    // Captured stderr should be surfaced as the error output (no internal exit code noise)
    assert!(workflow_display.has_message("something went wrong"));
}

/// Error block indent is determined by the step number width. For a single-step
/// workflow `[1/1]` — 5 chars + space — the indent is 6.
#[test]
fn run_with_ui_error_block_indent_single_step() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [broken]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert("broken".to_string(), make_step("broken", "exit 1", vec![]));

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    let state = workflow_display.state();
    let blocks = &state.step_error_blocks;
    assert!(
        !blocks.is_empty(),
        "expected an error block from failed step"
    );
    // [1/1] is 5 chars wide, plus one space → indent 6
    assert_eq!(
        blocks[0].3, 6,
        "error block indent should match `[1/1] ` width"
    );
}

/// In a 10-step workflow the step number rendering becomes `[1/10]` (6 chars +
/// space = 7). Verify the error block indent tracks the wider numbering.
#[test]
fn run_with_ui_error_block_indent_two_digit_total() {
    let temp = TempDir::new().unwrap();

    // 10 steps: only the first one runs and fails; the rest are blocked.
    let mut yaml = String::from("workflows:\n  default:\n    steps: [");
    let names: Vec<String> = (1..=10).map(|i| format!("s{}", i)).collect();
    yaml.push_str(&names.join(", "));
    yaml.push_str("]\n");
    let config: BivvyConfig = serde_yaml::from_str(&yaml).unwrap();

    let mut steps = HashMap::new();
    steps.insert("s1".to_string(), make_step("s1", "exit 1", vec![]));
    for i in 2..=10 {
        let name = format!("s{}", i);
        let prev = format!("s{}", i - 1);
        steps.insert(name.clone(), make_step(&name, "true", vec![prev]));
    }

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    let state = workflow_display.state();
    let blocks = &state.step_error_blocks;
    assert!(
        !blocks.is_empty(),
        "expected an error block from failed step"
    );
    // [1/10] is 6 chars wide, plus one space → indent 7
    assert_eq!(
        blocks[0].3, 7,
        "error block indent should match `[1/10] ` width"
    );
}

#[test]
fn allow_failure_lets_all_dependent_steps_run() {
    let temp = TempDir::new().unwrap();
    let marker_b = temp.path().join("b.txt");
    let marker_c = temp.path().join("c.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [a, b, c]
        "#,
    )
    .unwrap();

    // Step a fails but has allow_failure; b and c form a chain
    let mut step_a = make_step("a", "exit 1", vec![]);
    step_a.behavior.allow_failure = true;

    let mut steps = HashMap::new();
    steps.insert("a".to_string(), step_a);
    steps.insert(
        "b".to_string(),
        make_step(
            "b",
            &format!("touch {}", marker_b.display()),
            vec!["a".to_string()],
        ),
    );
    steps.insert(
        "c".to_string(),
        make_step(
            "c",
            &format!("touch {}", marker_c.display()),
            vec!["b".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    assert!(!result.success);
    // All 3 steps ran despite a's failure
    assert_eq!(result.steps.len(), 3);

    let step_a_result = result.steps.iter().find(|s| s.name == "a").unwrap();
    let step_b_result = result.steps.iter().find(|s| s.name == "b").unwrap();
    let step_c_result = result.steps.iter().find(|s| s.name == "c").unwrap();
    assert!(!step_a_result.success);
    assert!(step_b_result.success);
    assert!(step_c_result.success);
    assert!(marker_b.exists());
    assert!(marker_c.exists());
}

#[test]
fn independent_steps_continue_after_failure() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("independent.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing, independent]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "failing".to_string(),
        make_step("failing", "exit 1", vec![]),
    );
    // independent has no depends_on, so it should still run
    steps.insert(
        "independent".to_string(),
        make_step(
            "independent",
            &format!("touch {}", marker.display()),
            vec![],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    assert!(!result.success);
    // Both steps should appear in results
    assert_eq!(result.steps.len(), 2);
    let failing = result.steps.iter().find(|s| s.name == "failing").unwrap();
    let independent = result
        .steps
        .iter()
        .find(|s| s.name == "independent")
        .unwrap();
    assert!(!failing.success);
    assert!(independent.success);
    assert!(marker.exists());
}

#[test]
fn transitive_dependency_blocked() {
    let temp = TempDir::new().unwrap();
    let marker_b = temp.path().join("b.txt");
    let marker_c = temp.path().join("c.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [a, b, c]
        "#,
    )
    .unwrap();

    // a fails → b blocked (depends on a) → c blocked (depends on b)
    let mut steps = HashMap::new();
    steps.insert("a".to_string(), make_step("a", "exit 1", vec![]));
    steps.insert(
        "b".to_string(),
        make_step(
            "b",
            &format!("touch {}", marker_b.display()),
            vec!["a".to_string()],
        ),
    );
    steps.insert(
        "c".to_string(),
        make_step(
            "c",
            &format!("touch {}", marker_c.display()),
            vec!["b".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let result = runner
        .run(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
        )
        .unwrap();

    assert!(!result.success);
    // Only a ran; b and c were blocked
    assert_eq!(result.steps.len(), 1);
    assert_eq!(result.steps[0].name, "a");
    assert!(!marker_b.exists());
    assert!(!marker_c.exists());
}

#[test]
fn run_with_ui_auto_runs_all_steps_without_prompting() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [first, second]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "first".to_string(),
        make_step("first", "echo first", vec![]),
    );
    steps.insert(
        "second".to_string(),
        make_step("second", "echo second", vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert_eq!(result.steps.len(), 2);
    // Steps auto-run without prompting
    assert!(ui.prompts_shown().is_empty());
    assert!(!result.steps[0].skipped);
    assert!(!result.steps[1].skipped);
}

#[test]
fn run_with_ui_confirm_step_skipped_when_declined() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("ran.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [optional]
        "#,
    )
    .unwrap();

    let mut step = make_step("optional", &format!("touch {}", marker.display()), vec![]);
    step.behavior.confirm = true;

    let mut steps = HashMap::new();
    steps.insert("optional".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_prompt_response("confirm_optional", "no");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert_eq!(result.steps.len(), 1);
    assert!(result.steps[0].skipped);
    assert!(!marker.exists());
    assert!(ui.prompts_shown().contains(&"confirm_optional".to_string()));
}

#[test]
fn run_with_ui_no_prompt_when_not_skippable() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [required]
        "#,
    )
    .unwrap();

    let mut step = make_step("required", "echo required", vec![]);
    step.behavior.skippable = false;

    let mut steps = HashMap::new();
    steps.insert("required".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // No run prompt for non-skippable steps
    assert!(!ui.prompts_shown().contains(&"run_required".to_string()));
}

#[test]
fn run_with_ui_no_prompt_when_non_interactive() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "hello".to_string(),
        make_step("hello", "echo hello", vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    // Not interactive (default)

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert!(ui.prompts_shown().is_empty());
}

#[test]
fn run_with_ui_satisfied_step_auto_skips_no_prompts() {
    let temp = TempDir::new().unwrap();
    fs::write(temp.path().join("marker.txt"), "done").unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [install]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "install".to_string(),
        make_step_with_check(
            "install",
            "echo installed",
            Some(crate::checks::Check::Presence {
                name: None,
                target: Some("marker.txt".to_string()),
                kind: Some(crate::checks::PresenceKind::File),
                command: None,
            }),
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // No prompts at all — satisfied steps auto-skip silently
    assert!(ui.prompts_shown().is_empty());
    // Step is check-passed (not skipped)
    assert!(!result.steps[0].skipped);
    assert!(result.steps[0].check_result.is_some());
}

#[test]
fn run_with_ui_blocked_step_shows_warning() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing, dependent]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "failing".to_string(),
        make_step("failing", "exit 1", vec![]),
    );
    steps.insert(
        "dependent".to_string(),
        make_step("dependent", "echo dep", vec!["failing".to_string()]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(!result.success);
    // The dependent step should show a blocked message naming the failed dep
    assert!(workflow_display.has_message("Blocked (dependency 'failing' failed)"));
}

// --- 1E-2: Gap checking integration tests ---

use crate::requirements::checker::GapChecker;
use crate::requirements::probe::EnvironmentProbe;
use crate::requirements::registry::RequirementRegistry;
use crate::requirements::status::RequirementStatus;

fn make_gap_checker() -> (EnvironmentProbe, RequirementRegistry) {
    let probe = EnvironmentProbe::run_with_env(|_| Err(std::env::VarError::NotPresent));
    let registry = RequirementRegistry::new();
    (probe, registry)
}

#[test]
fn run_with_ui_proceeds_when_all_satisfied() {
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("ran.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    let mut step = make_step("hello", &format!("touch {}", marker.display()), vec![]);
    step.requires = vec!["fake-tool".to_string()];

    let mut steps = HashMap::new();
    steps.insert("hello".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let (probe, registry) = make_gap_checker();
    let mut checker = GapChecker::new(&registry, &probe, temp.path());
    // Pre-cache as satisfied
    checker.seed_cache("fake-tool", RequirementStatus::Satisfied);

    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            Some(&mut checker),
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert!(marker.exists());
}

#[test]
fn run_with_ui_warns_on_system_only() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    let mut step = make_step("hello", "echo hello", vec![]);
    step.requires = vec!["system-ruby".to_string()];

    let mut steps = HashMap::new();
    steps.insert("hello".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let (probe, registry) = make_gap_checker();
    let mut checker = GapChecker::new(&registry, &probe, temp.path());
    checker.seed_cache(
        "system-ruby",
        RequirementStatus::SystemOnly {
            path: "/usr/bin/ruby".into(),
            install_template: None,
            warning: "System ruby detected at /usr/bin/ruby. Consider using a version manager."
                .to_string(),
        },
    );

    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            Some(&mut checker),
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    // Should succeed — SystemOnly allows proceeding
    assert!(result.success);
    // Warning should be shown
    assert!(ui.has_warning("System ruby detected"));
}

#[test]
fn run_with_ui_errors_on_unknown_requirement() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    let mut step = make_step("hello", "echo hello", vec![]);
    step.requires = vec!["nonexistent-xyz".to_string()];

    let mut steps = HashMap::new();
    steps.insert("hello".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let (probe, registry) = make_gap_checker();
    let mut checker = GapChecker::new(&registry, &probe, temp.path());
    // Unknown is not cached — it's the default for unknown requirements
    // But we can pre-cache it for clarity
    checker.seed_cache("nonexistent-xyz", RequirementStatus::Unknown);

    // Non-interactive (default MockUI) → engine skips the step (soft block)
    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            Some(&mut checker),
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    // Engine decides Skip(RequirementNotMet) — step is skipped, workflow continues
    assert!(!result.steps.is_empty() || !result.skipped.is_empty());
}

#[test]
fn run_with_ui_non_interactive_fails_on_missing() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    let mut step = make_step("hello", "echo hello", vec![]);
    step.requires = vec!["missing-tool".to_string()];

    let mut steps = HashMap::new();
    steps.insert("hello".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let (probe, registry) = make_gap_checker();
    let mut checker = GapChecker::new(&registry, &probe, temp.path());
    checker.seed_cache(
        "missing-tool",
        RequirementStatus::Missing {
            install_template: None,
            install_hint: Some("brew install missing-tool".to_string()),
        },
    );

    // Non-interactive → engine skips the step (soft block)
    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner.run_with_ui(
        &options,
        &ctx,
        &HashMap::new(),
        &HashMap::new(),
        temp.path(),
        false,
        &HashMap::new(),
        Some(&mut checker),
        None,
        &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
        &mut ui,
        &mut workflow_display,
        &mut EventBus::new(),
    );

    let result = result.unwrap();
    // Engine decides Skip(RequirementNotMet) — step is skipped, workflow continues
    assert!(!result.steps.is_empty() || !result.skipped.is_empty());
}

#[test]
fn run_with_ui_interactive_warns_on_missing() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    let mut step = make_step("hello", "echo hello", vec![]);
    step.requires = vec!["missing-tool".to_string()];

    let mut steps = HashMap::new();
    steps.insert("hello".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let (probe, registry) = make_gap_checker();
    let mut checker = GapChecker::new(&registry, &probe, temp.path());
    checker.seed_cache(
        "missing-tool",
        RequirementStatus::Missing {
            install_template: None,
            install_hint: Some("brew install missing-tool".to_string()),
        },
    );

    // Interactive → should warn but proceed
    let mut ui = MockUI::new();
    ui.set_interactive(true);

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            Some(&mut checker),
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert!(ui.has_warning("Missing requirement"));
    assert!(ui.has_warning("brew install missing-tool"));
}

#[test]
fn run_with_ui_no_gaps_when_requires_empty() {
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [hello]
        "#,
    )
    .unwrap();

    // Step with empty requires — no gap checking needed
    let step = make_step("hello", "echo hello", vec![]);

    let mut steps = HashMap::new();
    steps.insert("hello".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let (probe, registry) = make_gap_checker();
    let mut checker = GapChecker::new(&registry, &probe, temp.path());

    let mut ui = MockUI::new();

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            Some(&mut checker),
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // No warnings should appear
    assert!(ui.warnings().is_empty());
}

#[test]
fn run_filters_only_environments() {
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [always, ci_only]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "always".to_string(),
        make_step("always", "echo always", vec![]),
    );
    let mut ci_step = make_step("ci_only", "echo ci", vec![]);
    ci_step.scoping.only_environments = vec!["ci".to_string()];
    steps.insert("ci_only".to_string(), ci_step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        active_environment: Some("development".to_string()),
        ..Default::default()
    };
    let ctx = crate::config::InterpolationContext::new();
    let global_env = HashMap::new();
    let temp = TempDir::new().unwrap();

    let result = runner
        .run(&options, &ctx, &HashMap::new(), &global_env, temp.path())
        .unwrap();

    // ci_only should be skipped in development
    assert_eq!(result.steps.len(), 1);
    assert!(result.skipped.contains(&"ci_only".to_string()));
}

#[test]
fn run_includes_matching_only_environments() {
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [always, ci_only]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "always".to_string(),
        make_step("always", "echo always", vec![]),
    );
    let mut ci_step = make_step("ci_only", "echo ci", vec![]);
    ci_step.scoping.only_environments = vec!["ci".to_string()];
    steps.insert("ci_only".to_string(), ci_step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        active_environment: Some("ci".to_string()),
        ..Default::default()
    };
    let ctx = crate::config::InterpolationContext::new();
    let global_env = HashMap::new();
    let temp = TempDir::new().unwrap();

    let result = runner
        .run(&options, &ctx, &HashMap::new(), &global_env, temp.path())
        .unwrap();

    // ci_only should run in ci environment
    assert_eq!(result.steps.len(), 2);
    assert!(result.skipped.is_empty());
}

#[test]
fn run_empty_only_environments_runs_always() {
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [always]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    // Empty only_environments = run in all environments
    let step = make_step("always", "echo always", vec![]);
    assert!(step.scoping.only_environments.is_empty());
    steps.insert("always".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        active_environment: Some("any_env".to_string()),
        ..Default::default()
    };
    let ctx = crate::config::InterpolationContext::new();
    let global_env = HashMap::new();
    let temp = TempDir::new().unwrap();

    let result = runner
        .run(&options, &ctx, &HashMap::new(), &global_env, temp.path())
        .unwrap();

    assert_eq!(result.steps.len(), 1);
    assert!(result.skipped.is_empty());
}

#[test]
fn run_only_environments_skipped_steps_in_result() {
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [a, b, c]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert("a".to_string(), make_step("a", "echo a", vec![]));

    let mut b = make_step("b", "echo b", vec![]);
    b.scoping.only_environments = vec!["ci".to_string()];
    steps.insert("b".to_string(), b);

    let mut c = make_step("c", "echo c", vec![]);
    c.scoping.only_environments = vec!["staging".to_string()];
    steps.insert("c".to_string(), c);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        active_environment: Some("development".to_string()),
        ..Default::default()
    };
    let ctx = crate::config::InterpolationContext::new();
    let global_env = HashMap::new();
    let temp = TempDir::new().unwrap();

    let result = runner
        .run(&options, &ctx, &HashMap::new(), &global_env, temp.path())
        .unwrap();

    assert_eq!(result.steps.len(), 1);
    assert_eq!(result.skipped.len(), 2);
    assert!(result.skipped.contains(&"b".to_string()));
    assert!(result.skipped.contains(&"c".to_string()));
}

#[test]
fn run_with_progress_respects_only_environments() {
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [always, ci_only]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "always".to_string(),
        make_step("always", "echo always", vec![]),
    );
    let mut ci_step = make_step("ci_only", "echo ci", vec![]);
    ci_step.scoping.only_environments = vec!["ci".to_string()];
    steps.insert("ci_only".to_string(), ci_step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions {
        active_environment: Some("development".to_string()),
        ..Default::default()
    };
    let ctx = crate::config::InterpolationContext::new();
    let global_env = HashMap::new();
    let temp = TempDir::new().unwrap();

    let mut skipped_names = Vec::new();
    let result = runner
        .run_with_progress(
            &options,
            &ctx,
            &HashMap::new(),
            &global_env,
            temp.path(),
            None,
            None,
            |progress| {
                if let RunProgress::StepSkipped { name } = progress {
                    skipped_names.push(name.to_string());
                }
            },
            &mut EventBus::new(),
        )
        .unwrap();

    assert_eq!(result.steps.len(), 1);
    assert!(skipped_names.contains(&"ci_only".to_string()));
}

// --- 7C: Runner environment tests ---

#[test]
fn runner_env_override_depends_on_changes_graph() {
    // If CI overrides change depends_on, the graph should reflect the new ordering.
    // Base: a (no deps), b depends_on [a], c depends_on [b]
    // CI override: c depends_on [a] instead of [b]  — c can now run right after a.
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [a, b, c]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert("a".to_string(), make_step("a", "echo a", vec![]));
    steps.insert(
        "b".to_string(),
        make_step("b", "echo b", vec!["a".to_string()]),
    );
    // Simulate CI override already applied: c depends_on [a] instead of [b]
    steps.insert(
        "c".to_string(),
        make_step("c", "echo c", vec!["a".to_string()]),
    );

    let runner = WorkflowRunner::new(&config, steps);
    let graph = runner.build_graph("default").unwrap();

    // c's dependency should be a, not b
    let c_deps = graph.dependencies_of("c").unwrap();
    assert!(c_deps.contains("a"));
    assert!(!c_deps.contains("b"));

    // b and c should be in the same parallel group (both depend only on a)
    let groups = graph.parallel_groups().unwrap();
    assert_eq!(groups.len(), 2); // [a] then [b, c]
    assert_eq!(groups[0], vec!["a"]);
    assert!(groups[1].contains(&"b".to_string()));
    assert!(groups[1].contains(&"c".to_string()));
}

// --- Recovery loop integration tests ---

#[test]
fn recovery_retry_succeeds() {
    // Step fails once, user picks Retry, step succeeds on second attempt.
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("attempt");

    // Command: create marker on second call (first call fails)
    let cmd = format!(
        "if [ -f {m} ]; then exit 0; else touch {m} && exit 1; fi",
        m = marker.display()
    );

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [flaky]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert("flaky".to_string(), make_step("flaky", &cmd, vec![]));

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    ui.set_prompt_response("recovery_flaky", "retry");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert_eq!(result.steps.len(), 1);
    assert!(result.steps[0].success);
    assert_eq!(
        result.steps[0].recovery_detail.as_deref(),
        Some("succeeded on retry (attempt 2)")
    );
}

#[test]
fn recovery_skip_does_not_block_dependents() {
    // Step fails, user picks Skip. Dependent step runs.
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("dependent_ran.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing, dependent]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "failing".to_string(),
        make_step("failing", "exit 1", vec![]),
    );
    steps.insert(
        "dependent".to_string(),
        make_step(
            "dependent",
            &format!("touch {}", marker.display()),
            vec!["failing".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    ui.set_prompt_response("recovery_failing", "skip");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    // Skip does NOT add to failed_steps, so dependent should run
    assert_eq!(result.steps.len(), 2);
    let dependent = result.steps.iter().find(|s| s.name == "dependent").unwrap();
    assert!(dependent.success);
    assert!(marker.exists());
}

#[test]
fn recovery_abort_stops_workflow() {
    // Step fails, user picks Abort. No further steps execute.
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("should_not_run.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing, next]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "failing".to_string(),
        make_step("failing", "exit 1", vec![]),
    );
    // next depends on failing to ensure deterministic ordering
    steps.insert(
        "next".to_string(),
        make_step(
            "next",
            &format!("touch {}", marker.display()),
            vec!["failing".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    ui.set_prompt_response("recovery_failing", "abort");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.aborted);
    assert!(!result.success);
    // Only the failing step should have a result; next is blocked by abort
    assert_eq!(result.steps.len(), 1);
    assert!(!marker.exists());
}

#[test]
fn recovery_abort_includes_partial_results() {
    // First step succeeds, second fails and user aborts.
    // Result should include both steps. Third is blocked by dependency.
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [good, bad, third]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert("good".to_string(), make_step("good", "echo ok", vec![]));
    // bad depends on good to enforce ordering
    steps.insert(
        "bad".to_string(),
        make_step("bad", "exit 1", vec!["good".to_string()]),
    );
    // third depends on bad so it comes after and gets blocked by abort
    steps.insert(
        "third".to_string(),
        make_step("third", "echo third", vec!["bad".to_string()]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    ui.set_prompt_response("recovery_bad", "abort");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.aborted);
    // good + bad should be in results; third should not
    assert_eq!(result.steps.len(), 2);
    let good = result.steps.iter().find(|s| s.name == "good").unwrap();
    let bad = result.steps.iter().find(|s| s.name == "bad").unwrap();
    assert!(good.success);
    assert!(!bad.success);
}

#[test]
fn auto_retry_before_menu() {
    // retry: 2. Step fails 3 times. Menu shown only after auto-retries exhausted.
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [flaky]
        "#,
    )
    .unwrap();

    let mut step = make_step("flaky", "exit 1", vec![]);
    step.execution.retry = 2;

    let mut steps = HashMap::new();
    steps.insert("flaky".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    ui.set_prompt_response("recovery_flaky", "abort");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.aborted);
    // Recovery prompt should have been shown (after auto-retries exhausted)
    assert!(ui.prompts_shown().contains(&"recovery_flaky".to_string()));
    // Auto-retry messages should appear
    assert!(workflow_display.has_message("Retrying... (attempt 2/3)"));
    assert!(workflow_display.has_message("Retrying... (attempt 3/3)"));
}

#[test]
fn auto_retry_succeeds() {
    // retry: 1. Fails first, succeeds on auto-retry. No menu shown.
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("attempt");

    let cmd = format!(
        "if [ -f {m} ]; then exit 0; else touch {m} && exit 1; fi",
        m = marker.display()
    );

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [flaky]
        "#,
    )
    .unwrap();

    let mut step = make_step("flaky", &cmd, vec![]);
    step.execution.retry = 1;

    let mut steps = HashMap::new();
    steps.insert("flaky".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // No recovery prompt should have been shown (auto-retry worked)
    assert!(!ui.prompts_shown().contains(&"recovery_flaky".to_string()));
    // Auto-retry message should appear
    assert!(workflow_display.has_message("Retrying... (attempt 2/2)"));
}

#[test]
fn allow_failure_suppresses_menu() {
    // allow_failure: true. No recovery prompt. Workflow continues.
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("next_ran.txt");

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing, next]
        "#,
    )
    .unwrap();

    let mut failing_step = make_step("failing", "exit 1", vec![]);
    failing_step.behavior.allow_failure = true;

    let mut steps = HashMap::new();
    steps.insert("failing".to_string(), failing_step);
    steps.insert(
        "next".to_string(),
        make_step(
            "next",
            &format!("touch {}", marker.display()),
            vec!["failing".to_string()],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    // No recovery prompt
    assert!(!ui.prompts_shown().contains(&"recovery_failing".to_string()));
    // Next step still runs
    assert_eq!(result.steps.len(), 2);
    assert!(marker.exists());
}

#[test]
fn non_interactive_no_menu() {
    // Non-interactive: step fails, no prompt, failed_steps populated.
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "failing".to_string(),
        make_step("failing", "exit 1", vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    // Not interactive (default)

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(!result.success);
    assert!(!result.aborted);
    // No recovery prompt
    assert!(!ui.prompts_shown().contains(&"recovery_failing".to_string()));
}

#[test]
fn non_interactive_auto_retry() {
    // Non-interactive + retry: 1. Retries silently, no menu.
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("attempt");

    let cmd = format!(
        "if [ -f {m} ]; then exit 0; else touch {m} && exit 1; fi",
        m = marker.display()
    );

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [flaky]
        "#,
    )
    .unwrap();

    let mut step = make_step("flaky", &cmd, vec![]);
    step.execution.retry = 1;

    let mut steps = HashMap::new();
    steps.insert("flaky".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    // Not interactive

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    // No recovery prompt
    assert!(!ui.prompts_shown().contains(&"recovery_flaky".to_string()));
}

#[test]
fn recovery_detail_in_skip() {
    // After Skip, recovery_detail is set.
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [failing]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert(
        "failing".to_string(),
        make_step("failing", "exit 1", vec![]),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    ui.set_prompt_response("recovery_failing", "skip");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert_eq!(
        result.steps[0].recovery_detail.as_deref(),
        Some("skipped by user after failure")
    );
}

#[test]
fn recovery_detail_in_retry() {
    // After successful retry, recovery_detail records the attempt number.
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("attempt");

    let cmd = format!(
        "if [ -f {m} ]; then exit 0; else touch {m} && exit 1; fi",
        m = marker.display()
    );

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [flaky]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert("flaky".to_string(), make_step("flaky", &cmd, vec![]));

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    ui.set_prompt_response("recovery_flaky", "retry");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert_eq!(
        result.steps[0].recovery_detail.as_deref(),
        Some("succeeded on retry (attempt 2)")
    );
}

#[test]
fn hint_shown_on_low_confidence() {
    // A low-confidence pattern match shows a hint in the error block.
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [broken]
        "#,
    )
    .unwrap();

    // "command not found: xyz" triggers the low-confidence command_not_found pattern
    let mut steps = HashMap::new();
    steps.insert(
        "broken".to_string(),
        make_step(
            "broken",
            "echo 'command not found: xyz' >&2; exit 127",
            vec![],
        ),
    );

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    // Non-interactive so no recovery menu
    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(!result.success);
    // Verify hint was passed to show_error_block
    let state = workflow_display.state();
    let blocks = &state.step_error_blocks;
    assert!(!blocks.is_empty());
    let (_, _, hint, _) = &blocks[0];
    assert!(
        hint.is_some(),
        "Expected a hint for 'command not found' pattern"
    );
    assert!(
        hint.as_deref().unwrap().contains("xyz"),
        "Hint should reference the missing command"
    );
}

#[test]
fn recovery_fix_confirmed_retries() {
    // User picks Fix, confirms, fix runs, step re-executes.
    let temp = TempDir::new().unwrap();
    let marker = temp.path().join("attempt");

    // Command fails first, succeeds after marker exists
    let cmd = format!(
        "if [ -f {m} ]; then exit 0; else exit 1; fi",
        m = marker.display()
    );

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [bundler]
        "#,
    )
    .unwrap();

    // Use a command that triggers a known pattern: bundler native ext
    let mut step = make_step("bundler", &cmd, vec![]);
    // We need stderr to contain the pattern. Since we can't easily produce
    // stderr with the right pattern from a shell command in a test,
    // we instead test the fix flow by using explicit prompt responses.
    // The fix option won't appear without a pattern match, so we use
    // the custom_fix path instead.
    step.requires = vec![];

    let mut steps = HashMap::new();
    steps.insert("bundler".to_string(), step);

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    // First recovery prompt: pick custom_fix (since no pattern will match)
    ui.queue_prompt_responses("recovery_bundler", vec!["custom_fix"]);
    // Enter the fix command
    ui.set_prompt_response("custom_fix_bundler", &format!("touch {}", marker.display()));
    // Confirm fix execution
    ui.set_prompt_response("confirm_fix_bundler", "yes");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.success);
    assert!(marker.exists());
}

#[test]
fn recovery_fix_declined_reprompts() {
    // User picks Fix, declines confirm, menu re-appears, then aborts.
    let temp = TempDir::new().unwrap();

    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [broken]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    steps.insert("broken".to_string(), make_step("broken", "exit 1", vec![]));

    let mut runner = WorkflowRunner::new(&config, steps);
    let options = RunOptions::default();
    let ctx = InterpolationContext::new();

    let mut ui = MockUI::new();
    ui.set_interactive(true);
    ui.set_default_prompt_response("yes");
    // First: pick custom_fix, then abort on second prompt
    ui.queue_prompt_responses("recovery_broken", vec!["custom_fix", "abort"]);
    ui.set_prompt_response("custom_fix_broken", "some-fix-cmd");
    // Decline the fix confirmation
    ui.set_prompt_response("confirm_fix_broken", "no");

    let mut workflow_display = MockWorkflowDisplay::new();
    let result = runner
        .run_with_ui(
            &options,
            &ctx,
            &HashMap::new(),
            &HashMap::new(),
            temp.path(),
            false,
            &HashMap::new(),
            None,
            None,
            &mut SatisfactionCache::empty(std::path::PathBuf::from("/tmp/bivvy_test_sat.json")),
            &mut ui,
            &mut workflow_display,
            &mut EventBus::new(),
        )
        .unwrap();

    assert!(result.aborted);
}

#[test]
fn runner_env_override_depends_on_cycle_detected() {
    // If CI override introduces a circular dependency, build_graph succeeds
    // but topological_order returns an error.
    // a -> b -> a (cycle)
    let config: BivvyConfig = serde_yaml::from_str(
        r#"
            workflows:
              default:
                steps: [a, b]
        "#,
    )
    .unwrap();

    let mut steps = HashMap::new();
    // Simulate CI override that created a cycle: a depends_on [b], b depends_on [a]
    steps.insert(
        "a".to_string(),
        make_step("a", "echo a", vec!["b".to_string()]),
    );
    steps.insert(
        "b".to_string(),
        make_step("b", "echo b", vec!["a".to_string()]),
    );

    let runner = WorkflowRunner::new(&config, steps);
    let graph = runner.build_graph("default").unwrap();

    // Topological order should detect the cycle
    let result = graph.topological_order();
    assert!(result.is_err());

    // find_cycle should also detect it
    let cycle = graph.find_cycle();
    assert!(cycle.is_some());
}
