//! Runner configuration options.
//!
//! Split out from `engine.rs` so the engine module can focus on the
//! orchestration state machine. Every option here maps onto a public
//! CLI flag (`--parallel`, `--timeout`, `--retry`, `--fail-fast`); the
//! types stay simple enough that the CLI command can build a
//! [`RunnerOptions`] directly from parsed clap matches.

use std::time::Duration;

/// How many tests the runner may execute concurrently.
///
/// `Auto` picks a sensible default at run time (the number of logical
/// CPUs, clamped to at least one). `Fixed(n)` honors `--parallel n`
/// from the CLI. We keep the enum public so callers can pattern-match
/// in tests; the engine resolves to a concrete bound via
/// [`Parallelism::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Parallelism {
    /// Use the runtime default (logical CPU count, clamped to 1).
    #[default]
    Auto,
    /// Cap concurrency at the given positive integer. `Fixed(0)` is
    /// treated as `Fixed(1)` so the runner always makes forward
    /// progress.
    Fixed(usize),
}

impl Parallelism {
    /// Fold the CLI `--parallel` flag and the suite's `run_options.parallel`
    /// value into one concurrency policy (WOR-1283). Exists so the precedence
    /// rule (CLI wins over YAML, YAML wins over the Auto default) lives in
    /// one tested place instead of being re-derived per call site. A CLI
    /// value of `0` is the documented "force auto" spelling, so an explicit
    /// zero still overrides a YAML worker count.
    pub fn effective(cli_flag: Option<usize>, suite: Parallelism) -> Parallelism {
        match cli_flag {
            Some(0) => Parallelism::Auto,
            Some(n) => Parallelism::Fixed(n),
            None => suite,
        }
    }

    /// Resolve to a concrete worker count.
    ///
    /// `Auto` calls [`std::thread::available_parallelism`] and clamps
    /// the result to at least one. `Fixed(n)` clamps `n` to at least
    /// one so an accidental `--parallel 0` cannot deadlock the runner.
    pub fn resolve(&self) -> usize {
        match self {
            Parallelism::Auto => std::thread::available_parallelism()
                .map(|n| n.get())
                .unwrap_or(1),
            Parallelism::Fixed(n) => (*n).max(1),
        }
    }
}

/// Options the [`Runner`](super::engine::Runner) consumes at
/// construction time.
///
/// All fields have CLI counterparts. The defaults mirror what the
/// `mcptest run` subcommand applies when the user omits a flag.
#[derive(Debug, Clone)]
pub struct RunnerOptions {
    /// Concurrency policy. See [`Parallelism`].
    pub parallel: Parallelism,

    /// Per-test timeout applied when a [`TestSpec`](super::executor::TestSpec)
    /// does not override it. The CLI maps `--timeout` here.
    pub default_timeout: Duration,

    /// Retry count applied when a [`TestSpec`](super::executor::TestSpec)
    /// does not override it. `0` means "no retries"; `n > 0` means up
    /// to `n` extra attempts after the first.
    pub retry: u32,

    /// Maximum number of failing tests before the runner aborts the
    /// remaining specs. `None` means "run them all"; `Some(n)` aborts
    /// once `n` tests have failed (subsequent tests are recorded as
    /// `Skip`). The CLI maps `--bail` to `Some(1)` and `--maxfail N`
    /// to `Some(N)`.
    pub max_failures: Option<u32>,

    /// Fraction of tests to actually run, in `0.0..=1.0` (WOR-1386). The CLI
    /// maps `--sample-rate`. `1.0` (the default) runs every test; a smaller
    /// value deterministically skips tests by hashing the test name, so the
    /// same suite always samples the same subset. Sampled-out tests occupy a
    /// `Skip` row rather than vanishing from the report.
    pub sample_rate: f64,

    /// Run-path result-cache settings (WOR-1398). The CLI maps `--cache` and
    /// the `--no-cache*` flags here. The default is off, so a bare
    /// `mcptest run` never serves a cached response.
    pub result_cache: ResultCacheOptions,
}

/// Run-path result-cache settings (WOR-1398).
///
/// Off by default; `mcptest run --cache` turns it on and the `--no-cache*`
/// flags refine read and write. The store is rooted per suite (under the
/// suite's `.mcptest/cache`), so two suites that reuse a server name never
/// share entries.
#[derive(Debug, Clone, Default)]
pub struct ResultCacheOptions {
    /// Whether result caching is active at all (the `--cache` opt-in).
    pub enabled: bool,
    /// Serve a hit (cleared by `--no-cache-read` / `--no-cache`).
    pub read: bool,
    /// Store a fresh response (cleared by `--no-cache-write` / `--no-cache`).
    pub write: bool,
    /// Directory the filesystem store is rooted at. `None` disables the cache
    /// even when [`Self::enabled`] is set, since there is nowhere to put
    /// entries.
    pub root: Option<std::path::PathBuf>,
}

impl Default for RunnerOptions {
    fn default() -> Self {
        Self {
            parallel: Parallelism::default(),
            // 60s mirrors the CLI default for `--timeout`. Long enough
            // to outlive any single MCP call worth running, short
            // enough to catch a server that quietly hangs.
            default_timeout: Duration::from_secs(60),
            retry: 0,
            max_failures: None,
            // Run every test by default; sampling is opt-in via --sample-rate.
            sample_rate: 1.0,
            // Result caching is opt-in via --cache (WOR-1398).
            result_cache: ResultCacheOptions::default(),
        }
    }
}

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

    #[test]
    fn auto_resolves_to_at_least_one() {
        let n = Parallelism::Auto.resolve();
        assert!(n >= 1, "available_parallelism floor: {n}");
    }

    #[test]
    fn fixed_clamps_zero_to_one() {
        assert_eq!(Parallelism::Fixed(0).resolve(), 1);
    }

    #[test]
    fn fixed_preserves_positive_value() {
        assert_eq!(Parallelism::Fixed(4).resolve(), 4);
    }

    #[test]
    fn cli_parallel_flag_overrides_suite_run_options() {
        // YAML `run_options.parallel: 1` serializes, but an explicit
        // `--parallel 3` on the command line wins.
        assert_eq!(
            Parallelism::effective(Some(3), Parallelism::Fixed(1)),
            Parallelism::Fixed(3)
        );
    }

    #[test]
    fn cli_parallel_zero_forces_auto_over_suite_value() {
        // `--parallel 0` is the documented "auto" spelling, and the CLI
        // wins over YAML, so the suite's pinned worker count is dropped.
        assert_eq!(
            Parallelism::effective(Some(0), Parallelism::Fixed(4)),
            Parallelism::Auto
        );
    }

    #[test]
    fn suite_run_options_apply_when_cli_flag_absent() {
        assert_eq!(
            Parallelism::effective(None, Parallelism::Fixed(1)),
            Parallelism::Fixed(1)
        );
    }

    #[test]
    fn parallelism_defaults_to_auto_without_cli_or_suite_value() {
        assert_eq!(
            Parallelism::effective(None, Parallelism::Auto),
            Parallelism::Auto
        );
    }

    #[test]
    fn default_options_have_safe_floor() {
        let o = RunnerOptions::default();
        assert!(o.default_timeout >= Duration::from_secs(1));
        assert_eq!(o.retry, 0);
        assert!(o.max_failures.is_none());
        assert!(matches!(o.parallel, Parallelism::Auto));
    }
}
