//! The OSS `exec` scorer.
//!
//! Why a subprocess contract rather than bundled vendor adapters: most
//! teams already pay for an eval platform (Braintrust, Patronus, promptfoo,
//! ...). Forcing them to choose between mcptest's built-in judge and their
//! stack is an adoption blocker. The `exec` scorer is the extension point:
//! mcptest writes `{input, output, criteria, metadata}` to a wrapper
//! process's stdin and reads `{verdict, score, reasoning, cost_usd}` back
//! from stdout. A user (or a vendor) writes a thin 10-20 line wrapper;
//! mcptest maintains no per-vendor code in OSS. Named, managed adapters are
//! the enterprise tier.
//!
//! Each call spawns a fresh process: the wrapper reads one JSON document,
//! writes one back, and exits. One-shot keeps the contract trivial to
//! implement in any language and matches how eval CLIs (promptfoo, etc.)
//! already shell out.
//!
//! Trust boundary: the wrapper is an arbitrary user-supplied process, so
//! nothing it emits is trusted. Its stdout is capped (a runaway wrapper is
//! killed rather than buffered), its stderr is scrubbed of secret-shaped
//! tokens and truncated before it lands in an error message, and its
//! self-reported `cost_usd` is an unvalidated claim that gets clamped, not
//! a measurement.

use std::process::Stdio;
use std::time::Duration;

use async_trait::async_trait;
use mcptest_scorer_core::{ScoreRequest, ScoreResponse, ScorerAdapter, ScorerError};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::process::{Child, Command};
use tokio::time::timeout;

use super::config::ExecScorerConfig;

/// Hard cap on wrapper stdout: 4 MiB. A verdict document is a few hundred
/// bytes; anything past this is a bug or an attack, and buffering it
/// unbounded would let a wrapper exhaust memory.
const STDOUT_CAP: usize = 4 * 1024 * 1024;

/// Cap on captured stderr: 64 KiB. Enough to keep the head of any stack
/// trace; the rest is drained and discarded so the child never blocks on a
/// full pipe.
const STDERR_CAP: usize = 64 * 1024;

/// Max bytes of scrubbed stderr embedded in a `Backend` error message.
const STDERR_DETAIL_MAX: usize = 2048;

/// Ceiling for a wrapper-claimed `cost_usd`. No single scoring call
/// plausibly costs more; a larger claim clamps here rather than blowing
/// the `--max-cost` budget on a wrapper bug.
const COST_USD_CEILING: f64 = 1000.0;

/// A scorer that shells out to an external wrapper command.
#[derive(Debug, Clone)]
pub struct ExecScorer {
    config: ExecScorerConfig,
}

impl ExecScorer {
    /// Build a scorer from its parsed config.
    pub fn new(config: ExecScorerConfig) -> Self {
        Self { config }
    }

    fn timeout(&self) -> Duration {
        Duration::from_millis(self.config.timeout_ms)
    }
}

#[async_trait]
impl ScorerAdapter for ExecScorer {
    fn scorer_type(&self) -> &str {
        "exec"
    }

    /// The configured scorer `name` doubles as the version handle in the
    /// verdict cache key: renaming a wrapper (or bumping a versioned name
    /// like `judge-v2`) invalidates its cached verdicts.
    fn scorer_version(&self) -> &str {
        &self.config.name
    }

    /// Exec wrappers run arbitrary user commands and are not idempotent:
    /// retrying a failed call replays the wrapper's side effects (API
    /// spend, file writes), and a deterministic non-zero exit fails the
    /// same way on every attempt. One failed call is final.
    fn retryable(&self) -> bool {
        false
    }

    async fn score(&self, request: ScoreRequest) -> Result<ScoreResponse, ScorerError> {
        let payload = request.to_canonical_json().map_err(|err| {
            ScorerError::Backend(format!("could not encode score request: {err}"))
        })?;
        let output = self.run_process(&payload).await?;
        parse_verdict(&output)
    }
}

/// Outcome of reading a wrapper's pipes under the output caps.
enum Collected {
    /// The wrapper finished within the caps.
    Done {
        stdout: Vec<u8>,
        stderr: Vec<u8>,
        status: std::process::ExitStatus,
    },
    /// The wrapper exceeded the stdout cap; the child has been killed.
    StdoutOverflow,
}

impl ExecScorer {
    /// Spawn the wrapper, write `payload` to its stdin, close stdin, and
    /// collect capped stdout/stderr under the configured timeout.
    async fn run_process(&self, payload: &str) -> Result<String, ScorerError> {
        let mut child = self.spawn_and_feed(payload).await?;
        let collected = match timeout(self.timeout(), collect_capped(&mut child)).await {
            Ok(result) => result?,
            // `kill_on_drop` reaps the wedged child when `child` drops.
            Err(_) => return Err(ScorerError::Timeout(self.timeout())),
        };
        let (stdout, stderr, status) = match collected {
            Collected::StdoutOverflow => {
                return Err(ScorerError::Backend(format!(
                    "scorer `{}` produced more than 4 MiB of output",
                    self.config.name
                )));
            }
            Collected::Done {
                stdout,
                stderr,
                status,
            } => (stdout, stderr, status),
        };
        if !status.success() {
            let text = String::from_utf8_lossy(&stderr);
            let detail = truncate_detail(&scrub_secrets(text.trim()), STDERR_DETAIL_MAX);
            let suffix = if detail.is_empty() {
                String::new()
            } else {
                format!(": {detail}")
            };
            return Err(ScorerError::Backend(format!(
                "scorer `{}` exited with {}{suffix}",
                self.config.name, status
            )));
        }
        Ok(String::from_utf8_lossy(&stdout).into_owned())
    }

    /// Spawn the wrapper and feed it the one request document on stdin.
    async fn spawn_and_feed(&self, payload: &str) -> Result<Child, ScorerError> {
        let (program, args) = self
            .config
            .command
            .split_first()
            .ok_or_else(|| ScorerError::InvalidConfig("exec scorer command is empty".into()))?;
        let mut cmd = Command::new(program);
        cmd.args(args)
            .envs(&self.config.env)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .kill_on_drop(true);
        if let Some(dir) = &self.config.cwd {
            cmd.current_dir(dir);
        }
        let mut child = cmd
            .spawn()
            .map_err(|err| ScorerError::Transport(format!("failed to spawn `{program}`: {err}")))?;

        let mut stdin = child
            .stdin
            .take()
            .ok_or_else(|| ScorerError::Transport("scorer stdin was not piped".into()))?;
        // A wrapper that exits before reading its stdin (it rejects the input
        // and exits non-zero) closes the read end, so our write races into a
        // broken pipe. That is not the meaningful failure: swallow it and let
        // the exit-status check below surface the backend error. Without this
        // the result is timing-dependent across platforms. Other write errors
        // still surface.
        match stdin.write_all(payload.as_bytes()).await {
            Ok(()) => {}
            Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => {}
            Err(err) => {
                return Err(ScorerError::Transport(format!(
                    "writing scorer stdin: {err}"
                )));
            }
        }
        // Drop stdin so the wrapper sees EOF and can finish reading its one
        // input document.
        drop(stdin);
        Ok(child)
    }
}

/// Read stdout and stderr under their caps, then reap the child.
///
/// stderr drains on its own task so a wrapper that interleaves heavy
/// stderr with stdout cannot deadlock on a full pipe. On stdout overflow
/// the child is killed before returning, so a runaway wrapper stops
/// immediately instead of running until the timeout.
async fn collect_capped(child: &mut Child) -> Result<Collected, ScorerError> {
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| ScorerError::Transport("scorer stdout was not piped".into()))?;
    let stderr = child
        .stderr
        .take()
        .ok_or_else(|| ScorerError::Transport("scorer stderr was not piped".into()))?;
    let stderr_task = tokio::spawn(read_capped(stderr, STDERR_CAP, true));
    let (stdout_buf, over_cap) = read_capped(stdout, STDOUT_CAP, false)
        .await
        .map_err(|err| ScorerError::Transport(format!("reading scorer stdout: {err}")))?;
    if over_cap {
        if let Err(err) = child.kill().await {
            tracing::warn!("could not kill scorer after stdout overflow: {err}");
        }
        return Ok(Collected::StdoutOverflow);
    }
    let status = child
        .wait()
        .await
        .map_err(|err| ScorerError::Transport(format!("waiting on scorer: {err}")))?;
    // A failed stderr read loses diagnostics, not the verdict: the exit
    // status above already decided the outcome, so degrade to an empty
    // detail rather than failing the call.
    let stderr_buf = match stderr_task.await {
        Ok(Ok((buf, _))) => buf,
        _ => Vec::new(),
    };
    Ok(Collected::Done {
        stdout: stdout_buf,
        stderr: stderr_buf,
        status,
    })
}

/// Read `pipe` keeping at most `cap` bytes.
///
/// Why a manual loop instead of `read_to_end`: an arbitrary wrapper can
/// write unbounded output, and `read_to_end` would buffer all of it. With
/// `drain` set the read continues past the cap (discarding bytes) so the
/// child never blocks on a full pipe; without it the read stops at the
/// first byte over the cap so the caller can kill the child immediately.
/// The second tuple element reports whether the cap was exceeded.
async fn read_capped<R>(mut pipe: R, cap: usize, drain: bool) -> std::io::Result<(Vec<u8>, bool)>
where
    R: AsyncRead + Unpin,
{
    let mut buf = Vec::new();
    let mut chunk = [0u8; 8192];
    let mut over_cap = false;
    loop {
        let n = pipe.read(&mut chunk).await?;
        if n == 0 {
            return Ok((buf, over_cap));
        }
        if buf.len() + n > cap {
            over_cap = true;
            let keep = cap.saturating_sub(buf.len());
            buf.extend_from_slice(&chunk[..keep]);
            if !drain {
                return Ok((buf, true));
            }
        } else {
            buf.extend_from_slice(&chunk[..n]);
        }
    }
}

/// Mask secret-shaped substrings in wrapper stderr before it lands in an
/// error message that reporters echo verbatim.
///
/// Provider API keys (`sk-ant-`, `sk-proj-`, `sk-`, `AIzaSy`) go through
/// the existing [`mcptest_agent::redact_secrets`] redactor; this adds the
/// shapes that redactor does not cover (GitHub `ghp_`/`gho_`/`ghs_`
/// tokens, AWS `AKIA` access key ids, long token runs after "Bearer "),
/// mirroring the central `mcptest-core` redactor's pattern set.
/// Conservative by design: only the matched token is masked, the
/// surrounding text stays so the failure remains diagnosable.
fn scrub_secrets(text: &str) -> String {
    let mut out = mcptest_agent::redact_secrets(text);
    for prefix in ["ghp_", "gho_", "ghs_", "AKIA"] {
        out = mask_prefixed_token(&out, prefix);
    }
    mask_bearer_token(&out)
}

/// Replace each occurrence of `prefix` plus the token run following it
/// with `[REDACTED]`. Short runs stay (the bare word "AKIA" in prose is
/// not a credential), so the threshold errs toward keeping text.
fn mask_prefixed_token(text: &str, prefix: &str) -> String {
    const MIN_TOKEN_RUN: usize = 8;
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    while let Some(pos) = rest.find(prefix) {
        out.push_str(&rest[..pos]);
        let after = &rest[pos + prefix.len()..];
        let run = after
            .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
            .unwrap_or(after.len());
        if run >= MIN_TOKEN_RUN {
            out.push_str("[REDACTED]");
        } else {
            out.push_str(prefix);
            out.push_str(&after[..run]);
        }
        rest = &after[run..];
    }
    out.push_str(rest);
    out
}

/// Mask the credential after "Bearer " while keeping the scheme word, so
/// "401: Bearer <token> rejected" stays diagnosable without the token.
fn mask_bearer_token(text: &str) -> String {
    const MIN_TOKEN_RUN: usize = 16;
    const SCHEME: &str = "Bearer ";
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    while let Some(pos) = rest.find(SCHEME) {
        let token_start = pos + SCHEME.len();
        out.push_str(&rest[..token_start]);
        let after = &rest[token_start..];
        let run = after
            .find(|c: char| {
                !c.is_ascii_alphanumeric() && !matches!(c, '+' | '/' | '=' | '_' | '-' | '.')
            })
            .unwrap_or(after.len());
        if run >= MIN_TOKEN_RUN {
            out.push_str("[REDACTED]");
        } else {
            out.push_str(&after[..run]);
        }
        rest = &after[run..];
    }
    out.push_str(rest);
    out
}

/// Truncate `s` to at most `max` bytes on a char boundary, marking the
/// cut. Stderr from an arbitrary wrapper can be megabytes of stack trace;
/// the error message keeps enough to diagnose and drops the rest.
fn truncate_detail(s: &str, max: usize) -> String {
    const MARKER: &str = "... (truncated)";
    if s.len() <= max {
        return s.to_string();
    }
    let mut end = max.saturating_sub(MARKER.len());
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}{MARKER}", &s[..end])
}

/// Parse and validate the wrapper's stdout as a [`ScoreResponse`].
///
/// A blank stdout or a body that does not match the
/// `{verdict, score, reasoning, cost_usd}` envelope fails the test with a
/// [`ScorerError::MalformedVerdict`] rather than a panic, so a buggy
/// wrapper produces a clear diagnostic in every reporter. The claimed
/// `cost_usd` is sanitized here because the wrapper is untrusted.
fn parse_verdict(stdout: &str) -> Result<ScoreResponse, ScorerError> {
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        return Err(ScorerError::MalformedVerdict(
            "scorer wrote nothing to stdout".into(),
        ));
    }
    let mut resp: ScoreResponse = serde_json::from_str(trimmed).map_err(|err| {
        ScorerError::MalformedVerdict(format!("scorer stdout was not a valid verdict: {err}"))
    })?;
    resp.cost_usd = sanitize_cost(resp.cost_usd);
    Ok(resp)
}

/// Clamp a wrapper-claimed cost into `[0, COST_USD_CEILING]`.
///
/// The wrapper is an arbitrary process; its `cost_usd` is an unvalidated
/// claim, not a measurement. A negative or non-finite claim is dropped
/// (with a warning) rather than poisoning the `--max-cost` budget; an
/// implausibly large claim clamps to the ceiling.
fn sanitize_cost(cost: Option<f64>) -> Option<f64> {
    let claimed = cost?;
    if !claimed.is_finite() || claimed < 0.0 {
        tracing::warn!(
            cost_usd = claimed,
            "scorer reported a negative or non-finite cost_usd; ignoring it"
        );
        return None;
    }
    if claimed > COST_USD_CEILING {
        tracing::warn!(
            cost_usd = claimed,
            "scorer reported cost_usd above {COST_USD_CEILING}; clamping"
        );
        return Some(COST_USD_CEILING);
    }
    Some(claimed)
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_scorer_core::Verdict;
    use std::collections::BTreeMap;

    fn config(command: Vec<String>) -> ExecScorerConfig {
        ExecScorerConfig {
            name: "test-judge".into(),
            command,
            env: BTreeMap::new(),
            cwd: None,
            timeout_ms: 5_000,
        }
    }

    #[test]
    fn parse_verdict_accepts_full_envelope() {
        let wire = r#"{"verdict":"pass","score":0.9,"reasoning":"good","cost_usd":0.01}"#;
        let resp = parse_verdict(wire).unwrap();
        assert_eq!(resp.verdict, Verdict::Pass);
        assert_eq!(resp.score, Some(0.9));
        assert_eq!(resp.cost_usd, Some(0.01));
    }

    #[test]
    fn parse_verdict_accepts_null_cost() {
        let wire = r#"{"verdict":"fail","cost_usd":null}"#;
        let resp = parse_verdict(wire).unwrap();
        assert_eq!(resp.verdict, Verdict::Fail);
        assert_eq!(resp.cost_usd, None);
    }

    #[test]
    fn parse_verdict_rejects_empty_stdout() {
        let err = parse_verdict("   \n").unwrap_err();
        assert!(matches!(err, ScorerError::MalformedVerdict(_)));
    }

    #[test]
    fn parse_verdict_rejects_malformed_json() {
        let err = parse_verdict("{not json").unwrap_err();
        assert!(matches!(err, ScorerError::MalformedVerdict(_)));
    }

    #[test]
    fn negative_cost_usd_becomes_none() {
        let wire = r#"{"verdict":"pass","cost_usd":-0.5}"#;
        let resp = parse_verdict(wire).unwrap();
        assert_eq!(resp.cost_usd, None);
    }

    #[test]
    fn implausibly_large_cost_usd_clamps_to_ceiling() {
        let wire = r#"{"verdict":"pass","cost_usd":5000.0}"#;
        let resp = parse_verdict(wire).unwrap();
        assert_eq!(resp.cost_usd, Some(COST_USD_CEILING));
    }

    #[test]
    fn non_finite_cost_claim_is_dropped() {
        assert_eq!(sanitize_cost(Some(f64::NAN)), None);
        assert_eq!(sanitize_cost(Some(f64::INFINITY)), None);
        assert_eq!(sanitize_cost(Some(0.25)), Some(0.25));
        assert_eq!(sanitize_cost(None), None);
    }

    #[test]
    fn scrub_masks_anthropic_key_but_keeps_context() {
        let dirty = "auth failed: sk-ant-FAKEKEY123456 was rejected";
        let clean = scrub_secrets(dirty);
        assert!(!clean.contains("sk-ant-FAKEKEY123456"), "clean={clean}");
        assert!(clean.contains("auth failed"), "clean={clean}");
        assert!(clean.contains("was rejected"), "clean={clean}");
    }

    #[test]
    fn scrub_masks_github_and_aws_tokens() {
        let dirty = "push denied for ghp_abcdef1234567890 using AKIAIOSFODNN7EXAMPLE";
        let clean = scrub_secrets(dirty);
        assert!(!clean.contains("ghp_abcdef1234567890"), "clean={clean}");
        assert!(!clean.contains("AKIAIOSFODNN7EXAMPLE"), "clean={clean}");
        assert!(clean.contains("push denied"), "clean={clean}");
    }

    #[test]
    fn scrub_masks_bearer_token_after_scheme() {
        let dirty = "401: Bearer abcDEF0123456789xyz+/= rejected";
        let clean = scrub_secrets(dirty);
        assert!(!clean.contains("abcDEF0123456789xyz"), "clean={clean}");
        assert!(clean.contains("Bearer [REDACTED]"), "clean={clean}");
        assert!(clean.contains("rejected"), "clean={clean}");
    }

    #[test]
    fn scrub_leaves_short_prefix_words_alone() {
        let benign = "the AKIA docs mention Bearer auth and ghp_ id prefixes";
        assert_eq!(scrub_secrets(benign), benign);
    }

    #[test]
    fn truncate_detail_keeps_short_strings_intact() {
        assert_eq!(truncate_detail("boom", 2048), "boom");
    }

    #[test]
    fn truncate_detail_cuts_on_a_char_boundary() {
        // 2-byte chars force the cut position off a boundary.
        let long = "\u{e9}".repeat(2000);
        let out = truncate_detail(&long, 2048);
        assert!(out.len() <= 2048, "len={}", out.len());
        assert!(out.ends_with("... (truncated)"), "out tail wrong");
    }

    #[tokio::test]
    async fn scores_via_a_real_passthrough_process() {
        // `cat` echoes nothing useful; use a shell that prints a fixed
        // verdict regardless of stdin. The contract only requires the
        // wrapper to write one verdict document to stdout.
        let scorer = ExecScorer::new(config(vec![
            "sh".into(),
            "-c".into(),
            r#"cat >/dev/null; printf '{"verdict":"pass","score":1.0,"reasoning":"ok","cost_usd":0.0}'"#.into(),
        ]));
        let resp = scorer
            .score(ScoreRequest::new("q", "a", "rubric"))
            .await
            .unwrap();
        assert_eq!(resp.verdict, Verdict::Pass);
        assert_eq!(resp.score, Some(1.0));
    }

    #[tokio::test]
    async fn non_zero_exit_is_a_backend_error_with_stderr() {
        let scorer = ExecScorer::new(config(vec![
            "sh".into(),
            "-c".into(),
            r#"echo "boom" >&2; exit 3"#.into(),
        ]));
        let err = scorer
            .score(ScoreRequest::new("q", "a", "r"))
            .await
            .unwrap_err();
        match err {
            ScorerError::Backend(msg) => {
                assert!(msg.contains("test-judge"), "msg={msg}");
                assert!(msg.contains("boom"), "msg={msg}");
            }
            other => panic!("expected Backend error, got {other}"),
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn stderr_secret_does_not_reach_the_error_message() {
        let scorer = ExecScorer::new(config(vec![
            "sh".into(),
            "-c".into(),
            r#"cat >/dev/null; echo "auth failed: sk-ant-FAKEKEY123456" >&2; exit 2"#.into(),
        ]));
        let err = scorer
            .score(ScoreRequest::new("q", "a", "r"))
            .await
            .unwrap_err();
        match err {
            ScorerError::Backend(msg) => {
                assert!(!msg.contains("sk-ant-FAKEKEY123456"), "msg={msg}");
                assert!(msg.contains("auth failed"), "msg={msg}");
            }
            other => panic!("expected Backend error, got {other}"),
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn long_stderr_is_truncated_in_the_error_message() {
        // ~10 KB of stderr; the embedded detail must stay under the cap.
        let scorer = ExecScorer::new(config(vec![
            "sh".into(),
            "-c".into(),
            r#"cat >/dev/null; i=0; while [ $i -lt 1000 ]; do echo "stderr spam"; i=$((i+1)); done >&2; exit 1"#.into(),
        ]));
        let err = scorer
            .score(ScoreRequest::new("q", "a", "r"))
            .await
            .unwrap_err();
        match err {
            ScorerError::Backend(msg) => {
                assert!(msg.len() < 2300, "msg len={}", msg.len());
                assert!(msg.contains("... (truncated)"), "msg={msg}");
            }
            other => panic!("expected Backend error, got {other}"),
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn oversized_stdout_is_killed_with_a_cap_message() {
        // 6 MiB of stdout: the read must stop at the cap, kill the child,
        // and fail with the cap diagnostic instead of buffering or hanging.
        let scorer = ExecScorer::new(config(vec![
            "sh".into(),
            "-c".into(),
            "cat >/dev/null; head -c 6291456 /dev/zero".into(),
        ]));
        let err = scorer
            .score(ScoreRequest::new("q", "a", "r"))
            .await
            .unwrap_err();
        match err {
            ScorerError::Backend(msg) => {
                assert!(msg.contains("more than 4 MiB"), "msg={msg}");
                assert!(msg.contains("test-judge"), "msg={msg}");
            }
            other => panic!("expected Backend error, got {other}"),
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn exec_failure_is_not_retried_by_the_registry() {
        use mcptest_scorer_core::{RateLimitPolicy, ScorerRegistry};
        use std::sync::Arc;
        // The wrapper appends a line to a counter file per invocation and
        // fails. Backend errors are retryable in general, but the exec
        // scorer declares itself non-retryable, so exactly one line lands.
        let dir = tempfile::tempdir().unwrap();
        let counter = dir.path().join("count");
        let script = format!("cat >/dev/null; echo x >> {}; exit 1", counter.display());
        let mut registry = ScorerRegistry::new().with_retry_policy(RateLimitPolicy {
            max_retries: 3,
            base_delay: Duration::from_millis(1),
            total_budget: Duration::from_secs(5),
        });
        registry.register(Arc::new(ExecScorer::new(config(vec![
            "sh".into(),
            "-c".into(),
            script,
        ]))));
        let err = registry
            .score("exec", ScoreRequest::new("q", "a", "r"))
            .await
            .unwrap_err();
        assert!(matches!(err, ScorerError::Backend(_)));
        let invocations = std::fs::read_to_string(&counter).unwrap().lines().count();
        assert_eq!(invocations, 1);
    }

    #[tokio::test]
    async fn malformed_stdout_fails_with_a_clear_diagnostic() {
        let scorer = ExecScorer::new(config(vec![
            "sh".into(),
            "-c".into(),
            "printf 'not a verdict'".into(),
        ]));
        let err = scorer
            .score(ScoreRequest::new("q", "a", "r"))
            .await
            .unwrap_err();
        assert!(matches!(err, ScorerError::MalformedVerdict(_)));
    }

    #[tokio::test]
    async fn slow_process_times_out() {
        let mut cfg = config(vec!["sh".into(), "-c".into(), "sleep 5".into()]);
        cfg.timeout_ms = 50;
        let scorer = ExecScorer::new(cfg);
        let err = scorer
            .score(ScoreRequest::new("q", "a", "r"))
            .await
            .unwrap_err();
        assert!(matches!(err, ScorerError::Timeout(_)));
    }

    #[tokio::test]
    async fn missing_binary_is_a_transport_error() {
        let scorer = ExecScorer::new(config(vec!["this-binary-does-not-exist-mcptest".into()]));
        let err = scorer
            .score(ScoreRequest::new("q", "a", "r"))
            .await
            .unwrap_err();
        assert!(matches!(err, ScorerError::Transport(_)));
    }

    #[tokio::test]
    async fn wrapper_receives_the_request_on_stdin() {
        // The wrapper reads stdin, extracts the output field, and passes
        // only when it equals "expected". Proves the request reaches the
        // process intact.
        let scorer = ExecScorer::new(config(vec![
            "sh".into(),
            "-c".into(),
            r#"in=$(cat); case "$in" in *'"output":"expected"'*) printf '{"verdict":"pass"}';; *) printf '{"verdict":"fail"}';; esac"#.into(),
        ]));
        let resp = scorer
            .score(ScoreRequest::new("q", "expected", "r"))
            .await
            .unwrap();
        assert_eq!(resp.verdict, Verdict::Pass);
    }
}
