//! Dispatch helpers used by the executor's tool-test branch.
//!
//! Pulled out of `executor/mod.rs` so the parent file stays under the
//! production-module-size cap. Each helper is pure or async-pure: it
//! takes a borrowed input plus a transform context and either returns
//! the wrapped `{"result": ...}` envelope the matchers see, or the
//! synthesized `{"result": {"jsonrpc":"2.0","error":{...}}}` envelope
//! the fixture-injection path uses in place of a live call.

use std::time::Duration;

use serde_json::{json, Value};

use crate::eval::metamorphic::{
    transform_args, MetamorphicGate, MetamorphicReport, MetamorphicSummary,
};
use crate::fixtures::NamedError;
use crate::suite::{Action, Assertion, MetamorphicSpec, TransformSpec};
use crate::transform::{run_transform_step, TransformContext, TransformPhase};

use super::{eval_metric_assertion, ToolPlan};

/// Build a [`TransformContext`] for an agent test.
///
/// An agent test has no single server or tool, so only the test name
/// populates; a per-assertion transform still sees the phase marker
/// and test name via the `MCPTEST_*` env vars.
pub(super) fn agent_transform_context(test_name: &str) -> TransformContext {
    TransformContext {
        test_name: test_name.to_string(),
        ..TransformContext::default()
    }
}

/// Build the [`TransformContext`] for a tool plan.
///
/// Carries the test name, server, and tool so a transform subprocess
/// sees them via `MCPTEST_*` env vars. The resolved-var map is empty:
/// the typed suite the executor runs over does not carry it (it is
/// baked into the YAML upstream), so today only the four fixed fields
/// populate.
pub(super) fn transform_context(test_name: &str, tool: &ToolPlan) -> TransformContext {
    let tool_name = match &tool.action {
        Action::ToolCall { tool, .. } => tool.clone(),
        Action::ResourceRead { uri } => uri.clone(),
        Action::PromptGet { name, .. } => name.clone(),
    };
    TransformContext {
        test_name: test_name.to_string(),
        server: tool.server.clone(),
        tool: tool_name,
        vars: std::collections::BTreeMap::new(),
    }
}

/// Run the response transform on the `{"result": ...}` envelope, if
/// the plan declares one. The transform replaces the value the
/// matchers see; a transform error fails the test with a clear
/// message.
pub(super) async fn apply_response_transform(
    response: Value,
    transform: Option<&TransformSpec>,
    ctx: &TransformContext,
) -> Result<Value, String> {
    let Some(step) = transform.and_then(|t| t.response.as_ref()) else {
        return Ok(response);
    };
    run_transform_step(TransformPhase::Response, step, &response, ctx)
        .await
        .map_err(|e| e.to_string())
}

/// Dispatch one action against the protocol client and return the
/// wrapped `{"result": ...}` envelope so dotted assertion paths
/// rooted at `result.` resolve directly, plus the JSON-RPC request id
/// the call was sent under. The id keys the transport's response header
/// capture (WOR-1287); callers that do not consume it drop it.
///
/// When `transform` declares a `request` phase, it runs on the
/// built request params after they are materialized and before
/// the dispatch, replacing the params with the transform output.
/// A transform error short-circuits to a failed test.
/// `expects_error` is set when the test's assertions target `result.error`,
/// which tells the dispatch to route a live JSON-RPC error response into the
/// same assertable envelope the offline `inject_error` path produces instead
/// of failing the test hard. An unexpected error (no error assertion) still
/// fails loudly with the server's message, preserving the safety net.
/// What the executor needs to satisfy an `InputRequiredResult`
/// elicitation: the resolved answer source plus the test/server labels
/// for the REST payload and the trace (WOR-1383).
pub(super) struct ElicitSetup<'a> {
    /// The resolved answer source (static map or REST endpoint).
    pub responder: &'a crate::executor::elicitation::Responder,
    /// Test name, for the trace and the REST request body.
    pub test_name: &'a str,
    /// Server name, for the trace and the REST request body.
    pub server: &'a str,
}

pub(super) async fn call_server(
    client: &crate::protocol::Client,
    action: &Action,
    transform: Option<&TransformSpec>,
    ctx: &TransformContext,
    expects_error: bool,
    elicit: Option<&ElicitSetup<'_>>,
) -> Result<(Value, i64), String> {
    let (method, mut params, label) = match action {
        Action::ToolCall { tool, args } => (
            "tools/call",
            json!({ "name": tool, "arguments": args }),
            tool.as_str(),
        ),
        Action::ResourceRead { uri } => ("resources/read", json!({ "uri": uri }), uri.as_str()),
        Action::PromptGet { name, args } => {
            let mut params = json!({ "name": name });
            if let Some(args) = args {
                params["arguments"] = args.clone();
            }
            ("prompts/get", params, name.as_str())
        }
    };
    // Rewrite the outbound params before they are sent. Only the
    // tool-call request transform is supported; resource and prompt
    // tests never carry a transform, so this is a no-op for them.
    if let Some(step) = transform.and_then(|t| t.request.as_ref()) {
        params = run_transform_step(TransformPhase::Request, step, &params, ctx)
            .await
            .map_err(|e| e.to_string())?;
    }
    tracing::debug!(
        target: "mcptest_core::executor",
        method,
        target = %label,
        "dispatching action"
    );
    // Keep the transformed params as the base for elicitation retries (only
    // when a responder is configured, to avoid a clone on the common path).
    let retry_base = elicit.map(|_| params.clone());
    match client.request_with_id(method.to_string(), params).await {
        Ok((id, raw)) => {
            tracing::trace!(
                target: "mcptest_core::executor",
                method,
                target = %label,
                response = %raw,
                "received response"
            );
            // A 2026-07-28 Tasks server can answer a tools/call with a task
            // handle instead of the result; poll tasks/get to completion so a
            // plain tool test sees the terminal result (WOR-1383). Only a
            // tools/call can become a task; resource/prompt reads pass through.
            let resolved = if matches!(action, Action::ToolCall { .. }) {
                resolve_task_handle(client, raw, label).await?
            } else {
                raw
            };
            // A tools/call can also answer with an InputRequiredResult; when a
            // responder is configured, satisfy it and retry until a final
            // result, returning the final request id for header assertions
            // (WOR-1383). `params` is the already-transformed base.
            if let (Action::ToolCall { .. }, Some(setup)) = (action, elicit) {
                let base = retry_base.expect("retry_base is cloned whenever elicit is set");
                return resolve_input_required(
                    client,
                    json!({ "result": resolved }),
                    id,
                    base,
                    setup,
                    label,
                )
                .await;
            }
            Ok((json!({ "result": resolved }), id))
        }
        // A live JSON-RPC error becomes an assertable `result.error` envelope
        // only when the test asked for one. The id is 0: no successful wire
        // response carried headers to claim, matching the inject path which
        // omits the id entirely.
        Err(crate::protocol::ProtocolError::ServerError {
            code,
            message,
            data,
        }) if expects_error => Ok((live_error_envelope(code, &message, data.as_ref()), 0)),
        Err(e) => Err(format!("{method} failed: {e}")),
    }
}

/// Poll a 2026-07-28 Tasks `CreateTaskResult` to a terminal state
/// (SEP-2663).
///
/// When a `tools/call` result is a task handle, the runner drives
/// `tasks/get` until the task completes and returns the terminal result,
/// so a tool test sees what the call would have returned synchronously. A
/// result that is not a handle passes straight through. The poll count is
/// bounded so a stuck task fails the test instead of hanging the run.
async fn resolve_task_handle(
    client: &crate::protocol::Client,
    raw: Value,
    label: &str,
) -> Result<Value, String> {
    use crate::protocol::{parse_task_get, recognize_task_handle};

    let task = match recognize_task_handle(&json!({ "result": &raw })) {
        Some(task) => task,
        None => return Ok(raw),
    };
    const MAX_POLLS: u32 = 1000;
    const DEFAULT_POLL_MS: u64 = 100;
    const MAX_POLL_MS: u64 = 2000;
    let task_id = task.task_id;
    let mut poll_ms = task
        .poll_interval_ms
        .unwrap_or(DEFAULT_POLL_MS)
        .min(MAX_POLL_MS);
    for _ in 0..MAX_POLLS {
        tokio::time::sleep(Duration::from_millis(poll_ms)).await;
        let raw = client
            .request("tasks/get", json!({ "taskId": &task_id }))
            .await
            .map_err(|e| format!("tasks/get for `{label}` failed: {e}"))?;
        let state = parse_task_get(&json!({ "result": raw }))
            .ok_or_else(|| format!("tasks/get for `{label}` returned an unparseable task state"))?;
        // The poll interval may change over the task's lifetime.
        if let Some(ms) = state.task.poll_interval_ms {
            poll_ms = ms.min(MAX_POLL_MS);
        }
        match task_poll_outcome(&state, &task_id, label) {
            PollOutcome::Continue => continue,
            PollOutcome::Done(result) => return Ok(result),
            PollOutcome::Failed(message) => return Err(message),
        }
    }
    Err(format!(
        "task `{task_id}` for `{label}` did not reach a terminal state after {MAX_POLLS} polls"
    ))
}

/// Drive the `InputRequiredResult` retry loop for a tools/call (SEP-2322,
/// WOR-1383).
///
/// On entry `envelope` is `{"result": <first result>}`. While it is an
/// input-required result, the runner resolves answers from `setup`,
/// retries with `build_retry_params` (off the transformed `base_params`),
/// polls any task handle each round, and loops until a final result or
/// the round cap. Returns the final `{"result": ...}` envelope and the
/// final request id (so response-header assertions inspect the final
/// response). Every round emits a redacted `mcptest_core::elicitation`
/// trace event: ids and counts, never `requestState` or answer values.
async fn resolve_input_required(
    client: &crate::protocol::Client,
    initial_envelope: Value,
    initial_id: i64,
    base_params: Value,
    setup: &ElicitSetup<'_>,
    label: &str,
) -> Result<(Value, i64), String> {
    use crate::executor::elicitation::{ElicitCallContext, DEFAULT_MAX_ELICITATION_ROUNDS};
    use crate::protocol::elicitation::{build_retry_params, recognize_input_required};

    let mut envelope = initial_envelope;
    let mut params = base_params;
    let mut last_id = initial_id;
    for round in 0..DEFAULT_MAX_ELICITATION_ROUNDS {
        let Some(irr) = recognize_input_required(&envelope) else {
            if round > 0 {
                tracing::info!(
                    target: "mcptest_core::elicitation",
                    event = "elicitation.completed",
                    test = setup.test_name, server = setup.server, tool = label,
                    rounds = round, request_id = last_id,
                    "elicitation resolved",
                );
            }
            return Ok((envelope, last_id));
        };
        let request_ids: Vec<&str> = irr.input_requests.iter().map(|r| r.id.as_str()).collect();
        let required_count = irr.input_requests.iter().filter(|r| r.required).count();
        tracing::info!(
            target: "mcptest_core::elicitation",
            event = "elicitation.round_started",
            test = setup.test_name, server = setup.server, tool = label,
            round = round + 1, responder_kind = setup.responder.kind(),
            input_request_ids = ?request_ids, required_count,
            optional_count = irr.input_requests.len() - required_count,
            request_state_hash = %short_hash(irr.request_state.as_str()),
            "elicitation round started",
        );
        let ctx = ElicitCallContext {
            test_name: setup.test_name.to_string(),
            server: setup.server.to_string(),
            tool: label.to_string(),
            arguments: params
                .get("arguments")
                .cloned()
                .unwrap_or_else(|| json!({})),
            request_state: irr.request_state.clone(),
            round: round + 1,
        };
        let started = std::time::Instant::now();
        let answers = match setup.responder.answer(&irr.input_requests, &ctx).await {
            Ok(answers) => answers,
            Err(err) => {
                tracing::warn!(
                    target: "mcptest_core::elicitation",
                    event = "elicitation.failed",
                    test = setup.test_name, server = setup.server, tool = label,
                    round = round + 1, error_kind = elicitation_error_kind(&err),
                    "elicitation could not be answered",
                );
                return Err(format!("tools/call `{label}` elicitation: {err}"));
            }
        };
        tracing::debug!(
            target: "mcptest_core::elicitation",
            event = "elicitation.answer_resolved",
            test = setup.test_name, server = setup.server, tool = label,
            round = round + 1, answered = answers.len(),
            duration_ms = started.elapsed().as_millis() as u64,
            "answers resolved",
        );
        let retry = build_retry_params(&params, &irr.request_state, &answers);
        let (id, raw) = client
            .request_with_id("tools/call".to_string(), retry.clone())
            .await
            .map_err(|e| format!("tools/call retry for `{label}` failed: {e}"))?;
        last_id = id;
        let resolved = resolve_task_handle(client, raw, label).await?;
        envelope = json!({ "result": resolved });
        params = retry;
        tracing::debug!(
            target: "mcptest_core::elicitation",
            event = "elicitation.retry_dispatched",
            test = setup.test_name, server = setup.server, tool = label,
            round = round + 1, request_id = last_id,
            "retry dispatched",
        );
    }
    if recognize_input_required(&envelope).is_some() {
        tracing::warn!(
            target: "mcptest_core::elicitation",
            event = "elicitation.failed",
            test = setup.test_name, server = setup.server, tool = label,
            error_kind = "max_rounds",
            "server kept eliciting past the round cap",
        );
        return Err(format!(
            "tools/call `{label}` elicitation: server kept eliciting after {DEFAULT_MAX_ELICITATION_ROUNDS} rounds; aborting"
        ));
    }
    Ok((envelope, last_id))
}

/// Short, run-stable hash of an opaque `requestState`, so a trace can
/// correlate the rounds of one elicitation without logging the token.
fn short_hash(value: &str) -> String {
    use std::hash::{Hash, Hasher};
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    value.hash(&mut hasher);
    format!("{:08x}", hasher.finish() & 0xffff_ffff)
}

/// One-word `error_kind` for the failure trace, never the message body.
fn elicitation_error_kind(error: &crate::executor::elicitation::ElicitationError) -> &'static str {
    use crate::executor::elicitation::ElicitationError;
    match error {
        ElicitationError::MissingResponse { .. } => "missing_response",
        ElicitationError::MaxRoundsExceeded { .. } => "max_rounds",
        ElicitationError::NoFixture => "no_fixture",
        ElicitationError::WrongKind { .. } => "wrong_kind",
        ElicitationError::Rest(_) => "rest",
    }
}

/// What a single `tasks/get` poll tells the runner to do next.
enum PollOutcome {
    /// Non-terminal: poll again.
    Continue,
    /// Terminal `completed`: use this result as the call's result.
    Done(Value),
    /// Terminal failure (failed / cancelled / unsupported input-required):
    /// fail the test with this message.
    Failed(String),
}

/// Map one parsed `tasks/get` state to the runner's next action. Pure so
/// every terminal status is unit-testable without a live client.
fn task_poll_outcome(
    state: &crate::protocol::TaskGetResult,
    task_id: &str,
    label: &str,
) -> PollOutcome {
    use crate::protocol::TaskStatus;
    match state.task.status {
        TaskStatus::Working => PollOutcome::Continue,
        TaskStatus::Completed => {
            PollOutcome::Done(state.result.clone().unwrap_or_else(|| json!({})))
        }
        TaskStatus::Failed => {
            let detail = state.error.clone().unwrap_or(Value::Null);
            PollOutcome::Failed(format!("task `{task_id}` for `{label}` failed: {detail}"))
        }
        TaskStatus::Cancelled => {
            PollOutcome::Failed(format!("task `{task_id}` for `{label}` was cancelled"))
        }
        TaskStatus::InputRequired => PollOutcome::Failed(format!(
            "task `{task_id}` for `{label}` needs input via tasks/update, \
             which the runner does not yet supply"
        )),
    }
}

/// Build the `{"result":{"jsonrpc":"2.0","error":{...}}}` envelope for a live
/// JSON-RPC error, mirroring [`synthesize_error_envelope`] so the same
/// `result.error.code` / `result.error.message` assertions resolve whether the
/// error was injected offline or returned by a real server. The optional
/// `data` payload is carried through when the server supplied one.
fn live_error_envelope(code: i64, message: &str, data: Option<&Value>) -> Value {
    let mut error = json!({ "code": code, "message": message });
    if let Some(data) = data {
        error["data"] = data.clone();
    }
    json!({ "result": { "jsonrpc": "2.0", "error": error } })
}

/// Whether a test's assertions expect a JSON-RPC error response.
///
/// True when any assertion targets the `result.error` subtree (or the bare
/// `error` prefix), which is the signal [`call_server`] uses to route a live
/// error into an assertable envelope rather than failing the test.
pub(super) fn assertions_expect_error(expectations: &[Assertion]) -> bool {
    expectations.iter().any(|a| {
        let t = a.target.as_str();
        t == "result.error"
            || t.starts_with("result.error.")
            || t == "error"
            || t.starts_with("error.")
    })
}

/// Run the metamorphic relations declared on a tool test (WOR-1237).
///
/// Issues one base call plus one transformed call per relation against the live
/// server (no transform, so every call is apples to apples), folds the result
/// pairs into a [`MetamorphicReport`], and gates on it. Returns `Ok(())` when
/// the gate passes and a rendered failure message otherwise. A dispatch error
/// or a relation whose argument is missing or the wrong type surfaces as a
/// failure rather than a silent pass.
pub(super) async fn run_metamorphic(
    client: &crate::protocol::Client,
    tool: &str,
    base_args: &Value,
    spec: &MetamorphicSpec,
    ctx: &TransformContext,
) -> Result<(), String> {
    let base_action = Action::ToolCall {
        tool: tool.to_string(),
        args: base_args.clone(),
    };
    // Metamorphic follow-up calls do not participate in elicitation (v1):
    // the primary call already resolved any InputRequiredResult (WOR-1383).
    let (base_envelope, _id) = call_server(client, &base_action, None, ctx, false, None).await?;
    let base_result = inner_result(base_envelope);
    let mut pairs = Vec::with_capacity(spec.relations.len());
    for relation in &spec.relations {
        let followup_args = transform_args(base_args, relation).map_err(|e| e.to_string())?;
        let action = Action::ToolCall {
            tool: tool.to_string(),
            args: followup_args,
        };
        let (followup_envelope, _id) = call_server(client, &action, None, ctx, false, None).await?;
        let followup_result = inner_result(followup_envelope);
        pairs.push((relation.clone(), base_result.clone(), followup_result));
    }
    evaluate_metamorphic_gate(&MetamorphicReport::build(&pairs), &spec.expect)
}

/// Unwrap the `{"result": ...}` envelope to the inner tool result the relations
/// compare. Falls back to the whole value if there is no `result` key.
fn inner_result(mut envelope: Value) -> Value {
    envelope
        .get_mut("result")
        .map(Value::take)
        .unwrap_or(envelope)
}

/// Decide pass or fail for a metamorphic report. With no `expect`, the default
/// gate fails on any relation violation. With an `expect` list, the
/// `metamorphic.*` targets are gated directly through the shared metric-assert
/// path, so the explicit assertions replace the default gate.
fn evaluate_metamorphic_gate(
    report: &MetamorphicReport,
    expect: &[Assertion],
) -> Result<(), String> {
    if expect.is_empty() {
        if report.violations() == 0 {
            return Ok(());
        }
        return Err(metamorphic_failure(report));
    }
    let summary = MetamorphicSummary::from_report(report, &MetamorphicGate);
    let metrics = json!({
        "metamorphic": {
            "relations_checked": summary.relations_checked,
            "violations": summary.violations,
            "gate_passed": if summary.gate_passed { 1 } else { 0 },
        }
    });
    for assertion in expect {
        eval_metric_assertion(&metrics, assertion).map_err(|m| {
            format!(
                "metamorphic assertion on `{}` failed: {m}",
                assertion.target
            )
        })?;
    }
    Ok(())
}

/// Render a default-gate failure naming every violated relation and its detail.
fn metamorphic_failure(report: &MetamorphicReport) -> String {
    let violated: Vec<String> = report
        .outcomes
        .iter()
        .filter(|o| !o.holds)
        .map(|o| format!("{} ({})", o.relation, o.detail))
        .collect();
    format!(
        "metamorphic gate failed: {} relation(s) violated: {}",
        violated.len(),
        violated.join("; ")
    )
}

/// Build the `{"result":{"jsonrpc":"2.0","error":{...}}}` envelope
/// the fixture-injection path feeds to the assertion pipeline.
///
/// Matches the shape a real MCP server returns for a JSON-RPC error
/// (a top-level `result` wrapper so dotted assertion paths rooted at
/// `result.` resolve, with the `jsonrpc`/`error` keys inside). The
/// `id` field is omitted: the executor never assigned one because no
/// wire request was dispatched, and assertions against an injected
/// fixture key on the error code, message, or downstream behavior.
pub(super) fn synthesize_error_envelope(err: &NamedError) -> Value {
    json!({
        "result": {
            "jsonrpc": "2.0",
            "error": {
                "code": err.code,
                "message": err.message,
            },
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fixtures::{ErrorScope, NamedError};

    #[test]
    fn synthesized_envelope_carries_code_and_message_under_result_error() {
        let err = NamedError {
            name: "rate_limited".into(),
            code: -32000,
            message: "rate limit exceeded".into(),
            scope: ErrorScope::Tool("create_issue".into()),
        };
        let env = synthesize_error_envelope(&err);
        assert_eq!(env["result"]["jsonrpc"], "2.0");
        assert_eq!(env["result"]["error"]["code"], -32000);
        assert_eq!(env["result"]["error"]["message"], "rate limit exceeded");
    }

    #[test]
    fn synthesized_envelope_omits_id_because_no_request_was_dispatched() {
        let err = NamedError {
            name: "auth_expired".into(),
            code: -32001,
            message: "token expired".into(),
            scope: ErrorScope::Any,
        };
        let env = synthesize_error_envelope(&err);
        assert!(env["result"].get("id").is_none());
    }

    #[test]
    fn live_error_envelope_matches_the_injected_shape() {
        // A live error must produce the same `result.error.*` paths the
        // offline inject path does, so one assertion form covers both.
        let env = live_error_envelope(-32602, "Invalid params", None);
        assert_eq!(env["result"]["jsonrpc"], "2.0");
        assert_eq!(env["result"]["error"]["code"].as_i64(), Some(-32602));
        assert_eq!(env["result"]["error"]["message"], "Invalid params");
        assert!(env["result"]["error"].get("data").is_none());
    }

    #[test]
    fn live_error_envelope_carries_data_when_present() {
        let data = json!({ "field": "city" });
        let env = live_error_envelope(-32000, "boom", Some(&data));
        assert_eq!(env["result"]["error"]["data"]["field"], "city");
    }

    #[test]
    fn assertions_expect_error_detects_result_error_targets() {
        let yes = vec![asrt("result.error.code", Matcher::Exact(json!(-32602)))];
        assert!(assertions_expect_error(&yes));
        let bare = vec![asrt("error.code", Matcher::Exact(json!(-32602)))];
        assert!(assertions_expect_error(&bare));
    }

    #[test]
    fn assertions_expect_error_ignores_unrelated_targets() {
        // `result.errors` (plural) and content targets must not be mistaken
        // for an error expectation, or a normal test would silently swallow
        // a real JSON-RPC error.
        let no = vec![
            asrt("result.content[0].text", Matcher::Contains(json!("hi"))),
            asrt("result.errors", Matcher::Exact(json!([]))),
        ];
        assert!(!assertions_expect_error(&no));
    }

    #[test]
    fn synthesized_envelope_preserves_negative_codes_as_signed_integers() {
        // JSON-RPC error codes are signed 32-bit integers, and the standard
        // application range (-32000 .. -32099) is always negative. A round
        // trip through serde_json must keep the sign so a `code: -32000`
        // assertion matches.
        let err = NamedError {
            name: "x".into(),
            code: -32099,
            message: "x".into(),
            scope: ErrorScope::Any,
        };
        let env = synthesize_error_envelope(&err);
        assert_eq!(env["result"]["error"]["code"].as_i64(), Some(-32099));
    }

    use crate::eval::metamorphic::Relation;
    use crate::suite::{Assertion, Matcher};

    fn content(items: Value) -> Value {
        json!({ "content": items })
    }

    fn asrt(target: &str, matcher: Matcher) -> Assertion {
        Assertion {
            target: target.into(),
            matcher,
            weight: 1.0,
            name: None,
            transform: None,
        }
    }

    #[test]
    fn inner_result_unwraps_the_result_envelope() {
        let env = json!({ "result": { "content": [1, 2] } });
        assert_eq!(inner_result(env), json!({ "content": [1, 2] }));
    }

    #[test]
    fn default_gate_passes_on_a_clean_report() {
        let report = MetamorphicReport::build(&[(
            Relation::Idempotent,
            content(json!([1])),
            content(json!([1])),
        )]);
        assert!(evaluate_metamorphic_gate(&report, &[]).is_ok());
    }

    #[test]
    fn default_gate_fails_and_names_the_violated_relation() {
        let report = MetamorphicReport::build(&[(
            Relation::ArgOrderInsensitive,
            content(json!([1])),
            content(json!([2])),
        )]);
        let err = evaluate_metamorphic_gate(&report, &[]).expect_err("violation fails the gate");
        assert!(err.contains("arg_order_insensitive"), "{err}");
    }

    #[test]
    fn explicit_expect_floor_passes_when_there_is_no_violation() {
        let report = MetamorphicReport::build(&[(
            Relation::Idempotent,
            content(json!([1])),
            content(json!([1])),
        )]);
        let expect = [asrt(
            "metamorphic.violations",
            Matcher::Schema(json!({ "maximum": 0 })),
        )];
        assert!(evaluate_metamorphic_gate(&report, &expect).is_ok());
    }

    #[test]
    fn explicit_expect_floor_fails_on_a_violation() {
        let report = MetamorphicReport::build(&[(
            Relation::Idempotent,
            content(json!([1])),
            content(json!([2])),
        )]);
        let expect = [asrt(
            "metamorphic.violations",
            Matcher::Schema(json!({ "maximum": 0 })),
        )];
        let err = evaluate_metamorphic_gate(&report, &expect).expect_err("floor breach fails");
        assert!(err.contains("metamorphic.violations"), "{err}");
    }

    fn task_state(status: crate::protocol::TaskStatus) -> crate::protocol::TaskGetResult {
        crate::protocol::TaskGetResult {
            task: crate::protocol::Task {
                task_id: "task-1".into(),
                status,
                status_message: None,
                created_at: None,
                last_updated_at: None,
                ttl_ms: None,
                poll_interval_ms: None,
            },
            result: None,
            error: None,
        }
    }

    #[test]
    fn poll_outcome_keeps_polling_while_working() {
        let state = task_state(crate::protocol::TaskStatus::Working);
        assert!(matches!(
            task_poll_outcome(&state, "task-1", "render"),
            PollOutcome::Continue
        ));
    }

    #[test]
    fn poll_outcome_returns_the_completed_result() {
        let mut state = task_state(crate::protocol::TaskStatus::Completed);
        state.result = Some(json!({ "content": [{ "type": "text", "text": "done" }] }));
        match task_poll_outcome(&state, "task-1", "render") {
            PollOutcome::Done(result) => assert_eq!(result["content"][0]["text"], "done"),
            _ => panic!("completed must yield the result"),
        }
    }

    #[test]
    fn poll_outcome_fails_on_a_failed_task_with_the_error_detail() {
        let mut state = task_state(crate::protocol::TaskStatus::Failed);
        state.error = Some(json!({ "code": -32000, "message": "boom" }));
        match task_poll_outcome(&state, "task-1", "render") {
            PollOutcome::Failed(message) => {
                assert!(message.contains("failed"), "{message}");
                assert!(message.contains("boom"), "{message}");
            }
            _ => panic!("failed task must fail the test"),
        }
    }

    #[test]
    fn poll_outcome_fails_on_a_cancelled_task() {
        let state = task_state(crate::protocol::TaskStatus::Cancelled);
        match task_poll_outcome(&state, "task-1", "render") {
            PollOutcome::Failed(message) => assert!(message.contains("cancelled"), "{message}"),
            _ => panic!("cancelled task must fail the test"),
        }
    }

    #[test]
    fn poll_outcome_fails_when_a_task_needs_input() {
        // The runner cannot satisfy a task that pauses for input via
        // tasks/update yet, so it fails loudly rather than looping forever.
        let state = task_state(crate::protocol::TaskStatus::InputRequired);
        match task_poll_outcome(&state, "task-1", "render") {
            PollOutcome::Failed(message) => assert!(message.contains("input"), "{message}"),
            _ => panic!("input-required task must fail the test"),
        }
    }
}
