//! Test-selection logic for `mcptest run`.
//!
//! `mcptest run` filters and reorders the discovered test list before
//! handing it to the runner. The same selection rules must be applied
//! by `--collect-only` so the printed list matches what would actually
//! execute. Centralising the logic here keeps the two code paths in
//! sync. , , .

use std::path::Path;

use anyhow::Result;
use mcptest_core::suite::{ModelRef, ResolvedSuite};
use serde::{Deserialize, Serialize};

use crate::cli::args::run::ShardSpec;
use crate::cli::args::RunArgs;

/// Result of `mcptest run`'s test-selection pass.
///
/// Carries the ordered list of selected names, the skipped rows, and a
/// boolean for "every candidate was filtered out." Splitting them lets
/// the caller distinguish "the suite was empty" (which is still a config
/// bug most of the time) from "the filter or shard produced no work"
/// (which is fine when `--pass-with-no-tests` is set).
pub(crate) struct Selection {
    /// Test names that survived filter, shard, last-failed, and the
    /// `skip:`/`only:` markers, in the order the runner should
    /// dispatch them.
    pub(crate) selected: Vec<String>,
    /// `(name, reason)` pairs for tests that passed the filters but are
    /// parked by a YAML `skip:` or excluded by an `only:` focus set
    /// (WOR-1267). The runner reports each as a SKIP row without
    /// executing it.
    pub(crate) skipped: Vec<(String, String)>,
    /// True when any test in the suite carries `only: true`, whether or
    /// not the filters kept it. Drives the loud focus warning and the
    /// CI=true refusal: a focused suite must never gate CI green.
    pub(crate) only_active: bool,
    /// True when the suite had no tests and no agent matrix entries
    /// before any selection ran. Used to keep the legacy
    /// "0 tests discovered" message readable.
    pub(crate) suite_empty: bool,
}

/// Run the whole selection pipeline for one suite: `--filter`, tags,
/// `--changed`, `--shard`, last-failed ordering, shuffle, and finally
/// the `skip:`/`only:` partition. Every run-shaped surface (the live
/// run, `--collect-only`, `--print-config`, `--explain`) goes through
/// this one function so they can never disagree about what runs.
pub(crate) fn compute_selection(
    suite: &ResolvedSuite,
    config_path: &Path,
    filter: Option<&str>,
    args: &RunArgs,
) -> Result<Selection> {
    let candidates = candidate_names(suite);
    let suite_empty = candidates.is_empty();
    let mut selected = apply_filter(candidates, filter)?;
    if !args.tags.is_empty() || !args.skip_tags.is_empty() {
        let tag_map = tags_by_name(suite);
        selected = apply_tags(selected, &tag_map, &args.tags, &args.skip_tags);
    }
    if args.changed {
        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
        selected = apply_changed(selected, config_path, &cwd, &args.changed_base);
    }
    if let Some(shard) = args.shard {
        selected = apply_shard(selected, shard);
    }
    if args.last_failed {
        let state = read_last_run_state(config_path);
        selected = apply_last_failed(selected, state.as_ref());
    } else if args.failed_first {
        let state = read_last_run_state(config_path);
        selected = apply_failed_first(selected, state.as_ref());
    }
    // `--seed N` implies `--random`. Print the seed whenever shuffle
    // runs so the user can paste it back as `--seed` to reproduce.
    if args.random || args.seed.is_some() {
        let (shuffled, seed) = apply_random(selected, args.seed);
        eprintln!("mcptest run: shuffled with --seed {seed}");
        selected = shuffled;
    }
    // WOR-1267: partition the surviving candidates by the YAML
    // `skip:`/`only:` markers. An explicit `skip:` wins over `only:` on
    // the same test (the author parked it on purpose); when any test is
    // focused, everything unmarked is implicitly skipped.
    let markers = authoring_markers(suite);
    let only_active = !markers.only.is_empty();
    let mut runnable = Vec::with_capacity(selected.len());
    let mut skipped = Vec::new();
    for name in selected {
        if let Some(reason) = markers.skip_reasons.get(&name) {
            skipped.push((name, reason.clone()));
        } else if only_active && !markers.only.contains(&name) {
            skipped.push((name, "not in only set".to_string()));
        } else {
            runnable.push(name);
        }
    }
    Ok(Selection {
        selected: runnable,
        skipped,
        only_active,
        suite_empty,
    })
}

/// Print a clear "no tests matched" line and return the right exit
/// code. `--pass-with-no-tests` exits `0`; otherwise the runner exits
/// `7` so a misconfigured shard, filter, or `--last-failed` does not
/// silently succeed.
pub(crate) fn handle_empty_selection(
    suite_empty: bool,
    args: &RunArgs,
    quiet: bool,
    config_path: &Path,
    other_block_hint: Option<&str>,
) -> i32 {
    if !quiet {
        if suite_empty {
            println!(
                "mcptest run: 0 tests discovered in {}",
                config_path.display()
            );
            // A suite with an `evals:` or `compliance:` block but no `tests:`
            // is not really empty, it just needs a different subcommand. Point
            // the operator there instead of leaving a bare dead-end.
            if let Some(hint) = other_block_hint {
                println!("note: {hint}");
            }
        } else {
            println!(
                "mcptest run: 0 tests matched filter / shard / --last-failed in {}",
                config_path.display()
            );
        }
    }
    if args.pass_with_no_tests {
        crate::cli::exit_codes::EXIT_OK
    } else {
        if !quiet {
            eprintln!("(pass `--pass-with-no-tests` to treat this as a success)");
        }
        crate::cli::exit_codes::EXIT_NO_TESTS_SELECTED
    }
}

/// WOR-1267: a focused suite must never gate CI green, so `only: true`
/// anywhere in the suite is a config error under CI=true (exit 2, the
/// invalid-config code). Returns `Some(2)` when the run must refuse.
pub(crate) fn refuse_focused_suite_in_ci(suite: &ResolvedSuite) -> Option<i32> {
    if !authoring_markers(suite).only.is_empty() && super::run::is_ci_env() {
        eprintln!(
            "mcptest run: this suite contains `only: true` and CI=true is set. \
             Focused tests must not gate CI; remove the `only:` marker(s) before merging."
        );
        return Some(2);
    }
    None
}

/// WOR-1267: outside CI a focused suite runs, but with a loud warning so
/// the `only:` marker is impossible to forget. The CI refusal for the
/// same marker fires earlier, before anything executes.
pub(crate) fn warn_focused_selection(selection: &Selection, quiet: bool) {
    if selection.only_active && !quiet {
        eprintln!(
            "WARNING: only: true is set; running {} focused test(s), {} other test(s) \
             implicitly skipped (not in only set). Remove `only:` before merging.",
            selection.selected.len(),
            selection.skipped.len()
        );
    }
}

/// Append the SKIP rows for parked tests (WOR-1267) and order every spec
/// by the selection order. Parked tests still occupy a report row: the
/// engine sees the reason on the spec and reports SKIP without calling
/// the executor, so they count in the skipped summary bucket. The sort
/// honors the selection order so `--failed-first` and any future
/// ordering pass shows up in the report and `--bail` short-circuits on
/// the prioritised tests first; skips order after the executed tests.
pub(crate) fn finalize_spec_order(
    specs: &mut Vec<mcptest_core::runner::TestSpec>,
    skipped: &[(String, String)],
    selected_order: &std::collections::HashMap<String, usize>,
) {
    for (name, reason) in skipped {
        specs.push(mcptest_core::runner::TestSpec::skipped(name, reason));
    }
    specs.sort_by_key(|spec| {
        selected_order
            .get(&spec.name)
            .copied()
            .unwrap_or(usize::MAX)
    });
}

/// State file written after every `mcptest run` so `--last-failed` and
/// `--failed-first` know which tests failed the previous time.
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct LastRunState {
    /// Suite path the failures were recorded against. The file is
    /// shared per-cwd, so this lets the runner detect a config switch
    /// and ignore the stale list rather than silently filtering with
    /// names from the wrong suite.
    pub config_path: String,
    /// RFC3339 timestamp the run finished.
    pub ended_at: String,
    /// Test names that failed during the previous run, in failure order.
    pub failed: Vec<String>,
}

/// Compute every test name the suite would dispatch, in YAML order.
///
/// Tool tests use their declared `name`. Agent matrix entries use
/// `<agent>` when the matrix has one model and `<agent> [<label>]`
/// when there is more than one, matching the case names the runner
/// reports today.
pub(crate) fn candidate_names(suite: &ResolvedSuite) -> Vec<String> {
    let mut names = Vec::new();
    for t in &suite.tests {
        names.push(t.name.clone());
    }
    for agent in &suite.agents {
        for model_ref in &agent.models {
            let case = if agent.models.len() == 1 {
                agent.name.clone()
            } else {
                format!("{} [{}]", agent.name, model_label(model_ref))
            };
            names.push(case);
        }
    }
    // Calibration and tool_quality check blocks are test-like rows,
    // so they are selectable by name and respect --filter / --include like any
    // test.
    for c in &suite.calibration {
        names.push(c.name.clone());
    }
    for c in &suite.tool_quality {
        names.push(c.name.clone());
    }
    names
}

/// Render the label `--collect-only` and the runner use for an agent
/// matrix entry. Auto-detect models drop the provider prefix because
/// there is exactly one provider that could have served the model.
fn model_label(model_ref: &ModelRef) -> String {
    match model_ref {
        ModelRef::AutoDetect(id) => id.clone(),
        ModelRef::Named { provider, id } => format!("{provider}/{id}"),
    }
}

/// Apply `--filter` to a candidate list, preserving original order. A
/// `None` filter is a passthrough. Plain expressions match by literal
/// substring; the `re:` prefix switches to regex (WOR-1267). See
/// [`crate::cli::parsers::FilterExpr`] for the grammar.
pub(crate) fn apply_filter(candidates: Vec<String>, filter: Option<&str>) -> Result<Vec<String>> {
    let Some(raw) = filter else {
        return Ok(candidates);
    };
    let expr = crate::cli::parsers::FilterExpr::parse(raw).map_err(|e| anyhow::anyhow!("{e}"))?;
    Ok(candidates.into_iter().filter(|n| expr.matches(n)).collect())
}

/// Apply `--tag` and `--skip-tag` to a candidate list using the
/// per-candidate tag map. When `include` is non-empty,
/// every kept candidate must carry at least one of those tags. When
/// `exclude` is non-empty, candidates carrying any of those tags are
/// dropped. Applied in that order so a test that matches both is
/// dropped (the explicit skip wins).
pub(crate) fn apply_tags(
    candidates: Vec<String>,
    tags_by_name: &std::collections::HashMap<String, Vec<String>>,
    include: &[String],
    exclude: &[String],
) -> Vec<String> {
    let include_set: std::collections::BTreeSet<&str> =
        include.iter().map(String::as_str).collect();
    let exclude_set: std::collections::BTreeSet<&str> =
        exclude.iter().map(String::as_str).collect();
    candidates
        .into_iter()
        .filter(|name| {
            let tags: Vec<&str> = tags_by_name
                .get(name)
                .map(|v| v.iter().map(String::as_str).collect())
                .unwrap_or_default();
            if !include_set.is_empty() && !tags.iter().any(|t| include_set.contains(t)) {
                return false;
            }
            if !exclude_set.is_empty() && tags.iter().any(|t| exclude_set.contains(t)) {
                return false;
            }
            true
        })
        .collect()
}

/// Build the candidate → tags lookup map used by [`apply_tags`].
/// Tool tests carry their own tags; agent matrix entries inherit
/// the parent agent's tags so `--tag smoke` keeps or drops every
/// model under that agent as a unit.
pub(crate) fn tags_by_name(
    suite: &ResolvedSuite,
) -> std::collections::HashMap<String, Vec<String>> {
    let mut map = std::collections::HashMap::new();
    for t in &suite.tests {
        if !t.tags.is_empty() {
            map.insert(t.name.clone(), t.tags.clone());
        }
    }
    for agent in &suite.agents {
        if agent.tags.is_empty() {
            continue;
        }
        for model_ref in &agent.models {
            let case = if agent.models.len() == 1 {
                agent.name.clone()
            } else {
                format!("{} [{}]", agent.name, model_label(model_ref))
            };
            map.insert(case, agent.tags.clone());
        }
    }
    map
}

/// Per-candidate authoring markers from the YAML `skip:` and `only:`
/// fields (WOR-1267). The skip map carries `name -> reason`; the only
/// set carries every focused name. Agent matrix entries inherit their
/// parent agent's markers under the same case names
/// [`candidate_names`] produces, so the two lookups never drift from
/// the selection list.
pub(crate) struct AuthoringMarkers {
    /// Candidate name to the declared `skip:` reason.
    pub(crate) skip_reasons: std::collections::HashMap<String, String>,
    /// Candidate names carrying `only: true`.
    pub(crate) only: std::collections::BTreeSet<String>,
}

/// Collect the `skip:` / `only:` markers for every candidate name.
pub(crate) fn authoring_markers(suite: &ResolvedSuite) -> AuthoringMarkers {
    let mut skip_reasons = std::collections::HashMap::new();
    let mut only = std::collections::BTreeSet::new();
    for t in &suite.tests {
        if let Some(reason) = &t.skip {
            skip_reasons.insert(t.name.clone(), reason.clone());
        }
        if t.only {
            only.insert(t.name.clone());
        }
    }
    for agent in &suite.agents {
        for model_ref in &agent.models {
            let case = if agent.models.len() == 1 {
                agent.name.clone()
            } else {
                format!("{} [{}]", agent.name, model_label(model_ref))
            };
            if let Some(reason) = &agent.skip {
                skip_reasons.insert(case.clone(), reason.clone());
            }
            if agent.only {
                only.insert(case);
            }
        }
    }
    AuthoringMarkers { skip_reasons, only }
}

/// Partition the candidate list into the requested shard.
///
/// Tests are sorted by name so each `INDEX/TOTAL` worker sees a stable
/// slice across runs. Bucketing is `position_in_sorted_list % total`:
/// round-robin distribution keeps the load balanced even when one
/// shard has a few quick tests and another has a few slow ones. After
/// selection the original (YAML) order is restored so the report still
/// reads top-to-bottom in suite order.
pub(crate) fn apply_shard(candidates: Vec<String>, shard: ShardSpec) -> Vec<String> {
    if shard.total <= 1 {
        return candidates;
    }
    let mut sorted: Vec<(usize, String)> = candidates.iter().cloned().enumerate().collect();
    sorted.sort_by(|a, b| a.1.cmp(&b.1));
    let target = (shard.index - 1) as usize;
    let total = shard.total as usize;
    let mut selected: Vec<(usize, String)> = sorted
        .into_iter()
        .enumerate()
        .filter(|(sorted_idx, _)| sorted_idx % total == target)
        .map(|(_, pair)| pair)
        .collect();
    selected.sort_by_key(|(orig_idx, _)| *orig_idx);
    selected.into_iter().map(|(_, name)| name).collect()
}

/// Apply `--last-failed`: keep only candidates that appear in the
/// previous run's failure list. When the state file is missing or
/// belongs to a different suite, the result is empty and the caller
/// should fall through to the `--pass-with-no-tests` gate.
pub(crate) fn apply_last_failed(
    candidates: Vec<String>,
    state: Option<&LastRunState>,
) -> Vec<String> {
    let Some(state) = state else {
        return Vec::new();
    };
    let failed: std::collections::BTreeSet<&str> =
        state.failed.iter().map(String::as_str).collect();
    candidates
        .into_iter()
        .filter(|n| failed.contains(n.as_str()))
        .collect()
}

/// Apply `--changed` to a candidate list. Runs
/// `git diff --name-only <base>...HEAD` plus
/// `git diff --name-only --cached` and keeps only candidates whose
/// suite YAML or cassette path is in the diff. Falls back to "no
/// matches" when not inside a git repo or when the base ref is
/// unknown, so the caller can route through `--pass-with-no-tests`.
///
/// `suite_path` is the resolved YAML path mcptest loaded; `cwd` is
/// where the git command runs. The implementation looks at three
/// signals per test:
///   1. The suite file itself, when the YAML changed every test
///      surfaces, since renumbering or adding a server can affect
///      every case.
///   2. A cassette path that contains the test name as a stem.
///   3. Any path that matches the test name literally (for tests
///      that reference fixtures by name).
pub(crate) fn apply_changed(
    candidates: Vec<String>,
    suite_path: &std::path::Path,
    cwd: &std::path::Path,
    base_ref: &str,
) -> Vec<String> {
    let Some(changed) = git_changed_files(cwd, base_ref) else {
        return Vec::new();
    };
    if changed.is_empty() {
        return Vec::new();
    }
    let suite_rel = suite_path
        .strip_prefix(cwd)
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|_| suite_path.to_string_lossy().to_string());
    // Suite-file edit: include every candidate. A schema change or a
    // new server block legitimately affects the whole suite, and the
    // user does not want to triage which tests are "really" touched.
    if changed.iter().any(|p| p == &suite_rel) {
        return candidates;
    }
    candidates
        .into_iter()
        .filter(|name| {
            let needle_a = format!("{name}.json");
            let needle_b = format!("/{name}.json");
            changed
                .iter()
                .any(|p| p.contains(name) || p.ends_with(&needle_a) || p.contains(&needle_b))
        })
        .collect()
}

/// Run `git diff --name-only <base>...HEAD` plus the index diff and
/// return the union of changed paths relative to the repo root.
/// Returns `None` when the command fails (no git, no repo, no base
/// ref). Empty Vec means git ran successfully but found no changes.
fn git_changed_files(cwd: &std::path::Path, base_ref: &str) -> Option<Vec<String>> {
    use std::process::Command;
    let head = Command::new("git")
        .args(["diff", "--name-only", &format!("{base_ref}...HEAD")])
        .current_dir(cwd)
        .output()
        .ok()?;
    if !head.status.success() {
        tracing::debug!(
            target: "mcptest::cli",
            stderr = %String::from_utf8_lossy(&head.stderr),
            base = base_ref,
            "git diff failed; --changed yields empty selection"
        );
        return None;
    }
    let working = Command::new("git")
        .args(["diff", "--name-only", "HEAD"])
        .current_dir(cwd)
        .output()
        .ok()?;
    let mut out: Vec<String> = Vec::new();
    for raw in [head.stdout, working.stdout] {
        for line in String::from_utf8_lossy(&raw).lines() {
            let trimmed = line.trim();
            if !trimmed.is_empty() {
                out.push(trimmed.to_string());
            }
        }
    }
    out.sort();
    out.dedup();
    Some(out)
}

/// Shuffle the candidate list with a seeded RNG. Returns
/// the seed alongside so callers can log it for reproducibility.
/// When `explicit_seed` is `None` the runner picks a random `u64`.
pub(crate) fn apply_random(
    candidates: Vec<String>,
    explicit_seed: Option<u64>,
) -> (Vec<String>, u64) {
    use rand::seq::SliceRandom;
    use rand::SeedableRng;
    let seed = explicit_seed.unwrap_or_else(rand::random);
    let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
    let mut shuffled = candidates;
    shuffled.shuffle(&mut rng);
    (shuffled, seed)
}

/// Reorder the candidate list so previous failures execute first
///. Tests not in the failure list keep their relative order
/// and run after the failures. Stable for the no-state case.
pub(crate) fn apply_failed_first(
    candidates: Vec<String>,
    state: Option<&LastRunState>,
) -> Vec<String> {
    let Some(state) = state else {
        return candidates;
    };
    let failed: std::collections::BTreeSet<&str> =
        state.failed.iter().map(String::as_str).collect();
    let mut head: Vec<String> = Vec::new();
    let mut tail: Vec<String> = Vec::new();
    for name in candidates {
        if failed.contains(name.as_str()) {
            head.push(name);
        } else {
            tail.push(name);
        }
    }
    head.extend(tail);
    head
}

/// Read the last-run state file for the given suite, returning `None`
/// when the file is absent, malformed, or belongs to a different
/// suite. A malformed state file is logged at debug level and
/// otherwise treated as "no prior state."
pub(crate) fn read_last_run_state(config_path: &Path) -> Option<LastRunState> {
    let state_path = state_file_path()?;
    let raw = std::fs::read_to_string(&state_path).ok()?;
    let state: LastRunState = serde_json::from_str(&raw)
        .map_err(|e| {
            tracing::debug!(
                target: "mcptest::cli",
                path = %state_path.display(),
                error = %e,
                "ignoring malformed .mcptest/last-run.json"
            );
            e
        })
        .ok()?;
    let configured = config_path.to_string_lossy().to_string();
    if state.config_path != configured {
        tracing::debug!(
            target: "mcptest::cli",
            saved = %state.config_path,
            current = %configured,
            "ignoring last-run state from a different suite path"
        );
        return None;
    }
    Some(state)
}

/// Write the failure list for the current run. Failures persist in
/// `.mcptest/last-run.json` relative to the current working directory
/// so a developer can `cd` to the suite and re-run with `--lf` even
/// when the binary lives elsewhere. Errors writing the file are
/// logged but never surfaced: the user-facing exit code reflects the
/// run, not the bookkeeping.
pub(crate) fn write_last_run_state(config_path: &Path, failed: &[String]) {
    let Some(state_path) = state_file_path() else {
        return;
    };
    if let Some(parent) = state_path.parent() {
        if let Err(e) = std::fs::create_dir_all(parent) {
            tracing::debug!(
                target: "mcptest::cli",
                path = %parent.display(),
                error = %e,
                "could not create .mcptest state dir"
            );
            return;
        }
    }
    let state = LastRunState {
        config_path: config_path.to_string_lossy().to_string(),
        ended_at: format_unix_epoch_seconds(),
        failed: failed.to_vec(),
    };
    let Ok(json) = serde_json::to_string_pretty(&state) else {
        return;
    };
    if let Err(e) = std::fs::write(&state_path, json) {
        tracing::debug!(
            target: "mcptest::cli",
            path = %state_path.display(),
            error = %e,
            "could not write last-run state"
        );
    }
}

fn state_file_path() -> Option<std::path::PathBuf> {
    let cwd = std::env::current_dir().ok()?;
    Some(cwd.join(".mcptest").join("last-run.json"))
}

/// Format the current time as a plain `unix=<seconds>` string. The
/// state file is bookkeeping that humans rarely read directly, so a
/// machine-friendly format keeps the dependency surface small and
/// avoids pulling in a date crate for a single timestamp.
fn format_unix_epoch_seconds() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    format!("unix={secs}")
}

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

    #[test]
    fn filter_returns_all_when_none() {
        let result = apply_filter(vec!["a".into(), "b".into()], None).expect("filter");
        assert_eq!(result, vec!["a", "b"]);
    }

    #[test]
    fn filter_matches_by_substring_without_prefix() {
        let result = apply_filter(
            vec!["create_issue".into(), "recreate_db".into(), "delete".into()],
            Some("create_"),
        )
        .expect("filter");
        assert_eq!(result, vec!["create_issue", "recreate_db"]);
    }

    #[test]
    fn filter_re_prefix_matches_anchored_regex() {
        let result = apply_filter(
            vec!["create_issue".into(), "recreate_db".into()],
            Some("re:^create_"),
        )
        .expect("filter");
        assert_eq!(result, vec!["create_issue"]);
    }

    #[test]
    fn filter_re_prefix_surfaces_invalid_regex() {
        let err = apply_filter(vec!["a".into()], Some("re:[unclosed")).expect_err("invalid");
        assert!(err.to_string().contains("invalid --filter regex"));
    }

    #[test]
    fn shard_one_of_one_is_identity() {
        let names = vec!["x".to_string(), "y".to_string()];
        let result = apply_shard(names.clone(), ShardSpec { index: 1, total: 1 });
        assert_eq!(result, names);
    }

    #[test]
    fn shard_round_robins_in_yaml_order() {
        // Sorted: a=0, b=1, c=2, d=3. 4 buckets % 3:
        //   shard 1 (target 0): a, d
        //   shard 2 (target 1): b
        //   shard 3 (target 2): c
        // YAML order is [c, a, d, b].
        let candidates = vec![
            "c".to_string(),
            "a".to_string(),
            "d".to_string(),
            "b".to_string(),
        ];
        assert_eq!(
            apply_shard(candidates.clone(), ShardSpec { index: 1, total: 3 },),
            vec!["a", "d"]
        );
        assert_eq!(
            apply_shard(candidates.clone(), ShardSpec { index: 2, total: 3 },),
            vec!["b"]
        );
        assert_eq!(
            apply_shard(candidates, ShardSpec { index: 3, total: 3 },),
            vec!["c"]
        );
    }

    #[test]
    fn last_failed_filters_to_state_failures() {
        let state = LastRunState {
            config_path: "/tmp/x.yaml".into(),
            ended_at: "now".into(),
            failed: vec!["b".into(), "d".into()],
        };
        let result = apply_last_failed(
            vec!["a".into(), "b".into(), "c".into(), "d".into()],
            Some(&state),
        );
        assert_eq!(result, vec!["b", "d"]);
    }

    #[test]
    fn last_failed_empty_without_state() {
        let result = apply_last_failed(vec!["a".into()], None);
        assert!(result.is_empty());
    }

    #[test]
    fn failed_first_promotes_failures_to_head() {
        let state = LastRunState {
            config_path: "/tmp/x.yaml".into(),
            ended_at: "now".into(),
            failed: vec!["c".into()],
        };
        let result = apply_failed_first(
            vec!["a".into(), "b".into(), "c".into(), "d".into()],
            Some(&state),
        );
        assert_eq!(result, vec!["c", "a", "b", "d"]);
    }

    #[test]
    fn failed_first_no_state_keeps_order() {
        let result = apply_failed_first(vec!["a".into(), "b".into()], None);
        assert_eq!(result, vec!["a", "b"]);
    }

    #[test]
    fn tags_include_keeps_matching_only() {
        let mut map = std::collections::HashMap::new();
        map.insert("a".into(), vec!["smoke".into()]);
        map.insert("b".into(), vec!["slow".into()]);
        let result = apply_tags(
            vec!["a".into(), "b".into(), "c".into()],
            &map,
            &["smoke".into()],
            &[],
        );
        assert_eq!(result, vec!["a"]);
    }

    #[test]
    fn tags_skip_drops_matching() {
        let mut map = std::collections::HashMap::new();
        map.insert("b".into(), vec!["slow".into()]);
        let result = apply_tags(
            vec!["a".into(), "b".into(), "c".into()],
            &map,
            &[],
            &["slow".into()],
        );
        assert_eq!(result, vec!["a", "c"]);
    }

    #[test]
    fn tags_skip_wins_over_include() {
        let mut map = std::collections::HashMap::new();
        map.insert("a".into(), vec!["smoke".into(), "slow".into()]);
        let result = apply_tags(vec!["a".into()], &map, &["smoke".into()], &["slow".into()]);
        assert!(result.is_empty());
    }

    #[test]
    fn random_with_seed_is_reproducible() {
        let names = vec![
            "a".to_string(),
            "b".to_string(),
            "c".to_string(),
            "d".to_string(),
        ];
        let (first, seed_a) = apply_random(names.clone(), Some(42));
        let (second, seed_b) = apply_random(names, Some(42));
        assert_eq!(first, second);
        assert_eq!(seed_a, 42);
        assert_eq!(seed_b, 42);
    }

    #[test]
    fn random_without_seed_returns_chosen_seed() {
        let (_shuffled, seed) = apply_random(vec!["a".into(), "b".into()], None);
        // No assertion on the seed value, just that it was returned.
        // A zero seed is a perfectly valid random pick.
        let _ = seed;
    }
}
