//! Post-run agent telemetry and cross-run gates for the live runner.
//!
//! The live runner times the executor's assertion-eval of a precomputed agent
//! trace envelope, not the agent drive, so an agent row would otherwise report
//! no wall-clock and no cost. [`AgentRowMetric`] captures the run's real
//! duration and dollar cost at dispatch time and [`apply_agent_metrics`] folds
//! them onto the report after the run (WOR-1202). The within-session
//! stability gate and the objective tool-selection F1 gate evaluators live
//! here too: both reduce a cell's N run envelopes to a single PASS/FAIL row,
//! the same post-run-telemetry shape. Split out of `run_live_agents.rs` so
//! that module stays under the production-module-size guard.

/// Per-row agent telemetry the post-run pass folds onto the report
/// (WOR-1202).
pub(super) struct AgentRowMetric {
    /// Report row name this metric belongs to (matches the `TestSpec` name).
    pub(super) row_name: String,
    /// Wall-clock duration of the agent run in milliseconds, read from the
    /// trace's `conversation.duration_ms`.
    pub(super) duration_ms: u64,
    /// Dollar cost of the run, estimated from the trace's token totals via the
    /// provider's pricing table. `None` on the replay path and the stub
    /// fallback, where there is no real provider to price the tokens.
    pub(super) cost_usd: Option<f64>,
}

/// Estimate one agent run's dollar cost from its trace token totals
/// (WOR-1202). Only a real provider can price the tokens; the stub fallback
/// returns canned text and zero usage, so `real_provider == false` yields
/// `None` rather than a fabricated `$0`.
pub(super) fn agent_run_cost(
    real_provider: bool,
    provider: &dyn mcptest_core::eval::provider::LlmProvider,
    trace: &mcptest_agent::ConversationTrace,
    model_id: &str,
) -> Option<f64> {
    use mcptest_core::eval::provider::Usage;
    if !real_provider {
        return None;
    }
    let usage = Usage {
        input_tokens: trace.conversation.tokens.prompt,
        output_tokens: trace.conversation.tokens.completion,
        ..Usage::default()
    };
    Some(provider.estimate_cost(&usage, model_id).total())
}

/// Fold the captured agent telemetry onto the report (WOR-1202).
///
/// Patch each agent row's `duration_ms` from the captured run wall-clock, then
/// roll the per-run dollar cost (real-provider runs only) into the report's
/// cost block. A key-free or replay-only run carries no priced runs and leaves
/// the cost block absent, so the reporter does not print a `$0` line.
pub(super) fn apply_agent_metrics(
    report: &mut mcptest_core::report::Report,
    metrics: &[AgentRowMetric],
) {
    use mcptest_core::report::RunCost;
    if metrics.is_empty() {
        return;
    }
    // Patch durations by row name. An agent test contributes exactly one row
    // per run, so a name match is unambiguous.
    for metric in metrics {
        if let Some(row) = report.tests.iter_mut().find(|t| t.name == metric.row_name) {
            row.duration_ms = metric.duration_ms;
        }
    }
    let priced: Vec<f64> = metrics.iter().filter_map(|m| m.cost_usd).collect();
    if priced.is_empty() {
        return;
    }
    let added_total: f64 = priced.iter().sum();
    let added_calls = priced.len() as u64;
    // Merge with any cost already on the report (none on the run path today,
    // but additive so a future juror-cost wiring composes cleanly).
    let base = report.cost.unwrap_or(RunCost {
        total_cost_usd: 0.0,
        cost_per_juror_avg: 0.0,
        cost_per_test_avg: 0.0,
        juror_calls: 0,
        tests_with_cost: 0,
        cost_per_completed_task: None,
    });
    let total_cost_usd = base.total_cost_usd + added_total;
    let juror_calls = base.juror_calls + added_calls;
    let tests_with_cost = base.tests_with_cost + added_calls;
    // WOR-1393: cost per passing test, so the run-path scorecard reports the
    // same completed-task figure the eval path does. `None` when nothing passed.
    let passed = report.summary.passed as u64;
    let cost_per_completed_task = (passed > 0).then(|| total_cost_usd / passed as f64);
    report.cost = Some(RunCost {
        total_cost_usd,
        juror_calls,
        tests_with_cost,
        cost_per_juror_avg: if juror_calls == 0 {
            0.0
        } else {
            total_cost_usd / juror_calls as f64
        },
        cost_per_test_avg: if tests_with_cost == 0 {
            0.0
        } else {
            total_cost_usd / tests_with_cost as f64
        },
        cost_per_completed_task,
    });
}

/// Evaluate one agent test's `stability:` gate over its N run envelopes.
///
/// Aggregates the envelopes into the `stability.*` targets, applies the gate's
/// `expect:` (or the default `stability.weakest_score >= 0.5` when it declared
/// none), and folds the result into a [`mcptest_core::checks::CheckOutcome`] so
/// the post-run pass renders it as a single report row exactly like a
/// calibration or tool-quality check.
pub(super) fn eval_stability_gate(
    case_name: &str,
    gate: &mcptest_core::suite::StabilityGate,
    envelopes: &[serde_json::Value],
) -> mcptest_core::checks::CheckOutcome {
    use mcptest_core::eval::stability_aggregate::{
        agent_stability_across, default_expect, metrics_object,
    };
    let summary = agent_stability_across(envelopes);
    let metrics = metrics_object(&summary);
    let assertions = if gate.expect.is_empty() {
        default_expect()
    } else {
        gate.expect.clone()
    };
    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = mcptest_core::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "stability assertion #{i} (`{}`) failed: {reason}",
                    assertion.target
                ));
            }
        }
    }
    mcptest_core::checks::CheckOutcome {
        name: format!("{case_name} (stability)"),
        passed: failure.is_none(),
        failure,
    }
}

/// Evaluate one agent test's `equal_function_sets:` gate (MSC-Bench objective
/// tool-selection F1) over its N run envelopes.
///
/// Scores the per-run traces against the declared equal-function sets with no
/// model in the loop, micro-averages into the `tool_selection.*` targets,
/// applies the gate's `expect:` (or the default `tool_selection.f1 >= 50` when
/// it declared none), and folds the result into a
/// [`mcptest_core::checks::CheckOutcome`]. On failure the note carries the
/// per-class breakdown (missed classes, unexpected tools) so the floor failure
/// is actionable. Returns the aggregate too so the caller can print the
/// one-line summary. Mirrors [`eval_stability_gate`].
pub(super) fn eval_equal_function_gate(
    case_name: &str,
    gate: &mcptest_core::suite::EqualFunctionGate,
    envelopes: &[serde_json::Value],
) -> (
    mcptest_core::checks::CheckOutcome,
    mcptest_core::eval::tool_selection_f1::F1Aggregate,
) {
    use mcptest_core::eval::tool_selection_f1::{aggregate_f1, default_expect, metrics_object};
    let aggregate = aggregate_f1(&gate.classes, envelopes);
    let metrics = metrics_object(&aggregate);
    let assertions = if gate.expect.is_empty() {
        default_expect()
    } else {
        gate.expect.clone()
    };
    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = mcptest_core::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "tool-selection assertion #{i} (`{}`) failed: {reason}; \
                     missed classes: [{}]; unexpected tools: [{}]",
                    assertion.target,
                    aggregate.missed_classes().join(", "),
                    aggregate.unexpected_tools().join(", ")
                ));
            }
        }
    }
    let outcome = mcptest_core::checks::CheckOutcome {
        name: format!("{case_name} (tool-selection-f1)"),
        passed: failure.is_none(),
        failure,
    };
    (outcome, aggregate)
}

/// Evaluate one agent test's `token_efficiency:` gate (WOR-1366) over its N run
/// envelopes, the live tool-surface token count, and the run cost.
///
/// Scores the same equal-function selection F1 the `equal_function_sets:` gate
/// does (no model in the loop), then folds in the serialized tool-surface tokens
/// and the cell's dollar cost to expose the per-correct price of the catalog.
/// Applies the gate's `expect:` (or the default `token_efficiency.f1 >= 50` when
/// it declared none) over the `token_efficiency.*` targets. Returns the report
/// too so the caller can print the one-line summary. Mirrors
/// [`eval_equal_function_gate`].
pub(super) fn eval_token_efficiency_gate(
    case_name: &str,
    gate: &mcptest_core::suite::TokenEfficiencyGate,
    envelopes: &[serde_json::Value],
    tool_surface_tokens: u32,
    cost_usd: f64,
) -> (
    mcptest_core::checks::CheckOutcome,
    mcptest_core::eval::token_efficiency::TokenEfficiency,
) {
    use mcptest_core::eval::token_efficiency::{
        default_expect, TokenEfficiency, TokenEfficiencyInput,
    };
    use mcptest_core::eval::tool_selection_f1::aggregate_f1;
    let aggregate = aggregate_f1(&gate.classes, envelopes);
    // Correct selections (true positives) pooled across the runs, the
    // denominator for the per-correct figures.
    let correct_selections: u32 = aggregate.per_run.iter().map(|r| r.true_positives).sum();
    let report = TokenEfficiency::from_input(TokenEfficiencyInput {
        tool_surface_tokens,
        f1_percent: aggregate.f1_percent,
        correct_selections,
        cost_usd,
    });
    let metrics = report.metrics_object();
    let assertions = if gate.expect.is_empty() {
        default_expect()
    } else {
        gate.expect.clone()
    };
    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = mcptest_core::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "token-efficiency assertion #{i} (`{}`) failed: {reason}; {}",
                    assertion.target,
                    report.line()
                ));
            }
        }
    }
    let outcome = mcptest_core::checks::CheckOutcome {
        name: format!("{case_name} (token-efficiency)"),
        passed: failure.is_none(),
        failure,
    };
    (outcome, report)
}

/// Evaluate one agent test's `distractors:` gate (MCPAgentBench tool-overload
/// selection accuracy) over its N run envelopes.
///
/// Pools the chosen tool ids across every run, scores them against the gate's
/// correct set and the spec's resolved distractor ids with no model in the loop
/// (micro-averaging into the `distractors.*` targets), applies the gate's
/// `expect:` (or the default `distractors.accuracy >= 50` when it declared
/// none), and folds the result into a [`mcptest_core::checks::CheckOutcome`]. On
/// failure the note carries the observed accuracy and how many injected
/// distractors were chosen so the floor failure is actionable. Mirrors
/// [`eval_equal_function_gate`]; no model in the loop.
pub(super) fn eval_distractor_gate(
    case_name: &str,
    gate: &mcptest_core::suite::DistractorGate,
    envelopes: &[serde_json::Value],
) -> mcptest_core::checks::CheckOutcome {
    use mcptest_core::eval::distractors::{
        certified_accuracy_percent, default_expect, metrics_object, score_selection,
    };
    use mcptest_core::eval::tool_selection_f1::observed_calls;
    let distractor_ids = gate.spec.resolve_ids();
    // Score each run on its own so the per-run successes feed the certified
    // bound (a binomial over runs, WOR-1367); the pooled score keeps the
    // existing micro-averaged accuracy target.
    let per_run: Vec<_> = envelopes
        .iter()
        .map(|env| score_selection(&observed_calls(env), &gate.correct, &distractor_ids))
        .collect();
    let chosen: Vec<String> = envelopes.iter().flat_map(observed_calls).collect();
    let score = score_selection(&chosen, &gate.correct, &distractor_ids);
    let certified = certified_accuracy_percent(&per_run, 95);
    let mut metrics = metrics_object(&score);
    // WOR-1367: the Clopper-Pearson 95% lower bound on the per-run success rate,
    // the certified floor on selection accuracy under distraction. Assertable as
    // `distractors.certified_lower`, e.g. a suite can demand it stays above 20.
    metrics["distractors"]["certified_lower"] = serde_json::json!(certified);
    let assertions = if gate.expect.is_empty() {
        default_expect()
    } else {
        gate.expect.clone()
    };
    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = mcptest_core::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "distractor assertion #{i} (`{}`) failed: {reason}; \
                     accuracy {}%, certified_lower {certified}%, chose {} of {} injected distractor(s)",
                    assertion.target,
                    score.accuracy_percent,
                    score.chose_distractor,
                    distractor_ids.len()
                ));
            }
        }
    }
    mcptest_core::checks::CheckOutcome {
        name: format!("{case_name} (distractors)"),
        passed: failure.is_none(),
        failure,
    }
}

/// Evaluate one agent test's `tool_edges:` gate (structural coverage, WOR-1242)
/// over its N run envelopes.
///
/// Folds the run traces against the declared allowed, restricted, and
/// delegation edges into the `edges.*` targets, applies the gate's `expect:` (or
/// the default `edges.restricted_attempts <= 0` when it declared none), and
/// folds the result into a [`mcptest_core::checks::CheckOutcome`]. On failure
/// the note names the restricted tools that were hit. Mirrors
/// [`eval_stability_gate`]; no model in the loop.
pub(super) fn eval_tool_edges_gate(
    case_name: &str,
    gate: &mcptest_core::suite::ToolEdgesGate,
    envelopes: &[serde_json::Value],
) -> mcptest_core::checks::CheckOutcome {
    use mcptest_core::eval::tool_edges::{default_expect, edge_coverage, metrics_object};
    let summary = edge_coverage(&gate.set, envelopes);
    let metrics = metrics_object(&summary);
    let assertions = if gate.expect.is_empty() {
        default_expect()
    } else {
        gate.expect.clone()
    };
    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = mcptest_core::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "tool-edge assertion #{i} (`{}`) failed: {reason}; restricted tools hit: [{}]",
                    assertion.target,
                    summary.restricted_hit.join(", ")
                ));
            }
        }
    }
    mcptest_core::checks::CheckOutcome {
        name: format!("{case_name} (tool-edges)"),
        passed: failure.is_none(),
        failure,
    }
}

/// Evaluate one agent test's `golden_path:` gate (WOR-1380) over its run
/// envelopes.
///
/// Scores the last run's recorded tool_calls against the declared ideal path
/// for efficiency (extra steps, backtracks, repeated tools) with no model in
/// the loop, into the `golden_path.*` targets, applies the gate's `expect:` (or
/// the default `golden_path.passed >= 1`), and folds the result into a
/// [`mcptest_core::checks::CheckOutcome`]. Mirrors [`eval_distractor_gate`].
pub(super) fn eval_golden_path_gate(
    case_name: &str,
    gate: &mcptest_core::suite::GoldenPathGate,
    envelopes: &[serde_json::Value],
) -> mcptest_core::checks::CheckOutcome {
    use mcptest_core::eval::{score_golden_path, tool_calls_from_envelope};
    use mcptest_core::suite::{Assertion, Matcher};
    use serde_json::json;
    let Some(last) = envelopes.last() else {
        return mcptest_core::checks::CheckOutcome {
            name: format!("{case_name} (golden-path)"),
            passed: false,
            failure: Some("no run envelope to score".to_string()),
        };
    };
    let calls = tool_calls_from_envelope(last);
    let score = score_golden_path(&calls, &gate.path);
    let metrics = json!({
        "golden_path": {
            "penalty": score.penalty,
            "passed": if score.passed { 1 } else { 0 },
            "extra_steps": score.extra_steps,
            "backtracks": score.backtracks,
            "repeated_tools": score.repeated_tools,
        }
    });
    let assertions = if gate.expect.is_empty() {
        vec![Assertion {
            target: "golden_path.passed".to_string(),
            matcher: Matcher::Schema(json!({ "minimum": 1 })),
            weight: 1.0,
            name: None,
            transform: None,
        }]
    } else {
        gate.expect.clone()
    };
    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = mcptest_core::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "golden-path assertion #{i} (`{}`) failed: {reason}; \
                     penalty {:.3}, extra_steps {}, backtracks {}, repeated_tools {}",
                    assertion.target,
                    score.penalty,
                    score.extra_steps,
                    score.backtracks,
                    score.repeated_tools
                ));
            }
        }
    }
    mcptest_core::checks::CheckOutcome {
        name: format!("{case_name} (golden-path)"),
        passed: failure.is_none(),
        failure,
    }
}

/// Evaluate one agent test's `trajectory:` gate (WOR-1380) over its run
/// envelopes.
///
/// Validates the last run's recorded tool_calls against the declared expected
/// trace under the chosen match mode with no model in the loop, into the
/// `trajectory.*` targets, applies the gate's `expect:` (or the default
/// `trajectory.passed >= 1`), and folds the result into a
/// [`mcptest_core::checks::CheckOutcome`]. Mirrors [`eval_distractor_gate`].
pub(super) fn eval_trajectory_gate(
    case_name: &str,
    gate: &mcptest_core::suite::TrajectoryGate,
    envelopes: &[serde_json::Value],
) -> mcptest_core::checks::CheckOutcome {
    use mcptest_core::eval::{tool_calls_from_envelope, validate_trace};
    use mcptest_core::suite::{Assertion, Matcher};
    use serde_json::json;
    let Some(last) = envelopes.last() else {
        return mcptest_core::checks::CheckOutcome {
            name: format!("{case_name} (trajectory)"),
            passed: false,
            failure: Some("no run envelope to score".to_string()),
        };
    };
    let calls = tool_calls_from_envelope(last);
    let validation = match validate_trace(&calls, &gate.expected) {
        Ok(v) => v,
        Err(e) => {
            return mcptest_core::checks::CheckOutcome {
                name: format!("{case_name} (trajectory)"),
                passed: false,
                failure: Some(format!("trajectory validation error: {e}")),
            };
        }
    };
    let metrics = json!({
        "trajectory": {
            "passed": if validation.passed { 1 } else { 0 },
            "mismatch_count": validation.mismatches.len(),
        }
    });
    let assertions = if gate.expect.is_empty() {
        vec![Assertion {
            target: "trajectory.passed".to_string(),
            matcher: Matcher::Schema(json!({ "minimum": 1 })),
            weight: 1.0,
            name: None,
            transform: None,
        }]
    } else {
        gate.expect.clone()
    };
    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = mcptest_core::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                let notes: Vec<String> = validation
                    .mismatches
                    .iter()
                    .map(|m| m.note.clone())
                    .collect();
                failure = Some(format!(
                    "trajectory assertion #{i} (`{}`) failed: {reason}; mismatches: [{}]",
                    assertion.target,
                    notes.join("; ")
                ));
            }
        }
    }
    mcptest_core::checks::CheckOutcome {
        name: format!("{case_name} (trajectory)"),
        passed: failure.is_none(),
        failure,
    }
}

/// Evaluate one agent test's `trajectory_axes:` gate (WOR-1368) over its run
/// envelopes.
///
/// Scores the last run's recorded tool_calls against the declared dependency
/// and order constraints with no model in the loop, into the
/// `trajectory.dependency_satisfaction` and `trajectory.order_satisfaction`
/// targets, applies the gate's `expect:` (or the default requiring both axes at
/// 100), and folds the result into a [`mcptest_core::checks::CheckOutcome`].
/// Mirrors [`eval_trajectory_gate`].
pub(super) fn eval_trajectory_axes_gate(
    case_name: &str,
    gate: &mcptest_core::suite::TrajectoryAxesGate,
    envelopes: &[serde_json::Value],
) -> mcptest_core::checks::CheckOutcome {
    use mcptest_core::eval::{
        score_trajectory_axes, tool_calls_from_envelope, trajectory_axes_metrics,
    };
    use mcptest_core::suite::{Assertion, Matcher};
    use serde_json::json;
    let Some(last) = envelopes.last() else {
        return mcptest_core::checks::CheckOutcome {
            name: format!("{case_name} (trajectory-axes)"),
            passed: false,
            failure: Some("no run envelope to score".to_string()),
        };
    };
    let calls = tool_calls_from_envelope(last);
    let score = score_trajectory_axes(&calls, &gate.dependencies, &gate.order);
    let metrics = trajectory_axes_metrics(&score);
    let default_axis = |target: &str| Assertion {
        target: target.to_string(),
        matcher: Matcher::Schema(json!({ "minimum": 100 })),
        weight: 1.0,
        name: None,
        transform: None,
    };
    let assertions = if gate.expect.is_empty() {
        vec![
            default_axis("trajectory.dependency_satisfaction"),
            default_axis("trajectory.order_satisfaction"),
        ]
    } else {
        gate.expect.clone()
    };
    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = mcptest_core::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "trajectory-axes assertion #{i} (`{}`) failed: {reason}; \
                     dependency_satisfaction {}%, order_satisfaction {}%",
                    assertion.target, score.dependency_satisfaction, score.order_satisfaction
                ));
            }
        }
    }
    mcptest_core::checks::CheckOutcome {
        name: format!("{case_name} (trajectory-axes)"),
        passed: failure.is_none(),
        failure,
    }
}

/// Compute the live tool-surface token count (only when a `token_efficiency:`
/// gate asks for it, WOR-1366) and run every cross-run gate over the cell's
/// envelopes. The catalog listing is async, so this wraps the sync
/// [`record_cell_gates`]; it lives here, not in `run_live_agents`, to keep that
/// module under the size guard.
pub(super) async fn record_live_cell_gates(
    agent: &mcptest_core::suite::AgentTest,
    case_name: &str,
    quiet: bool,
    envelopes: &[serde_json::Value],
    handles: &[mcptest_agent::ServerHandle<'_>],
    acc: &mut super::run_live_agents::CellAcc<'_>,
) {
    let tool_surface_tokens = if agent.token_efficiency.is_some() {
        agent_tool_surface_tokens(handles).await
    } else {
        None
    };
    record_cell_gates(agent, case_name, quiet, envelopes, tool_surface_tokens, acc);
}

/// Token cost of the merged tool surface the model saw, for the
/// `token_efficiency:` gate (WOR-1366).
///
/// Lists the same merged catalog the driver advertises, then counts the tokens
/// the way the doctor catalog-token report does (name + description +
/// `inputSchema` per tool) with the `cl100k_base` BPE tokenizer. Returns `None`
/// when the catalog cannot be listed or the tokenizer fails to load, so the gate
/// simply does not fire rather than failing a run that wanted only other gates.
async fn agent_tool_surface_tokens(handles: &[mcptest_agent::ServerHandle<'_>]) -> Option<u32> {
    let tools = mcptest_agent::list_merged_tools(handles).await.ok()?;
    let tools_list = serde_json::json!({
        "tools": tools
            .iter()
            .map(|t| serde_json::json!({
                "name": t.name,
                "description": t.description,
                "inputSchema": t.input_schema,
            }))
            .collect::<Vec<_>>()
    });
    let tokenizer = mcptest_core::matchers::budgets::BpeTokenizer::cl100k_base().ok()?;
    // Thresholds maxed out: we want the catalog total, not the doctor warnings.
    let report =
        crate::doctor::compute_catalog_tokens(&tools_list, &tokenizer, usize::MAX, usize::MAX);
    Some(report.total.min(u32::MAX as usize) as u32)
}

/// Record the `token_efficiency:` gate (WOR-1366) when the cell declares one.
///
/// The gate needs the live tool-surface token count, so it only fires when
/// `tool_surface_tokens` is `Some` (a live run, not a cassette replay). The cell
/// cost is summed from the per-run metrics this cell just recorded. Split out of
/// [`record_cell_gates`] to keep that dispatcher under the module-size guard.
fn record_token_efficiency_gate(
    agent: &mcptest_core::suite::AgentTest,
    case_name: &str,
    quiet: bool,
    envelopes: &[serde_json::Value],
    tool_surface_tokens: Option<u32>,
    acc: &mut super::run_live_agents::CellAcc<'_>,
) {
    let Some(gate) = &agent.token_efficiency else {
        return;
    };
    let Some(tokens) = tool_surface_tokens else {
        return;
    };
    let cell_prefix = format!("{case_name} #");
    let cost_usd: f64 = acc
        .agent_metrics
        .iter()
        .filter(|m| m.row_name == case_name || m.row_name.starts_with(&cell_prefix))
        .filter_map(|m| m.cost_usd)
        .sum();
    let (outcome, report) =
        eval_token_efficiency_gate(case_name, gate, envelopes, tokens, cost_usd);
    if !quiet {
        let verdict = if outcome.passed { "PASS" } else { "FAIL" };
        println!(
            "token efficiency [{verdict}] {case_name}: {}",
            report.line()
        );
    }
    acc.f1_outcomes.push(outcome);
}

/// Run every cross-run gate over a cell's N run envelopes and record the
/// outcomes on `acc` (tool-selection floor, stability, tool-selection F1,
/// distractor accuracy, token efficiency, tool-edge coverage, golden-path
/// efficiency, trajectory validation). Each prints a one-line verdict unless
/// `quiet`. Lives here next to the per-gate evaluators it dispatches to; `acc`
/// is owned by [`super::run_live_agents`].
pub(super) fn record_cell_gates(
    agent: &mcptest_core::suite::AgentTest,
    case_name: &str,
    quiet: bool,
    envelopes: &[serde_json::Value],
    tool_surface_tokens: Option<u32>,
    acc: &mut super::run_live_agents::CellAcc<'_>,
) {
    use mcptest_core::eval::aggregate_selection;
    if let Some(floor) = &agent.tool_selection {
        let report = aggregate_selection(envelopes, floor);
        let met = report.meets_floor(floor);
        if !quiet {
            let verdict = if met { "PASS" } else { "FAIL" };
            println!(
                "tool-selection floor [{verdict}] {case_name}: {}",
                report.cost_vs_accuracy()
            );
        }
        acc.floor_outcomes
            .push((case_name.to_string(), report, met));
    }
    if let Some(gate) = &agent.stability {
        let outcome = eval_stability_gate(case_name, gate, envelopes);
        if !quiet {
            let summary =
                mcptest_core::eval::stability_aggregate::agent_stability_across(envelopes);
            let verdict = if outcome.passed { "PASS" } else { "FAIL" };
            println!("stability gate [{verdict}] {case_name}: {}", summary.line());
        }
        acc.stability_outcomes.push(outcome);
    }
    if let Some(gate) = &agent.equal_function {
        let (outcome, aggregate) = eval_equal_function_gate(case_name, gate, envelopes);
        if !quiet {
            let verdict = if outcome.passed { "PASS" } else { "FAIL" };
            println!(
                "tool-selection F1 [{verdict}] {case_name}: {}",
                aggregate.line()
            );
        }
        acc.f1_outcomes.push(outcome);
    }
    if let Some(gate) = &agent.distractors {
        let outcome = eval_distractor_gate(case_name, gate, envelopes);
        if !quiet {
            use mcptest_core::eval::distractors::score_selection;
            use mcptest_core::eval::tool_selection_f1::observed_calls;
            let chosen: Vec<String> = envelopes.iter().flat_map(observed_calls).collect();
            let score = score_selection(&chosen, &gate.correct, &gate.spec.resolve_ids());
            let verdict = if outcome.passed { "PASS" } else { "FAIL" };
            println!(
                "distractor accuracy [{verdict}] {case_name}: {}",
                score.line()
            );
        }
        acc.f1_outcomes.push(outcome);
    }
    record_token_efficiency_gate(agent, case_name, quiet, envelopes, tool_surface_tokens, acc);
    if let Some(gate) = &agent.tool_edges {
        let outcome = eval_tool_edges_gate(case_name, gate, envelopes);
        if !quiet {
            let summary = mcptest_core::eval::tool_edges::edge_coverage(&gate.set, envelopes);
            let verdict = if outcome.passed { "PASS" } else { "FAIL" };
            println!(
                "tool-edge coverage [{verdict}] {case_name}: {}",
                summary.line()
            );
        }
        acc.f1_outcomes.push(outcome);
    }
    if let Some(gate) = &agent.golden_path {
        let outcome = eval_golden_path_gate(case_name, gate, envelopes);
        if !quiet {
            let verdict = if outcome.passed { "PASS" } else { "FAIL" };
            let note = outcome.failure.as_deref().unwrap_or("efficient path");
            println!("golden-path gate [{verdict}] {case_name}: {note}");
        }
        acc.f1_outcomes.push(outcome);
    }
    if let Some(gate) = &agent.trajectory {
        let outcome = eval_trajectory_gate(case_name, gate, envelopes);
        if !quiet {
            let verdict = if outcome.passed { "PASS" } else { "FAIL" };
            let note = outcome.failure.as_deref().unwrap_or("trace matched");
            println!("trajectory gate [{verdict}] {case_name}: {note}");
        }
        acc.f1_outcomes.push(outcome);
    }
    if let Some(gate) = &agent.trajectory_axes {
        let outcome = eval_trajectory_axes_gate(case_name, gate, envelopes);
        if !quiet {
            let verdict = if outcome.passed { "PASS" } else { "FAIL" };
            let note = outcome.failure.as_deref().unwrap_or("axes satisfied");
            println!("trajectory-axes gate [{verdict}] {case_name}: {note}");
        }
        acc.f1_outcomes.push(outcome);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_core::report::{Report, RunSummary, TestResult, TestStatus};

    fn agent_row(name: &str) -> TestResult {
        TestResult {
            name: name.to_string(),
            status: TestStatus::Pass,
            duration_ms: 0,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: None,
            file: None,
            line: None,
            steps: Vec::new(),
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
            natural_language: false,
        }
    }

    fn report_with(rows: Vec<TestResult>) -> Report {
        Report {
            schema_version: "0.1".to_string(),
            mcptest_version: "test".to_string(),
            generated_at: String::new(),
            run_id: String::new(),
            // Derived (WOR-1317): the rows are all Pass fixtures, so the
            // passed count comes from them instead of a hand-rolled literal.
            summary: RunSummary::from_tests(&rows),
            tests: rows,
            cost: None,
            metadata: None,
            matrix: None,
            protocol_violations: Vec::new(),
        }
    }

    #[test]
    fn patches_agent_row_duration_from_metric() {
        let mut report = report_with(vec![agent_row("weather agent")]);
        let metrics = vec![AgentRowMetric {
            row_name: "weather agent".to_string(),
            duration_ms: 1234,
            cost_usd: None,
        }];
        apply_agent_metrics(&mut report, &metrics);
        assert_eq!(report.tests[0].duration_ms, 1234);
        // No priced run, so the cost block stays absent.
        assert!(report.cost.is_none());
    }

    #[test]
    fn rolls_priced_runs_into_cost_block() {
        let mut report = report_with(vec![agent_row("a"), agent_row("b")]);
        let metrics = vec![
            AgentRowMetric {
                row_name: "a".to_string(),
                duration_ms: 10,
                cost_usd: Some(0.02),
            },
            AgentRowMetric {
                row_name: "b".to_string(),
                duration_ms: 20,
                cost_usd: Some(0.04),
            },
        ];
        apply_agent_metrics(&mut report, &metrics);
        let cost = report.cost.expect("cost block populated for priced runs");
        assert!((cost.total_cost_usd - 0.06).abs() < 1e-9);
        assert_eq!(cost.juror_calls, 2);
        assert_eq!(cost.tests_with_cost, 2);
        assert!((cost.cost_per_test_avg - 0.03).abs() < 1e-9);
    }

    #[test]
    fn skips_cost_when_no_run_is_priced() {
        let mut report = report_with(vec![agent_row("replay")]);
        let metrics = vec![AgentRowMetric {
            row_name: "replay".to_string(),
            duration_ms: 99,
            cost_usd: None,
        }];
        apply_agent_metrics(&mut report, &metrics);
        assert_eq!(report.tests[0].duration_ms, 99);
        assert!(report.cost.is_none());
    }

    /// Build a run envelope whose `tool_calls` name the given tools with no
    /// server key, so [`observed_calls`] yields the bare ids the catalog
    /// distractors and bare correct ids compare against.
    fn envelope_with_calls(names: &[&str]) -> serde_json::Value {
        let calls: Vec<serde_json::Value> = names
            .iter()
            .map(|n| serde_json::json!({ "name": n }))
            .collect();
        serde_json::json!({ "tool_calls": calls })
    }

    fn distractor_gate(
        correct: &[&str],
        expect: Vec<mcptest_core::suite::Assertion>,
    ) -> mcptest_core::suite::DistractorGate {
        use mcptest_core::eval::distractors::{DistractorSource, DistractorSpec};
        mcptest_core::suite::DistractorGate {
            spec: DistractorSpec::new(3, DistractorSource::Catalog),
            correct: correct.iter().map(|s| s.to_string()).collect(),
            expect,
        }
    }

    #[test]
    fn distractor_gate_passes_when_only_correct_tools_chosen() {
        let gate = distractor_gate(&["search"], vec![]);
        let envelopes = vec![envelope_with_calls(&["search"])];
        let outcome = eval_distractor_gate("picker", &gate, &envelopes);
        assert!(outcome.passed, "{:?}", outcome.failure);
        assert_eq!(outcome.name, "picker (distractors)");
    }

    #[test]
    fn distractor_gate_fails_when_a_distractor_is_chosen() {
        // weather_forecast is the first bundled catalog distractor, so the
        // default `distractors.accuracy >= 50` floor must fail.
        let gate = distractor_gate(&["search"], vec![]);
        let envelopes = vec![envelope_with_calls(&["weather_forecast"])];
        let outcome = eval_distractor_gate("picker", &gate, &envelopes);
        assert!(!outcome.passed);
        assert!(outcome.failure.unwrap().contains("distractor"));
    }

    #[test]
    fn distractor_gate_honors_explicit_expect_forbidding_distractor_hits() {
        use mcptest_core::suite::{Assertion, Matcher};
        let expect = vec![Assertion {
            target: "distractors.chose_distractor".to_string(),
            matcher: Matcher::Schema(serde_json::json!({ "maximum": 0 })),
            weight: 1.0,
            name: None,
            transform: None,
        }];
        let gate = distractor_gate(&["search"], expect);
        // One correct plus one distractor pooled across two runs: accuracy 50
        // clears the default floor, but a distractor was chosen, so the explicit
        // `chose_distractor <= 0` gate fails.
        let envelopes = vec![
            envelope_with_calls(&["search"]),
            envelope_with_calls(&["weather_forecast"]),
        ];
        let outcome = eval_distractor_gate("picker", &gate, &envelopes);
        assert!(!outcome.passed);
    }

    #[test]
    fn distractor_gate_surfaces_a_certified_lower_target() {
        use mcptest_core::suite::{Assertion, Matcher};
        // WOR-1367: a certified_lower floor that a tiny perfect sample cannot
        // meet (the 95% Clopper-Pearson bound on one run is ~5%), so the gate
        // fails, which proves `distractors.certified_lower` resolved as a real
        // target wired into the metrics.
        let expect = vec![Assertion {
            target: "distractors.certified_lower".to_string(),
            matcher: Matcher::Schema(serde_json::json!({ "minimum": 50 })),
            weight: 1.0,
            name: None,
            transform: None,
        }];
        let gate = distractor_gate(&["search"], expect);
        let envelopes = vec![envelope_with_calls(&["search"])];
        let outcome = eval_distractor_gate("picker", &gate, &envelopes);
        assert!(!outcome.passed);
        assert!(outcome.failure.unwrap().contains("certified_lower"));
    }

    #[test]
    fn golden_path_gate_passes_on_the_ideal_path() {
        let gate = mcptest_core::suite::GoldenPathGate {
            path: mcptest_core::eval::GoldenPath::new(["search", "fetch"]),
            expect: vec![],
        };
        let envelopes = vec![envelope_with_calls(&["search", "fetch"])];
        let outcome = eval_golden_path_gate("picker", &gate, &envelopes);
        assert!(outcome.passed, "{:?}", outcome.failure);
        assert_eq!(outcome.name, "picker (golden-path)");
    }

    #[test]
    fn golden_path_gate_fails_on_a_repeated_tool() {
        let gate = mcptest_core::suite::GoldenPathGate {
            path: mcptest_core::eval::GoldenPath::new(["search", "fetch"]),
            expect: vec![],
        };
        // Two identical calls in a row: repeated-tool waste, penalty < 1.
        let envelopes = vec![envelope_with_calls(&["search", "search"])];
        let outcome = eval_golden_path_gate("picker", &gate, &envelopes);
        assert!(!outcome.passed);
    }

    #[test]
    fn golden_path_gate_with_no_envelope_fails() {
        let gate = mcptest_core::suite::GoldenPathGate {
            path: mcptest_core::eval::GoldenPath::new(["search"]),
            expect: vec![],
        };
        let outcome = eval_golden_path_gate("picker", &gate, &[]);
        assert!(!outcome.passed);
    }

    #[test]
    fn trajectory_gate_passes_a_strict_match() {
        use mcptest_core::eval::{ExpectedCall, ExpectedTrace};
        let gate = mcptest_core::suite::TrajectoryGate {
            expected: ExpectedTrace::strict(vec![
                ExpectedCall::by_name("search"),
                ExpectedCall::by_name("fetch"),
            ]),
            expect: vec![],
        };
        let envelopes = vec![envelope_with_calls(&["search", "fetch"])];
        let outcome = eval_trajectory_gate("picker", &gate, &envelopes);
        assert!(outcome.passed, "{:?}", outcome.failure);
        assert_eq!(outcome.name, "picker (trajectory)");
    }

    #[test]
    fn trajectory_gate_fails_on_a_mismatch() {
        use mcptest_core::eval::{ExpectedCall, ExpectedTrace};
        let gate = mcptest_core::suite::TrajectoryGate {
            expected: ExpectedTrace::strict(vec![
                ExpectedCall::by_name("search"),
                ExpectedCall::by_name("fetch"),
            ]),
            expect: vec![],
        };
        // Wrong second call under strict mode is a mismatch.
        let envelopes = vec![envelope_with_calls(&["search", "delete"])];
        let outcome = eval_trajectory_gate("picker", &gate, &envelopes);
        assert!(!outcome.passed);
    }

    #[test]
    fn trajectory_axes_gate_passes_when_dependency_and_order_hold() {
        use mcptest_core::eval::DependencyEdge;
        let gate = mcptest_core::suite::TrajectoryAxesGate {
            dependencies: vec![DependencyEdge::new("search", "fetch")],
            order: vec![("auth".to_string(), "search".to_string())],
            expect: vec![],
        };
        // auth precedes search, and fetch follows the search it depends on.
        let envelopes = vec![envelope_with_calls(&["auth", "search", "fetch"])];
        let outcome = eval_trajectory_axes_gate("picker", &gate, &envelopes);
        assert!(outcome.passed, "{:?}", outcome.failure);
        assert_eq!(outcome.name, "picker (trajectory-axes)");
    }

    #[test]
    fn trajectory_axes_gate_fails_when_consumer_runs_before_producer() {
        use mcptest_core::eval::DependencyEdge;
        let gate = mcptest_core::suite::TrajectoryAxesGate {
            dependencies: vec![DependencyEdge::new("search", "fetch")],
            order: vec![],
            expect: vec![],
        };
        // fetch reads search's output but runs first: dependency unsatisfied.
        let envelopes = vec![envelope_with_calls(&["fetch", "search"])];
        let outcome = eval_trajectory_axes_gate("picker", &gate, &envelopes);
        assert!(!outcome.passed);
        assert!(outcome.failure.unwrap().contains("dependency_satisfaction"));
    }

    #[test]
    fn trajectory_axes_gate_fails_on_an_out_of_order_pair() {
        let gate = mcptest_core::suite::TrajectoryAxesGate {
            dependencies: vec![],
            order: vec![("auth".to_string(), "search".to_string())],
            expect: vec![],
        };
        // search runs before auth: the order pair is unsatisfied.
        let envelopes = vec![envelope_with_calls(&["search", "auth"])];
        let outcome = eval_trajectory_axes_gate("picker", &gate, &envelopes);
        assert!(!outcome.passed);
    }

    #[test]
    fn trajectory_axes_gate_with_no_constraints_scores_a_perfect_pass() {
        let gate = mcptest_core::suite::TrajectoryAxesGate {
            dependencies: vec![],
            order: vec![],
            expect: vec![],
        };
        // No declared axes means both default to 100, so any trace passes.
        let envelopes = vec![envelope_with_calls(&["anything"])];
        let outcome = eval_trajectory_axes_gate("picker", &gate, &envelopes);
        assert!(outcome.passed, "{:?}", outcome.failure);
    }

    #[test]
    fn trajectory_axes_gate_with_no_envelope_fails() {
        let gate = mcptest_core::suite::TrajectoryAxesGate {
            dependencies: vec![],
            order: vec![],
            expect: vec![],
        };
        let outcome = eval_trajectory_axes_gate("picker", &gate, &[]);
        assert!(!outcome.passed);
    }

    /// Build a `token_efficiency:` gate over one `search` class, with each
    /// `(target, schema)` pair turned into a schema-matcher assertion. An empty
    /// `expect` exercises the default floor.
    fn token_efficiency_gate(
        expect: Vec<(&str, serde_json::Value)>,
    ) -> mcptest_core::suite::TokenEfficiencyGate {
        use mcptest_core::eval::tool_selection_f1::EqualFunctionSet;
        use mcptest_core::suite::{Assertion, Matcher};
        mcptest_core::suite::TokenEfficiencyGate {
            classes: vec![EqualFunctionSet {
                name: "search".to_string(),
                members: vec!["search".to_string()],
            }],
            expect: expect
                .into_iter()
                .map(|(target, schema)| Assertion {
                    target: target.to_string(),
                    matcher: Matcher::Schema(schema),
                    weight: 1.0,
                    name: None,
                    transform: None,
                })
                .collect(),
        }
    }

    #[test]
    fn token_efficiency_gate_passes_the_default_f1_floor() {
        let gate = token_efficiency_gate(vec![]);
        let envelopes = vec![envelope_with_calls(&["search"])];
        let (outcome, report) = eval_token_efficiency_gate("picker", &gate, &envelopes, 2000, 1.0);
        assert!(outcome.passed, "{:?}", outcome.failure);
        // F1 100, one correct selection: the per-correct figures divide cleanly.
        assert_eq!(report.f1_percent, 100);
        assert_eq!(report.tokens_per_correct(), Some(2000.0));
        assert_eq!(report.cost_per_correct(), Some(1.0));
    }

    #[test]
    fn token_efficiency_gate_fails_a_tokens_per_correct_ceiling() {
        // A fat catalog: 2000 tool-surface tokens for one correct selection
        // breaches a 1000-token-per-correct ceiling even though the F1 is perfect.
        let gate = token_efficiency_gate(vec![
            ("token_efficiency.f1", serde_json::json!({ "minimum": 50 })),
            (
                "token_efficiency.tokens_per_correct",
                serde_json::json!({ "maximum": 1000 }),
            ),
        ]);
        let envelopes = vec![envelope_with_calls(&["search"])];
        let (outcome, _) = eval_token_efficiency_gate("picker", &gate, &envelopes, 2000, 1.0);
        assert!(!outcome.passed);
        assert!(outcome
            .failure
            .as_deref()
            .unwrap()
            .contains("token_efficiency.tokens_per_correct"));
    }
}
