//! Entry point for `creft skills test` and output formatting.
//!
//! This module owns:
//! - Fixture discovery and scenario dispatch.
//! - Console output formatting (per-scenario lines, final summary).
//! - `FixtureError` rendering to stderr; both parse errors and test failures
//!   exit with code 1 via [`CreftError::Setup`].
//!
//! Structured `ScenarioOutcome`s come back from [`scenario::run`]; this module
//! decides how to render them. Output formatting does not live in
//! `skill_test/` because the framework returns values, the CLI decides how to
//! present them.

use crate::error::CreftError;
use crate::model::AppContext;
use crate::skill_test::fixture::{self, FixtureError, Scenario};
use crate::skill_test::match_pattern::{self, MatchKind};
use crate::skill_test::scenario::{RunOpts, ScenarioOutcome, ScenarioStatus, run};

// ── Output formatting constants ───────────────────────────────────────────────

const PREFIX_PASS: &str = "PASS";
const PREFIX_FAIL: &str = "FAIL";
const PREFIX_TIME: &str = "TIME";
const PREFIX_SETUP: &str = "SETUP";

// ── Entry point ───────────────────────────────────────────────────────────────

/// Run `creft skills test [SKILL] [SCENARIO] [OPTIONS]`.
///
/// Discovers fixtures under the local root's `.creft/commands/`, optionally
/// filtered by `skill` (basename) and `scenario` (name). Runs every matching
/// scenario, prints per-scenario output, and returns `Ok(())` when all pass or
/// `Err(CreftError::Setup(...))` when any fail.
///
/// On non-Unix platforms this returns a setup error immediately; the
/// scenario runner itself also guards for non-Unix, but checking here avoids
/// allocating sandboxes before the OS constraint is surfaced.
pub fn cmd_skills_test(
    ctx: &AppContext,
    skill: Option<String>,
    scenario: Option<String>,
    keep: bool,
    detail: bool,
    where_: bool,
) -> Result<(), CreftError> {
    #[cfg(not(unix))]
    return Err(CreftError::Setup(
        "`creft skills test` is currently supported on Unix only (macOS, Linux); \
         Windows support is not yet implemented."
            .to_owned(),
    ));

    #[cfg(unix)]
    cmd_skills_test_unix(ctx, skill, scenario, keep, detail, where_)
}

/// Discover fixture files across every local root in the chain, nearest-first.
///
/// When two local roots contain a fixture for the same skill basename, only the
/// nearest root's fixture is included. The farther fixture is reported on stderr
/// as "note: shadowed fixture: <path> (overridden by <nearer-path>)". A fixture
/// from a non-nearest root is run when the corresponding skill name is not also
/// defined in a nearer root — it is still reachable via hierarchical fall-through.
#[cfg(unix)]
fn discover_fixtures_chain(
    ctx: &AppContext,
    skill_filter: Option<&crate::skill_test::match_pattern::Matcher>,
) -> Result<Vec<std::path::PathBuf>, fixture::FixtureError> {
    use std::collections::HashMap;

    // Map from skill basename to (fixture_path, source_root) for nearest-wins tracking.
    // We iterate nearest-first and record the first (nearest) occurrence.
    let mut seen: HashMap<String, std::path::PathBuf> = HashMap::new();
    let mut result: Vec<std::path::PathBuf> = Vec::new();
    let mut shadowed: Vec<(std::path::PathBuf, std::path::PathBuf)> = Vec::new();

    for root in ctx.local_roots() {
        let commands_dir = root.join("commands");
        if !commands_dir.exists() {
            continue;
        }
        let discovered = fixture::discover(&commands_dir, skill_filter)?;
        for path in discovered {
            // The skill basename is the filename stem with `.test.yaml` stripped.
            let basename = path
                .file_name()
                .and_then(|n| n.to_str())
                .map(|n| n.strip_suffix(".test.yaml").unwrap_or(n).to_owned())
                .unwrap_or_default();
            if let Some(winner) = seen.get(&basename) {
                // A nearer root already claimed this basename.
                shadowed.push((path, winner.clone()));
            } else {
                seen.insert(basename, path.clone());
                result.push(path);
            }
        }
    }

    // Report shadowed fixtures on stderr before returning.
    for (shadowed_path, winner_path) in &shadowed {
        eprintln!(
            "note: shadowed fixture: {} (overridden by {})",
            shadowed_path.display(),
            winner_path.display()
        );
    }

    result.sort();
    Ok(result)
}

#[cfg(unix)]
fn cmd_skills_test_unix(
    ctx: &AppContext,
    skill: Option<String>,
    scenario_filter: Option<String>,
    keep: bool,
    detail: bool,
    where_: bool,
) -> Result<(), CreftError> {
    // Require at least one local root — fixtures only exist in project skill trees.
    if ctx.local_roots().is_empty() {
        return Err(CreftError::Setup(
            "no .creft/ directory found in this or any parent directory; \
             run from a project root or after `creft init`"
                .to_owned(),
        ));
    }

    // Compile the SKILL pattern once before the filesystem walk. Plain text is
    // an exact basename match; globs are anchored fnmatch. When the caller
    // supplied no skill filter, the walk returns every fixture.
    let skill_matcher = skill
        .as_deref()
        .map(|p| match_pattern::compile(p, MatchKind::Exact))
        .transpose()
        .map_err(|e| CreftError::Setup(e.to_string()))?;

    // Discover fixture files across all local roots in the chain.
    let fixture_paths = discover_fixtures_chain(ctx, skill_matcher.as_ref())
        .map_err(|e| CreftError::Setup(e.to_string()))?;

    // Parse every fixture file, collecting parse errors to report before running.
    let mut all_scenarios: Vec<Scenario> = Vec::new();
    let mut parse_errors: Vec<FixtureError> = Vec::new();
    for path in &fixture_paths {
        match fixture::load_file(path) {
            Ok(scenarios) => all_scenarios.extend(scenarios),
            Err(e) => parse_errors.push(e),
        }
    }

    if !parse_errors.is_empty() {
        for e in &parse_errors {
            eprintln!("error: {e}");
        }
        return Err(CreftError::Setup(format!(
            "{} fixture file(s) failed to parse",
            parse_errors.len()
        )));
    }

    // Apply the scenario-name pattern filter (post-parse, because the name is
    // inside the file). Scenario names are narrative strings where substring
    // matching is the expected default.
    if let Some(ref pattern) = scenario_filter {
        let scenario_matcher = match_pattern::compile(pattern, MatchKind::Substring)
            .map_err(|e| CreftError::Setup(e.to_string()))?;
        all_scenarios.retain(|s| scenario_matcher.matches(&s.name));
    }

    // --where: list discovered fixtures and scenarios, then exit.
    if where_ {
        print_where_listing(&fixture_paths, &all_scenarios);
        return Ok(());
    }

    if all_scenarios.is_empty() {
        if fixture_paths.is_empty() {
            println!("0 scenarios: no fixtures found");
        } else {
            println!(
                "0 scenarios: filter matched no scenarios in {} fixture(s)",
                fixture_paths.len()
            );
        }
        return Ok(());
    }

    // Build RunOpts once; passed to every scenario::run call.
    let opts = RunOpts {
        creft_binary: None, // resolve via current_exe() inside scenario::run
        default_timeout: std::time::Duration::from_secs(60),
        keep_on_failure: keep,
    };

    // Run every scenario, accumulate outcomes.
    let mut outcomes: Vec<(Scenario, ScenarioOutcome)> = Vec::new();
    for scenario in all_scenarios {
        let outcome = run(&scenario, ctx, &opts);
        print_scenario_line(&scenario, &outcome, detail);
        outcomes.push((scenario, outcome));
    }

    print_summary(&outcomes);

    let any_failed = outcomes
        .iter()
        .any(|(_, o)| !matches!(o.status, ScenarioStatus::Pass));
    if any_failed {
        Err(CreftError::Setup("one or more scenarios failed".to_owned()))
    } else {
        Ok(())
    }
}

// ── Output helpers ────────────────────────────────────────────────────────────

/// Print the `--where` listing: one line per scenario, then a footer.
fn print_where_listing(fixture_paths: &[std::path::PathBuf], scenarios: &[Scenario]) {
    // Group scenarios by source file for the listing.
    let mut by_file: std::collections::BTreeMap<&std::path::Path, Vec<&Scenario>> =
        std::collections::BTreeMap::new();
    for s in scenarios {
        by_file.entry(&s.source_file).or_default().push(s);
    }

    // Also include fixture files that loaded zero scenarios.
    for path in fixture_paths {
        by_file.entry(path.as_path()).or_default();
    }

    for (path, file_scenarios) in &by_file {
        println!("{} ({} scenario(s))", path.display(), file_scenarios.len());
        for s in file_scenarios {
            println!("  - {}", s.name);
        }
    }

    let fixture_count = by_file.len();
    let scenario_count = scenarios.len();
    println!("\n{fixture_count} fixture(s), {scenario_count} scenario(s)");
}

/// Print one result line for a scenario.
///
/// On failure, print the failure detail block (assertion failures or error
/// message). When `detail` is true, also print the full stdout/stderr and any
/// `notes` for passing scenarios.
fn print_scenario_line(scenario: &Scenario, outcome: &ScenarioOutcome, detail: bool) {
    let skill_label = scenario
        .source_file
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("?");

    match &outcome.status {
        ScenarioStatus::Pass => {
            let block_count = outcome.trace.len();
            let prim_count: u32 = outcome
                .trace
                .iter()
                .map(|r| r.primitives.values().sum::<u32>())
                .sum();
            println!(
                "{PREFIX_PASS}  {skill_label} / {}       ({block_count} block(s), {prim_count} primitive(s))",
                scenario.name
            );
            if detail {
                print_detail_section(scenario, outcome);
            }
        }
        ScenarioStatus::Fail(failures) => {
            println!("{PREFIX_FAIL}  {skill_label} / {}", scenario.name);
            for f in failures {
                if let Some(ref loc) = f.locator {
                    println!("        {}: {}", f.kind, loc);
                } else {
                    println!("        {}:", f.kind);
                }
                println!("          expected: {}", f.expected);
                println!("          actual:   {}", f.actual);
            }
            if let Some(ref path) = outcome.kept_path {
                eprintln!("        sandbox: {}", path.display());
            }
            if detail {
                print_detail_section(scenario, outcome);
            }
        }
        ScenarioStatus::Timeout => {
            let timeout_secs = scenario.when.timeout_seconds.unwrap_or(60);
            println!(
                "{PREFIX_TIME}  {skill_label} / {}       (after {timeout_secs}s)",
                scenario.name
            );
            if detail {
                print_detail_section(scenario, outcome);
            }
        }
        ScenarioStatus::SetupError(msg) => {
            println!(
                "{PREFIX_SETUP}  {skill_label} / {}       ({})",
                scenario.name, msg
            );
            if detail {
                print_detail_section(scenario, outcome);
            }
        }
    }
}

/// Print the detail block for a scenario (stdout/stderr and optional notes).
fn print_detail_section(scenario: &Scenario, outcome: &ScenarioOutcome) {
    if let Some(ref notes) = scenario.notes {
        println!("      notes:");
        for line in notes.lines() {
            println!("        {line}");
        }
    }
    if !outcome.stdout.is_empty() {
        println!("      stdout:");
        for line in outcome.stdout.lines() {
            println!("        {line}");
        }
    }
    if !outcome.stderr.is_empty() {
        println!("      stderr:");
        for line in outcome.stderr.lines() {
            println!("        {line}");
        }
    }
}

/// Print the final summary line.
fn print_summary(outcomes: &[(Scenario, ScenarioOutcome)]) {
    let total = outcomes.len();
    let passed = outcomes
        .iter()
        .filter(|(_, o)| matches!(o.status, ScenarioStatus::Pass))
        .count();
    let failed = outcomes
        .iter()
        .filter(|(_, o)| matches!(o.status, ScenarioStatus::Fail(_)))
        .count();
    let timeout = outcomes
        .iter()
        .filter(|(_, o)| matches!(o.status, ScenarioStatus::Timeout))
        .count();
    let setup_errors = outcomes
        .iter()
        .filter(|(_, o)| matches!(o.status, ScenarioStatus::SetupError(_)))
        .count();

    let mut parts = vec![format!("{passed} passed")];
    if failed > 0 {
        parts.push(format!("{failed} failed"));
    }
    if timeout > 0 {
        parts.push(format!("{timeout} timeout"));
    }
    if setup_errors > 0 {
        parts.push(format!("{setup_errors} setup error(s)"));
    }

    println!("\n{total} scenario(s): {}", parts.join(", "));
}

#[cfg(all(test, unix))]
mod tests {
    use super::*;
    use crate::error::CreftError;
    use crate::model::AppContext;
    use pretty_assertions::assert_eq;

    // ── Helpers ───────────────────────────────────────────────────────────────

    /// Build a project root with `.creft/commands/` and a separate home dir.
    ///
    /// Returns `(home_tmp, project_tmp, ctx)`. The home dir is separate from the
    /// project dir so `nearest_local_root()` does not mistake it for `~/.creft/`.
    fn project_with_commands_dir() -> (tempfile::TempDir, tempfile::TempDir, AppContext) {
        let home_tmp = tempfile::TempDir::new().expect("home tmp");
        let project_tmp = tempfile::TempDir::new().expect("project tmp");
        std::fs::create_dir_all(project_tmp.path().join(".creft/commands"))
            .expect("create commands dir");
        let ctx = AppContext::for_test(
            home_tmp.path().to_path_buf(),
            project_tmp.path().to_path_buf(),
        );
        (home_tmp, project_tmp, ctx)
    }

    /// Write a fixture file with one or more scenarios to a project's commands dir.
    fn write_fixture(project: &tempfile::TempDir, skill_name: &str, yaml: &str) {
        let path = project
            .path()
            .join(".creft/commands")
            .join(format!("{skill_name}.test.yaml"));
        std::fs::write(&path, yaml).expect("write fixture");
    }

    // ── No-local-root tests ───────────────────────────────────────────────────

    #[test]
    fn cmd_skills_test_no_local_root_returns_setup_error() {
        let tmp = tempfile::TempDir::new().unwrap();
        // home == cwd, so nearest_local_root() sees no local root and returns None.
        let ctx = AppContext::for_test(tmp.path().to_path_buf(), tmp.path().to_path_buf());
        let result = cmd_skills_test(&ctx, None, None, false, false, false);
        assert!(
            matches!(result, Err(CreftError::Setup(ref msg)) if msg.contains("no .creft/ directory found")),
            "no local root must return Setup error containing 'no .creft/ directory found'; got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_no_local_root_with_where_returns_setup_error() {
        let tmp = tempfile::TempDir::new().unwrap();
        let ctx = AppContext::for_test(tmp.path().to_path_buf(), tmp.path().to_path_buf());
        let result = cmd_skills_test(&ctx, None, None, false, false, true);
        assert!(
            matches!(result, Err(CreftError::Setup(ref msg)) if msg.contains("no .creft/ directory found")),
            "--where without local root must return Setup error; got: {result:?}",
        );
    }

    // ── Happy-path tests ──────────────────────────────────────────────────────

    /// A fixture with two passing scenarios using `sh -c` (no creft binary needed).
    const TWO_PASS_SCENARIOS: &str = r#"
- name: first-pass
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
- name: second-pass
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
"#;

    #[test]
    fn cmd_skills_test_returns_ok_when_all_scenarios_pass() {
        let (_home, project, ctx) = project_with_commands_dir();
        write_fixture(&project, "setup", TWO_PASS_SCENARIOS);

        let result = cmd_skills_test(&ctx, None, None, false, false, false);
        assert!(
            result.is_ok(),
            "two passing scenarios must return Ok(()); got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_skill_filter_restricts_to_matching_fixture() {
        let (_home, project, ctx) = project_with_commands_dir();
        write_fixture(&project, "setup", TWO_PASS_SCENARIOS);
        // Write a second fixture that would fail if run.
        write_fixture(
            &project,
            "other",
            r#"
- name: would-fail
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
"#,
        );

        // Filtering to "setup" loads exactly TWO_PASS_SCENARIOS and skips "other".
        let result = cmd_skills_test(&ctx, Some("setup".to_owned()), None, false, false, false);
        assert!(
            result.is_ok(),
            "skill filter 'setup' must run only setup.test.yaml and return Ok(()); got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_scenario_filter_restricts_to_named_scenario() {
        let (_home, project, ctx) = project_with_commands_dir();
        write_fixture(
            &project,
            "setup",
            r#"
- name: first-pass
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
- name: would-fail
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
"#,
        );

        // Filtering to scenario "first-pass" must skip "would-fail".
        let result = cmd_skills_test(
            &ctx,
            Some("setup".to_owned()),
            Some("first-pass".to_owned()),
            false,
            false,
            false,
        );
        assert!(
            result.is_ok(),
            "scenario filter 'first-pass' must run at most one scenario; got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_with_where_returns_ok_without_running_scenarios() {
        let (_home, project, ctx) = project_with_commands_dir();
        // Write a fixture that would fail if actually executed.
        write_fixture(
            &project,
            "setup",
            r#"
- name: would-fail
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
"#,
        );

        // --where must exit 0 even though the scenario would fail if run.
        let result = cmd_skills_test(&ctx, None, None, false, false, true);
        assert!(
            result.is_ok(),
            "--where must return Ok(()) without executing scenarios; got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_where_with_skill_filter_restricts_listing() {
        let (_home, project, ctx) = project_with_commands_dir();
        write_fixture(&project, "setup", TWO_PASS_SCENARIOS);
        write_fixture(
            &project,
            "other",
            r#"
- name: other-scenario
  when:
    argv: ["sh", "-c", "exit 0"]
  then: {}
"#,
        );

        // --where with SKILL filter must return Ok(()) for a fixture that exists.
        let result = cmd_skills_test(&ctx, Some("setup".to_owned()), None, false, false, true);
        assert!(
            result.is_ok(),
            "--where with skill filter must return Ok(()); got: {result:?}",
        );
    }

    // ── Filter-empties diagnostic test ────────────────────────────────────────

    #[test]
    fn cmd_skills_test_filter_that_empties_scenarios_returns_ok_not_error() {
        let (_home, project, ctx) = project_with_commands_dir();
        write_fixture(
            &project,
            "setup",
            r#"
- name: alpha
  when:
    argv: ["sh", "-c", "exit 0"]
  then: {}
"#,
        );

        // Filter to a nonexistent scenario name — setup.test.yaml exists but
        // no scenario is named "nonexistent".
        let result = cmd_skills_test(
            &ctx,
            Some("setup".to_owned()),
            Some("nonexistent".to_owned()),
            false,
            false,
            false,
        );
        assert!(
            result.is_ok(),
            "filter that empties scenarios must return Ok(()); got: {result:?}",
        );
    }

    // ── Stage 3: filter / pattern tests ──────────────────────────────────────

    /// A fixture with three scenarios: one that would fail if run, and two
    /// merge-prefixed scenarios that pass. Used by the filter tests below.
    const THREE_SCENARIO_FIXTURE: &str = r#"
- name: fresh-install
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
- name: merge-clean
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
- name: merge-conflict
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
"#;

    #[test]
    fn cmd_skills_test_filter_narrows_to_matching_scenarios() {
        let (_home, project, ctx) = project_with_commands_dir();
        write_fixture(&project, "setup", THREE_SCENARIO_FIXTURE);

        // --filter "merge*" must run only merge-clean and merge-conflict,
        // skipping fresh-install which would fail. Ok(()) proves the filter
        // excluded the failing scenario.
        let result = cmd_skills_test(
            &ctx,
            Some("setup".to_owned()),
            Some("merge*".to_owned()),
            false,
            false,
            false,
        );
        assert!(
            result.is_ok(),
            "--filter 'merge*' must run only merge-prefixed scenarios and return Ok(()); got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_filter_substring_matches_anywhere() {
        let (_home, project, ctx) = project_with_commands_dir();
        write_fixture(&project, "setup", THREE_SCENARIO_FIXTURE);

        // --filter "clean" (substring, no glob) must match merge-clean (the
        // substring appears in the name) and exclude fresh-install and
        // merge-conflict. merge-clean exits 0, so the run succeeds.
        let result = cmd_skills_test(
            &ctx,
            Some("setup".to_owned()),
            Some("clean".to_owned()),
            false,
            false,
            false,
        );
        assert!(
            result.is_ok(),
            "--filter 'clean' must match only merge-clean and return Ok(()); got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_filter_without_skill_runs_across_all_fixtures() {
        let (_home, project, ctx) = project_with_commands_dir();

        // setup.test.yaml: one merge-prefixed scenario (passes) and one that
        // would fail if reached.
        write_fixture(
            &project,
            "setup",
            r#"
- name: merge-state
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
- name: would-fail
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
"#,
        );

        // auth.test.yaml: one merge-prefixed scenario (passes) and one that
        // would fail if reached.
        write_fixture(
            &project,
            "auth",
            r#"
- name: merge-tokens
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
- name: expire
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
"#,
        );

        // No SKILL supplied — filter applies across every discovered fixture.
        // Only merge-state and merge-tokens run. would-fail and expire are
        // excluded by the pattern. Both passing scenarios succeed, so Ok(()).
        let result = cmd_skills_test(&ctx, None, Some("merge*".to_owned()), false, false, false);
        assert!(
            result.is_ok(),
            "--filter 'merge*' without SKILL must run merge-prefixed scenarios across all fixtures and return Ok(()); got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_skill_pattern_discovers_multiple_fixtures() {
        let (_home, project, ctx) = project_with_commands_dir();

        // Two merge-prefixed fixtures that each pass, and one setup fixture
        // that would fail if discovered. The SKILL pattern "merge*" must
        // discover both merge fixtures and skip setup.
        write_fixture(
            &project,
            "merge-clean",
            r#"
- name: run-clean
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
"#,
        );
        write_fixture(
            &project,
            "merge-conflict",
            r#"
- name: run-conflict
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
"#,
        );
        write_fixture(
            &project,
            "setup",
            r#"
- name: would-fail
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
"#,
        );

        // "merge*" discovers merge-clean.test.yaml and merge-conflict.test.yaml,
        // excludes setup.test.yaml. Both merge scenarios pass, so Ok(()).
        let result = cmd_skills_test(&ctx, Some("merge*".to_owned()), None, false, false, false);
        assert!(
            result.is_ok(),
            "SKILL 'merge*' must discover both merge fixtures and return Ok(()); got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_skill_exact_pattern_does_not_include_setup_extra() {
        let (_home, project, ctx) = project_with_commands_dir();

        // SKILL "setup" is an exact basename match — it discovers only
        // setup.test.yaml and must not discover setup-extra.test.yaml.
        write_fixture(
            &project,
            "setup",
            r#"
- name: base-scenario
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
"#,
        );
        write_fixture(
            &project,
            "setup-extra",
            r#"
- name: extra-scenario
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
"#,
        );

        // Only setup.test.yaml is discovered. setup-extra.test.yaml contains a
        // failing scenario; if it were discovered, the result would be Err.
        let result = cmd_skills_test(&ctx, Some("setup".to_owned()), None, false, false, false);
        assert!(
            result.is_ok(),
            "SKILL 'setup' exact match must discover only setup.test.yaml, not setup-extra.test.yaml; got: {result:?}",
        );
    }

    #[test]
    fn cmd_skills_test_skill_glob_pattern_includes_setup_extra() {
        let (_home, project, ctx) = project_with_commands_dir();

        // SKILL "setup*" is a glob — it discovers setup.test.yaml and
        // setup-extra.test.yaml. Both fixtures pass.
        write_fixture(
            &project,
            "setup",
            r#"
- name: base-scenario
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
"#,
        );
        write_fixture(
            &project,
            "setup-extra",
            r#"
- name: extra-scenario
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
"#,
        );

        let result = cmd_skills_test(&ctx, Some("setup*".to_owned()), None, false, false, false);
        assert!(
            result.is_ok(),
            "SKILL 'setup*' glob must discover both setup.test.yaml and setup-extra.test.yaml; got: {result:?}",
        );
    }

    // ── Stage 3: discover_fixtures_chain across roots ─────────────────────────

    /// A fixture that lives only in an ancestor root is discovered when running
    /// from a descendant CWD that has its own empty `.creft/commands/` directory.
    ///
    /// Spec test expectation: `creft skills test` discovers a fixture in an
    /// ancestor root from a descendant CWD and runs it.
    #[test]
    fn discover_fixtures_chain_finds_fixture_in_ancestor_root() {
        let home_tmp = tempfile::TempDir::new().unwrap();
        let parent_tmp = tempfile::TempDir::new().unwrap();
        let child_dir = parent_tmp.path().join("child");

        // Ancestor root: has a fixture.
        std::fs::create_dir_all(parent_tmp.path().join(".creft/commands")).unwrap();
        let fixture_path = parent_tmp.path().join(".creft/commands/deploy.test.yaml");
        std::fs::write(
            &fixture_path,
            "- name: runs-from-ancestor\n  when:\n    argv: [\"sh\", \"-c\", \"exit 0\"]\n  then:\n    exit_code: 0\n",
        )
        .unwrap();

        // Descendant root: empty commands dir — no fixtures here.
        std::fs::create_dir_all(child_dir.join(".creft/commands")).unwrap();

        // Build context with CWD = child_dir, so local_roots has [child, parent].
        let ctx = AppContext::for_test(home_tmp.path().to_path_buf(), child_dir.clone());
        assert_eq!(
            ctx.local_roots().len(),
            2,
            "test setup: expect chain of 2 roots"
        );

        let discovered = discover_fixtures_chain(&ctx, None).expect("discovery must succeed");

        assert_eq!(
            discovered.len(),
            1,
            "ancestor fixture must be discovered from the descendant CWD; got: {discovered:?}"
        );
        assert_eq!(
            discovered[0].file_name().and_then(|n| n.to_str()),
            Some("deploy.test.yaml"),
            "discovered fixture must be deploy.test.yaml; got: {:?}",
            discovered[0]
        );
    }

    /// When two local roots define a fixture with the same skill basename, only
    /// the nearest root's fixture is returned. The farther fixture is reported
    /// on stderr in the format specified by the spec:
    /// `note: shadowed fixture: <farther-path> (overridden by <nearer-path>)`.
    ///
    /// Spec test expectations: spec line 769, 899.
    #[test]
    fn discover_fixtures_chain_conflict_nearest_wins_and_farther_is_shadowed() {
        let home_tmp = tempfile::TempDir::new().unwrap();
        let parent_tmp = tempfile::TempDir::new().unwrap();
        let child_dir = parent_tmp.path().join("child");

        // Both roots define a fixture with the same basename ("remote").
        std::fs::create_dir_all(parent_tmp.path().join(".creft/commands")).unwrap();
        std::fs::create_dir_all(child_dir.join(".creft/commands")).unwrap();

        let fixture_yaml = "- name: check\n  when:\n    argv: [\"sh\", \"-c\", \"exit 0\"]\n  then:\n    exit_code: 0\n";
        let nearer_path = child_dir.join(".creft/commands/remote.test.yaml");
        let farther_path = parent_tmp.path().join(".creft/commands/remote.test.yaml");
        std::fs::write(&nearer_path, fixture_yaml).unwrap();
        std::fs::write(&farther_path, fixture_yaml).unwrap();

        let ctx = AppContext::for_test(home_tmp.path().to_path_buf(), child_dir.clone());
        assert_eq!(
            ctx.local_roots().len(),
            2,
            "test setup: expect chain of 2 roots"
        );

        let discovered = discover_fixtures_chain(&ctx, None).expect("discovery must succeed");

        // Only one fixture should be returned (nearest wins).
        assert_eq!(
            discovered.len(),
            1,
            "only the nearest fixture must be returned when two roots conflict; got: {discovered:?}"
        );
        // The returned fixture must be from the nearest (child) root, not the parent.
        assert_eq!(
            discovered[0], nearer_path,
            "nearest fixture must win over farther; expected {:?}, got {:?}",
            nearer_path, discovered[0]
        );
        // Note: the shadow note is emitted to stderr via eprintln! — this is verified
        // by the integration test `cmd_skills_test_chain_shadow_note_in_stderr`.
    }

    #[test]
    fn cmd_skills_test_skill_exact_does_not_match_substring_in_other_basename() {
        let (_home, project, ctx) = project_with_commands_dir();

        // The user-reported symptom: `creft skills test ask` must discover
        // ask.test.yaml and must not discover task.test.yaml.
        write_fixture(
            &project,
            "ask",
            r#"
- name: ask-scenario
  when:
    argv: ["sh", "-c", "exit 0"]
  then:
    exit_code: 0
"#,
        );
        write_fixture(
            &project,
            "task",
            r#"
- name: task-scenario
  when:
    argv: ["sh", "-c", "exit 1"]
  then:
    exit_code: 0
"#,
        );

        // task.test.yaml contains a failing scenario; if "ask" matched it via
        // substring, the result would be Err.
        let result = cmd_skills_test(&ctx, Some("ask".to_owned()), None, false, false, false);
        assert!(
            result.is_ok(),
            "SKILL 'ask' exact match must discover ask.test.yaml only, not task.test.yaml; got: {result:?}",
        );
    }
}
