//! Handler for `mcptest run`.
//!
//! The live runner and the watch loop live in sibling modules
//! ([`run_live`](super::run_live), [`run_watch`](super::run_watch)) so
//! this file stays focused on the run-once orchestration: gate, banner,
//! coverage threshold, then either re-loop under `--watch` or hand off
//! to the live executor.

use anyhow::Result;

use crate::cli::args::RunArgs;
use crate::cli::exit_codes::{EXIT_FAILURE, EXIT_INVALID_INVOCATION, EXIT_OK, EXIT_REFUSED};
use crate::cli::Cli;
use crate::config;
use crate::server_override::ServerOverride;

use super::run_live::run_live;
use super::run_live_options::build_runner_options;
use super::run_live_print::print_config_error;
use super::run_watch::{incompatible_watch_reporter, run_watch_loop};

/// Orchestrate one `mcptest run` invocation: gate flags, resolve the
/// variable map, then either re-loop under `--watch` or hand off to the
/// live executor in [`run_once`].
///
/// `--update-snapshots` is gated here so the CI-refusal check
/// trips before the runner does any work. The actual matcher-mode plumbing
/// is exercised by [`crate::cli::args::update_snapshots_mode_for`].
pub(crate) fn run_command(cli: &Cli, args: &RunArgs) -> Result<i32> {
    if args.update_snapshots && is_ci_env() && !args.allow_update_in_ci {
        eprintln!(
            "mcptest run --update-snapshots refused: CI=true is set. Pass --allow-update-in-ci to override."
        );
        return Ok(EXIT_REFUSED);
    }
    // WOR-1265: same guard, same exit, for the cassette twin of the flag.
    if args.update_cassettes && is_ci_env() && !args.allow_update_in_ci {
        eprintln!(
            "mcptest run --update-cassettes refused: CI=true is set. Pass --allow-update-in-ci to override."
        );
        return Ok(EXIT_REFUSED);
    }

    // WOR-1400: `--repeat N` drives the suite up to N times under a
    // sequential reliability test. It re-runs `run_once` like the watch
    // loop, so it shares the same "a pipe is read once" and "watch owns
    // the re-loop" constraints and is refused in combination with both.
    if let Some(repeat) = args.repeat {
        if repeat >= 2 {
            if cli.global.watch {
                eprintln!("mcptest run --repeat cannot be combined with --watch.");
                return Ok(EXIT_INVALID_INVOCATION);
            }
            if args.stdin {
                eprintln!(
                    "mcptest run --repeat cannot be combined with --stdin; a pipe is read once. Pass a file path."
                );
                return Ok(EXIT_INVALID_INVOCATION);
            }
            return run_repeated(cli, args);
        }
    }

    // WOR-1509: a suite can opt into the three-valued verdict via
    // `run_options.inconclusive.on` without passing `--repeat`; it then runs a
    // fixed `min_trials`-sample sequential test. `--watch`/`--stdin` are
    // incompatible with the re-loop (a pipe is read once), same as `--repeat`,
    // and the `suite_inconclusive` read is skipped for them.
    if args.repeat.is_none() && !cli.global.watch && !args.stdin && suite_inconclusive(cli, args).on
    {
        return run_repeated(cli, args);
    }

    if cli.global.watch {
        if args.stdin {
            // The watch loop re-runs run_once, but a pipe can be read only
            // once; later iterations would run an empty suite. Refuse up
            // front instead of silently degrading.
            eprintln!("mcptest run --stdin cannot be combined with --watch; pass a file path.");
            return Ok(EXIT_INVALID_INVOCATION);
        }
        if let Some(reason) = incompatible_watch_reporter(cli.global.reporter) {
            eprintln!("mcptest run --watch is only compatible with --reporter pretty ({reason}).");
            // WOR-1317: a flag combination we refuse up front is an
            // invocation error, not a test failure.
            return Ok(EXIT_INVALID_INVOCATION);
        }
        return run_watch_loop(cli, args, run_once);
    }

    run_once(cli, args)
}

/// One pass of the run command. Factored out so watch mode can call it
/// in a loop without duplicating the body.
fn run_once(cli: &Cli, args: &RunArgs) -> Result<i32> {
    let cwd = std::env::current_dir()?;

    // WOR-1267: compile the `--filter` expression up front so an invalid
    // `re:` regex is a config error (exit 2) with an actionable message,
    // not a generic run failure halfway through suite resolution. The
    // selection pass recompiles the same expression later; this gate only
    // exists to pin the exit code and fail before any work runs.
    if let Some(raw) = cli.global.filter.as_deref() {
        if let Err(msg) = crate::cli::parsers::FilterExpr::parse(raw) {
            eprintln!("mcptest run: {msg}");
            return Ok(EXIT_INVALID_INVOCATION);
        }
    }

    // Resolve where the suite comes from before anything reads it:
    // `--stdin` (piped YAML written to a temp file so the normal loading
    // path applies), the positional path, the global `--config`, or the
    // `./mcptest.yaml` / `./mcptest.yml` default, in that order.
    let source = match resolve_suite_source(cli, args, &cwd) {
        Ok(source) => source,
        Err(exit) => return Ok(exit),
    };
    let config_path = source.path.clone();

    // Schema validate the YAML up front so the operator sees a
    // structured error (with JSON Pointer paths and "did you mean" hints)
    // before any downstream resolver runs. The schema gate exists
    // independently of the runner so a typo never silently rides into
    // the runner stub.
    if config_path.exists() {
        if let Err(err) = mcptest_config::loader::load_and_validate(
            &config_path,
            mcptest_config::loader::LoadOptions::default(),
        ) {
            print_config_error(&config_path, &err);
            return Ok(EXIT_INVALID_INVOCATION);
        }
    } else if cli.global.verbose {
        eprintln!("mcptest run: {}", source.missing_message(&cwd));
    }

    // When any auth-override flag is set, overlay it on the parsed
    // auth schemes (CLI beats YAML) and validate the result before the run
    // proceeds. This surfaces a bad cert path, an invalid SigV4 region, or
    // the deferred `--profile` flag up front rather than at connect time.
    if let Some(exit) = apply_and_validate_auth_overrides(&config_path, args) {
        return Ok(exit);
    }

    let resolved = config::build_resolved(&cli.global, None, &cwd)?;
    if cli.global.verbose {
        config::print_verbose_summary(&resolved, cli.global.show_secrets);
    }
    // Bridge dotenv / --env-file / --env / --var values into the process
    // environment before interpolation and agent provider resolution, both of
    // which read straight from `std::env`. Done here while still single
    // threaded, before the tokio runtime and any server subprocess spawn.
    config::apply_resolved_to_process_env(&resolved);

    let override_ = build_override(cli)?;

    if let Some(exit) = note_deferred_flags(cli, args) {
        return Ok(exit);
    }

    //  /  closer: drive the runner end to end against
    // a live MCP server. The concrete executor connects to each
    // declared server, dispatches every test's action, evaluates the
    // expectations, and returns a structured report. Snapshot,
    // schema, and llm-judge matchers route through Matcher::Deferred
    // until the follow-up that wires them to the existing
    // mcptest_core::matchers crate.
    if !config_path.exists() {
        if !cli.global.quiet {
            println!("mcptest run: {}", source.missing_message(&cwd));
        }
        return Ok(EXIT_OK);
    }
    // `--print-config` and `--collect-only` short-circuit before any
    // server connection so the operator can debug variables, shards,
    // or filters without paying connect time. `--print-config` wins
    // when both are set because the operator clearly wants the
    // richer view.
    if args.print_config {
        return crate::cli::handlers::run_live_print::print_resolved_config(
            &config_path,
            cli.global.filter.as_deref(),
            args,
            &cli.global.proxy_config(),
            &override_,
        );
    }
    // `--explain` (WOR-1267) is the dry-run twin of `--collect-only`: it
    // resolves the suite and prints what would run (with assertions and
    // budgets) plus every skipped test with its reason, executing nothing.
    if args.explain {
        return crate::cli::handlers::run_live_print::print_explain(
            &config_path,
            cli.global.filter.as_deref(),
            cli.global.quiet,
            args,
            &override_,
        );
    }
    if args.collect_only {
        return crate::cli::handlers::run_live::print_discovered_tests(
            &config_path,
            cli.global.filter.as_deref(),
            cli.global.quiet,
            args,
            &override_,
        );
    }
    // `--resume` only applies to record runs: the non-record path already
    // replays existing cassettes, so there is no partial-record state to
    // recover. Say so plainly rather than silently honoring a no-op flag.
    if args.resume && !args.record && !args.update_cassettes && !cli.global.quiet {
        eprintln!(
            "mcptest run --resume has no effect without --record; the non-record path already \
             replays existing cassettes."
        );
    }
    let mut runner_options = build_runner_options(args);
    // WOR-1398: opt-in run-path result cache, rooted under the suite dir.
    runner_options.result_cache = result_cache_options(args, &config_path);
    run_live(
        &config_path,
        runner_options,
        cli.global.parallel,
        cli.global.quiet,
        // WOR-1265: `--update-cassettes` re-records agent cassettes through
        // the existing `--record` path (one recording pipeline); server
        // cassettes are refreshed inside `run_live` before connect.
        args.record || args.update_cassettes,
        args.resume,
        cli.global.filter.as_deref(),
        args,
        cli.global.proxy_config(),
        &override_,
        cli.global.output.as_deref(),
        cli.global.reporter,
        cli.global.annotations,
        cli.global.color,
        cli.global.wait_for_ready,
        cli.global.agent_budget,
    )
}

/// Unreliable-hypothesis pass probability for `--repeat` (WOR-1400).
///
/// A suite that passes only at coin-flip odds is judged unreliable. Paired
/// with [`REPEAT_P1`] this lets an all-pass suite cross the reliable
/// boundary in a handful of trials and an all-fail suite cross the
/// unreliable boundary even faster.
const REPEAT_P0: f64 = 0.5;

/// Reliable-hypothesis pass probability for `--repeat` (WOR-1400). A suite
/// that passes at least this often is judged reliable.
const REPEAT_P1: f64 = 0.9;

/// Drive the suite up to `--repeat N` times under a Sequential Probability
/// Ratio Test and return a three-valued exit code (WOR-1400).
///
/// Each pass of [`run_once`] is one trial: exit `0` is a success, exit `1`
/// (a test failure) a miss. Any other exit (an invalid invocation, a policy
/// refusal, an empty selection, an interrupt) is not a pass/fail signal, so
/// the loop stops and propagates it rather than folding a broken run into
/// the verdict. The SPRT updates after every trial and breaks early the
/// moment a boundary is crossed, so a decisive suite spends well under N
/// runs.
/// The suite's resolved `run_options.inconclusive` block (WOR-1509). Returns
/// the default (off) when the suite cannot be resolved or parsed; the normal
/// load path surfaces those errors, so this stays a best-effort read. Never
/// called for `--stdin` (the caller guards it) so it cannot consume the pipe.
fn suite_inconclusive(
    cli: &Cli,
    args: &RunArgs,
) -> mcptest_config::run_options::InconclusiveOptions {
    let Ok(cwd) = std::env::current_dir() else {
        return Default::default();
    };
    let Ok(source) = resolve_suite_source(cli, args, &cwd) else {
        return Default::default();
    };
    let Ok(body) = std::fs::read_to_string(&source.path) else {
        return Default::default();
    };
    let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&body) else {
        return Default::default();
    };
    super::run_live_options::suite_run_options(&yaml)
        .map(|opts| opts.inconclusive)
        .unwrap_or_default()
}

fn run_repeated(cli: &Cli, args: &RunArgs) -> Result<i32> {
    use mcptest_core::eval::sprt::{SprtConfig, SprtDecision, SprtOutcome};

    // The dry-run views print and exit `0` without testing anything, so
    // repeating them would just reprint N times and accumulate hollow
    // successes. Run them once.
    if args.print_config || args.collect_only || args.explain {
        return run_once(cli, args);
    }

    // WOR-1509: `--repeat N` sets the trial budget and uses `--repeat-confidence`;
    // a config-driven run (`inconclusive.on`, no `--repeat`) runs exactly
    // `min_trials` samples at the suite's `inconclusive.confidence_pct`. Either
    // way `inconclusive.min_trials` is a floor: the sequential test cannot
    // return a definite PASS/FAIL before that many trials.
    let inc = suite_inconclusive(cli, args);
    let max_trials = match args.repeat {
        Some(n) if n >= 2 => n as usize,
        _ => inc.min_trials.max(2),
    };
    let confidence_pct = if args.repeat.is_some() {
        args.repeat_confidence
    } else {
        inc.confidence_pct
    };
    let min_trials = inc.min_trials;
    let config = SprtConfig::from_confidence_pct(confidence_pct, REPEAT_P0, REPEAT_P1);
    if let Err(reason) = config.validate() {
        eprintln!("mcptest run --repeat: invalid sequential-test configuration: {reason}");
        return Ok(EXIT_INVALID_INVOCATION);
    }

    if !cli.global.quiet {
        eprintln!(
            "mcptest run --repeat {max_trials}: sequential reliability test \
             (floor {REPEAT_P0}, target {REPEAT_P1}, confidence {confidence_pct}%)"
        );
    }

    let mut passes = 0usize;
    let mut trials = 0usize;
    for i in 0..max_trials {
        if !cli.global.quiet {
            eprintln!("--- repeat trial {}/{} ---", i + 1, max_trials);
        }
        let exit = run_once(cli, args)?;
        match exit {
            EXIT_OK => passes += 1,
            EXIT_FAILURE => {}
            other => {
                eprintln!(
                    "mcptest run --repeat: trial {} exited {other} (not a pass/fail result); stopping.",
                    i + 1
                );
                return Ok(other);
            }
        }
        trials += 1;
        if trials >= min_trials && config.decide(passes, trials) != SprtDecision::Continue {
            break;
        }
    }

    let outcome = SprtOutcome::build(&config, passes, trials, max_trials, confidence_pct);
    print_repeat_summary(&outcome);
    Ok(repeat_exit_code(&outcome, args.repeat_inconclusive_fail))
}

/// Print the one-line `--repeat` verdict (always, even under `--quiet`: the
/// verdict is the whole point of the command).
fn print_repeat_summary(outcome: &mcptest_core::eval::sprt::SprtOutcome) {
    use mcptest_core::eval::verdict::Verdict;
    let label = match outcome.verdict {
        Verdict::Pass => "pass",
        Verdict::Fail => "fail",
        Verdict::Inconclusive => "inconclusive",
    };
    let (lower, upper) = outcome.interval;
    let early = if outcome.early_stopped {
        ", early-stopped"
    } else {
        ""
    };
    println!(
        "repeat verdict: {label} ({passes}/{trials} trials passed, rate {rate:.0}% \
         [{lower:.0}%, {upper:.0}%], {trials}/{budget} budget{early})",
        passes = outcome.passes,
        trials = outcome.trials,
        rate = outcome.pass_rate * 100.0,
        lower = lower * 100.0,
        upper = upper * 100.0,
        budget = outcome.max_trials,
    );
}

/// Map a `--repeat` outcome onto an exit code. Pass is `0`, fail is `1`, and
/// an inconclusive verdict stays neutral (`0`) unless `inconclusive_fails`
/// arms it as a failure, mirroring `run_options.inconclusive.treat_as`.
fn repeat_exit_code(
    outcome: &mcptest_core::eval::sprt::SprtOutcome,
    inconclusive_fails: bool,
) -> i32 {
    use mcptest_core::eval::verdict::Verdict;
    match outcome.verdict {
        Verdict::Pass => EXIT_OK,
        Verdict::Fail => EXIT_FAILURE,
        Verdict::Inconclusive => {
            if inconclusive_fails {
                EXIT_FAILURE
            } else {
                EXIT_OK
            }
        }
    }
}

/// Where the suite YAML came from. The flag matters because the
/// missing-config message for a defaulted path must name both accepted
/// file names (WOR-1350), while an explicit path names exactly what the
/// user typed.
struct SuiteSource {
    /// Path the loading pipeline should read.
    path: std::path::PathBuf,
    /// True when the user passed no path and neither default candidate
    /// exists, so `path` is only the conventional fallback.
    defaulted_missing: bool,
}

impl SuiteSource {
    /// Render the note printed when the resolved path does not exist.
    fn missing_message(&self, cwd: &std::path::Path) -> String {
        if self.defaulted_missing {
            crate::cli::default_config::no_config_message(cwd)
        } else {
            format!("no config at {}", self.path.display())
        }
    }
}

/// Resolve the suite source for this run. Returns the path the loading
/// pipeline should read, or `Err(exit_code)` when the flag combination is
/// invalid, the bare default is ambiguous, or stdin could not be captured.
///
/// `--stdin` and the positional path are mutually exclusive at the clap
/// layer; `--stdin` with the global `--config` is checked here because the
/// two flags live on different arg structs.
fn resolve_suite_source(
    cli: &Cli,
    args: &RunArgs,
    cwd: &std::path::Path,
) -> std::result::Result<SuiteSource, i32> {
    if args.stdin {
        if cli.global.config.is_some() {
            eprintln!(
                "mcptest run --stdin conflicts with --config; pipe the suite or pass a path, not both."
            );
            return Err(EXIT_INVALID_INVOCATION);
        }
        return capture_stdin_suite()
            .map(|path| SuiteSource {
                path,
                defaulted_missing: false,
            })
            .map_err(|err| {
                eprintln!("mcptest run --stdin: could not read the piped suite: {err}");
                EXIT_INVALID_INVOCATION
            });
    }
    if let Some(path) = args.suite.clone().or_else(|| cli.global.config.clone()) {
        return Ok(SuiteSource {
            path,
            defaulted_missing: false,
        });
    }
    use crate::cli::default_config::{resolve_default_config, DefaultConfig};
    match resolve_default_config(cwd) {
        Ok(DefaultConfig::Found(path)) => Ok(SuiteSource {
            path,
            defaulted_missing: false,
        }),
        Ok(DefaultConfig::Missing(path)) => Ok(SuiteSource {
            path,
            defaulted_missing: true,
        }),
        Err(ambiguous) => {
            eprintln!("mcptest run: {}", ambiguous.message());
            Err(EXIT_INVALID_INVOCATION)
        }
    }
}

/// Write the piped suite to a uniquely named temp file so schema
/// validation and interpolation run the same path as a file-backed suite.
///
/// The file is deliberately not removed afterwards: repro lines in the
/// report reference it, and the OS owns temp-dir cleanup. Pid plus nanos
/// keeps concurrent `--stdin` runs from colliding.
fn capture_stdin_suite() -> std::io::Result<std::path::PathBuf> {
    use std::io::Read as _;
    let mut suite = String::new();
    std::io::stdin().read_to_string(&mut suite)?;
    let pid = std::process::id();
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let path = std::env::temp_dir().join(format!("mcptest-stdin-{pid}-{nanos}.yml"));
    std::fs::write(&path, suite)?;
    Ok(path)
}

/// Overlay the auth-override flags onto every server's parsed auth schemes
/// and validate the result. Returns `Some(exit)` when the
/// operator's flags are rejected (deferred `--profile`, unknown algorithm,
/// or an override that fails the config validator), `None` to continue.
///
/// A no-op when no auth-override flag is set, or when there is no config
/// file (the flags have nothing to override). CLI values win over YAML.
fn apply_and_validate_auth_overrides(config_path: &std::path::Path, args: &RunArgs) -> Option<i32> {
    use crate::cli::args::{any_auth_override, apply_auth_overrides};
    use mcptest_config::auth_schemes::parse_auth_schemes;

    if !any_auth_override(args) || !config_path.exists() {
        return None;
    }
    let raw = std::fs::read_to_string(config_path).ok()?;
    let yaml: serde_yaml::Value = serde_yaml::from_str(&raw).ok()?;
    let servers = yaml.get("servers").and_then(serde_yaml::Value::as_mapping);
    // No servers block: still validate the flags themselves (so `--profile`
    // is rejected) against an empty scheme set.
    let server_specs: Vec<&serde_yaml::Value> = match servers {
        Some(map) => map.values().collect(),
        None => vec![&yaml],
    };
    for spec in server_specs {
        let mut schemes = parse_auth_schemes(spec).unwrap_or_default();
        if let Err(err) = apply_auth_overrides(&mut schemes, args) {
            eprintln!("mcptest run: {err}");
            return Some(EXIT_INVALID_INVOCATION);
        }
        if let Err(err) = validate_overridden_schemes(&schemes) {
            eprintln!("mcptest run: auth override is invalid: {err}");
            return Some(EXIT_INVALID_INVOCATION);
        }
    }
    None
}

/// Run the config validators on the post-override schemes so a CLI flag that
/// produces an invalid block (a cert with no key, an empty SigV4 region) is
/// caught before the run starts.
fn validate_overridden_schemes(
    schemes: &mcptest_config::auth_schemes::AuthSchemes,
) -> Result<(), mcptest_core::auth::AuthError> {
    if let Some(mtls) = &schemes.mtls {
        mtls.validate()?;
    }
    if let Some(sigv4) = &schemes.sigv4 {
        sigv4.validate()?;
    }
    if let Some(wba) = &schemes.web_bot_auth {
        wba.validate()?;
    }
    Ok(())
}

/// Build the server-target override from the CLI, validate it, and print the
/// applied-override line plus the insecure-skip-verify banner when set.
fn build_override(cli: &Cli) -> Result<ServerOverride> {
    let override_ = ServerOverride::from_cli(cli)?;
    if override_.is_any() {
        override_.validate()?;
        if !cli.global.quiet {
            println!(
                "Server-target override applied: {}",
                override_.flag_names().join(", ")
            );
        }
        if override_.warns_about_insecure_skip_verify() {
            // Loud banner: an operator who set --insecure-skip-verify in a
            // CI job and forgot about it should notice the next time logs
            // are read.
            eprintln!("WARNING: --insecure-skip-verify is set. TLS certificate verification is OFF for HTTP transport. Do not use against production endpoints.");
        }
    }
    Ok(override_)
}

/// Print the "not yet wired" notes for flags the run path defers, and enforce
/// the one hard gate among them. Returns `Some(exit)` when a gate fails (the
/// caller should return that code), `None` to continue.
fn note_deferred_flags(cli: &Cli, args: &RunArgs) -> Option<i32> {
    // The `--coverage-threshold` validation stays: passing a gate without
    // `--coverage` is a configuration mistake, and the exit-2 check catches
    // it before any work runs.
    if args.coverage_threshold.is_some() && !args.coverage {
        eprintln!("mcptest run --coverage-threshold requires --coverage to also be set.");
        return Some(EXIT_INVALID_INVOCATION);
    }

    // WOR-1398: the result-cache read/write flags only take effect under
    // `--cache` (off by default), so warn when they were passed without it.
    // `--cache-filter` selection is still deferred, so note it whenever set.
    if !cli.global.quiet {
        let read_write_set = args.no_cache || args.no_cache_read || args.no_cache_write;
        if read_write_set && !args.cache {
            eprintln!(
                "WARNING: --no-cache / --no-cache-read / --no-cache-write have no effect without \
                 --cache; run-path result caching is off by default."
            );
        }
        if args.cache_filter.is_some() {
            eprintln!(
                "WARNING: --cache-filter is not yet honored on the run path; every selected test \
                 runs regardless of cache state."
            );
        }
    }
    None
}

/// Resolve the run-path result-cache settings from the CLI flags (WOR-1398).
///
/// Off unless `--cache` is set. The store is rooted at the suite's
/// `.mcptest/cache` so two suites that reuse a server name never share
/// entries; `--no-cache` clears both read and write, and the split
/// `--no-cache-read` / `--no-cache-write` flags clear one side each.
fn result_cache_options(
    args: &RunArgs,
    config_path: &std::path::Path,
) -> mcptest_core::runner::ResultCacheOptions {
    use mcptest_core::runner::ResultCacheOptions;
    if !args.cache {
        return ResultCacheOptions::default();
    }
    let enabled = !args.no_cache;
    ResultCacheOptions {
        enabled,
        read: enabled && !args.no_cache_read,
        write: enabled && !args.no_cache_write,
        root: config_path
            .parent()
            .map(|dir| dir.join(".mcptest").join("cache")),
    }
}

/// True when the standard CI signal is set in the environment.
///
/// We deliberately match only the literal string `"true"` so a developer
/// who set `CI=local-dev` for an experiment does not get blocked. Real
/// CI providers (GitHub Actions, GitLab CI, CircleCI, Buildkite) set
/// the value to `true`. Crate-visible because `commands::compliance`
/// applies the same CI refusal to focused suites (WOR-1282).
pub(crate) fn is_ci_env() -> bool {
    matches!(std::env::var("CI"), Ok(v) if v == "true")
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_core::eval::sprt::{SprtConfig, SprtOutcome};

    fn outcome(passes: usize, trials: usize) -> SprtOutcome {
        let config = SprtConfig::from_confidence_pct(95, REPEAT_P0, REPEAT_P1);
        SprtOutcome::build(&config, passes, trials, 20, 95)
    }

    #[test]
    fn a_reliable_repeat_outcome_exits_zero() {
        // 12 of 12 passes clears the reliable boundary.
        assert_eq!(repeat_exit_code(&outcome(12, 12), false), EXIT_OK);
    }

    #[test]
    fn an_unreliable_repeat_outcome_exits_one() {
        // 0 of 4 passes clears the unreliable boundary.
        assert_eq!(repeat_exit_code(&outcome(0, 4), false), EXIT_FAILURE);
    }

    #[test]
    fn an_inconclusive_repeat_outcome_is_neutral_by_default_and_gates_when_armed() {
        // 2 of 4 stays between the boundaries: undecided.
        let undecided = outcome(2, 4);
        assert_eq!(repeat_exit_code(&undecided, false), EXIT_OK);
        assert_eq!(repeat_exit_code(&undecided, true), EXIT_FAILURE);
    }
}
