//! Typed data model for a parsed mcptest suite.
//!
//! These are the plain-data structs and enums the executor consumes. The
//! parsing algorithm that builds them lives in [`super`] (and the
//! `suite::*_parse` siblings); this file is types only so the model and the
//! parser can grow independently. The agent-test cluster lives in the
//! [`super::agent_types`] sibling for the same size reason. Everything here
//! is re-exported from [`crate::suite`] so external paths such as
//! `mcptest_core::suite::Matcher` keep resolving unchanged.

use std::collections::BTreeMap;

use serde_json::Value;

use crate::hooks::HookStep;

use super::agent_types::{AgentTest, ProviderSpec, SuiteBudget};
use super::faults::FaultSpec;
use super::matcher::Matcher;

/// Typed view of a parsed mcptest YAML suite. The executor consumes
/// this; the loader produces it.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedSuite {
    /// Servers declared under the top-level `servers:` map, keyed by
    /// the YAML name the test references with `server:`.
    pub servers: BTreeMap<String, ServerSpec>,
    /// Tests declared under `tools:`, `resources:`, and `prompts:` in
    /// declaration order. Each carries the server reference plus the
    /// action and the assertion list the runner enforces.
    pub tests: Vec<TypedTest>,
    /// Agent tests declared under `agents:` in declaration order
    ///. Kept on a separate list because their runtime path
    /// is fundamentally different from the tool/resource/prompt path.
    pub agents: Vec<AgentTest>,
    /// Optional `budget:` block for agent runs.
    pub budget: SuiteBudget,
    /// Optional `providers:` block declaring named custom
    /// OpenAI-compatible endpoints (Azure, OpenRouter, vLLM, etc.).
    /// Empty when the suite only uses auto-detected providers.
    pub providers: BTreeMap<String, ProviderSpec>,
    /// Optional `defaultTest:` baseline merged into every test.
    /// Its assertions are appended to each test and its options act as
    /// defaults an explicit per-test value overrides. `None` when the
    /// suite declares no baseline, in which case every test is unchanged.
    pub default_test: Option<DefaultTest>,
    /// Suite-level `hooks:` block. Empty when omitted, in which
    /// case the runner takes the no-hooks path unchanged.
    pub hooks: Hooks,
    /// Top-level `calibration:` check blocks. Each reads a labels
    /// file and gates on the judge's Expected Calibration Error, Brier score,
    /// and optional Rogan-Gladen corrected rate. Empty when the suite declares
    /// none, so existing suites are unchanged.
    pub calibration: Vec<crate::checks::calibration::CalibrationCheck>,
    /// Top-level `tool_quality:` check blocks. Each scores a server's
    /// tool catalog with the deterministic TDQS heuristics and gates on
    /// `min_score`, `mean_score`, and per-tool scores. Empty when the suite
    /// declares none.
    pub tool_quality: Vec<crate::checks::tool_quality::ToolQualityCheck>,
    /// Optional compliance `grade_delta:` block. Compares the current
    /// compliance scorecard against a saved prior one. `None` when the suite's
    /// `compliance:` object declares no `grade_delta`. Consumed by the
    /// `mcptest compliance` command, not the tool/agent run.
    pub grade_delta: Option<crate::checks::grade_delta::GradeDelta>,
    /// Optional compliance `spec_version_check:` block. Diffs the
    /// suite's pinned `spec_version` against a target revision and gates on the
    /// cross-version breakage. `None` when the `compliance:` object declares no
    /// `spec_version_check`. Consumed by the `mcptest compliance` command.
    pub spec_version_check: Option<crate::checks::spec_version_check::SpecVersionCheck>,
    /// Compliance tests from the `compliance:` block (bare-array or
    /// object form), in declaration order (WOR-1347). Kept off
    /// [`Self::tests`] because each names a raw protocol method, not a
    /// tool/resource/prompt action. Consumed by
    /// `mcptest compliance run --from-suite`; empty when the suite
    /// declares none.
    pub compliance_tests: Vec<super::compliance_parse::ComplianceCheck>,
    /// Optional `target_versions:` list. Empty means the
    /// suite uses the runner's default protocol; non-empty fans the
    /// run out one execution per version (v2 / ).
    pub target_versions: Vec<crate::protocol::ProtocolVersion>,
    /// Reusable error fixtures parsed from `fixtures:`.
    pub fixtures: crate::fixtures::FixtureSpec,
    /// Composition DAGs parsed from `compositions:`. Empty when none.
    pub compositions: Vec<crate::composition::ResolvedComposition>,
    /// Named fault declarations parsed from the top-level `faults:` block,
    /// referenced by an agent test's `inject:`. Empty when none.
    pub faults: Vec<FaultSpec>,
    /// Optional top-level `security:` block. Carries the cross-server
    /// trust-boundary assertion the agent run enforces after each run.
    /// `None` when the suite declares no `security:` block, so existing suites
    /// are unchanged.
    pub security: Option<SecurityAssertions>,
    /// Scenario-world definitions parsed from the top-level `scenarios:`
    /// block, in declaration order. Each declares a seeded hidden world and
    /// the transitions that mutate it; the `mcptest scenario` command replays
    /// a recorded trajectory against it. Empty when the suite declares none,
    /// so existing suites are unchanged.
    pub scenarios: Vec<crate::scenario::Scenario>,
    /// Optional suite-level default elicitation answer source. Applies to
    /// any tool test whose tool returns an `InputRequiredResult`, unless the
    /// test overrides it with its own `input_responses` / `input_responder`
    /// (WOR-1383). `None` when the suite declares none.
    pub input_responder: Option<InputResponderSpec>,
    /// Optional top-level `model_compat:` block (WOR-1486). Names a saved
    /// baseline snapshot and a candidate snapshot; the run's model-compat lane
    /// diffs them offline and folds the verdict in. `None` when the suite
    /// declares none, so existing suites are unchanged. Distinct from the
    /// `model_compatibility:` metadata list, which stays a list of strings.
    pub model_compat: Option<ModelCompatBlock>,
    /// Optional top-level `conformance:` block (WOR-1484). Opts the run into
    /// the SEP conformance lane: the run connects the suite's server, captures
    /// the MCP handshake evidence, runs the conformance probes, and folds each
    /// probe outcome into the report and exit code. `None` when the suite
    /// declares no `conformance:` block, so existing suites are unchanged.
    pub conformance: Option<ConformanceBlock>,
    /// Optional top-level `coverage:` block (WOR-1488). Declares the minimum
    /// coverage percentages the run gates on (exit 6 when a declared dimension
    /// falls short), so a suite carries its own coverage gate instead of
    /// passing `--coverage`/`--coverage-threshold`. Declaring it also turns on
    /// coverage instrumentation. `None` when the suite declares none, so
    /// existing suites are unchanged; the `--coverage-threshold` flag overrides
    /// the declared thresholds.
    pub coverage: Option<CoverageBlock>,
    /// Optional top-level `performance:` block (WOR-1487). Carries the per-test
    /// timeout default and the soft p95 latency budget. `default_timeout_ms`
    /// sets the runner's default per-test timeout (a test's own `timeout:` still
    /// wins); `p95_latency_ms` drives an advisory performance lane that
    /// highlights a p95 over budget without failing the run. `None` when the
    /// suite declares none, so existing suites are unchanged.
    pub performance: Option<PerformanceBlock>,
}

/// Parsed view of the top-level `performance:` block (WOR-1487).
///
/// The v1 surface is intentionally minimal (deeper load testing lives behind a
/// separate command): a default per-test timeout and a soft p95 latency budget.
/// Declaring `p95_latency_ms` adds an advisory performance lane that highlights
/// a p95 over budget without gating the run.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PerformanceBlock {
    /// Default per-test timeout in milliseconds. Maps onto the runner's
    /// `default_timeout`; a test's own `timeout:` still wins. `None` leaves the
    /// runner default in place.
    pub default_timeout_ms: Option<u64>,
    /// Soft p95 latency budget in milliseconds. When the run's p95 per-test
    /// latency exceeds it, the performance lane reports an advisory row that
    /// never fails CI. `None` runs no performance lane.
    pub p95_latency_ms: Option<u64>,
}

/// Parsed view of the top-level `coverage:` block (WOR-1488).
///
/// Each entry is the minimum percentage for one coverage dimension; the run
/// gates (exit 6) when a declared dimension falls below it. Declaring the block
/// also enables coverage instrumentation, so a suite owns its gate without the
/// `--coverage` flag. An empty set measures and reports coverage without
/// gating. The `--coverage-threshold` flag overrides these thresholds.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CoverageBlock {
    /// Minimum coverage percentages, one entry per declared dimension, in
    /// declared order.
    pub thresholds: crate::coverage::Thresholds,
}

/// Parsed view of the top-level `conformance:` block (WOR-1484).
///
/// The key's presence opts the suite into the conformance lane. The lane
/// connects the suite's server, builds the handshake evidence the conformance
/// command captures, and runs the universal and extended probes; a failed MUST
/// gates the run while a failed SHOULD or MAY is advisory.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ConformanceBlock {
    /// Protocol version the run targets (for example `2026-07-28`). When
    /// `Some`, the negotiated version must match it or the version probe
    /// fails. `None` defaults to the command's behavior of accepting any
    /// negotiated version.
    pub target_version: Option<String>,
}

/// Parsed view of the top-level `model_compat:` block (WOR-1486).
///
/// Declares the two baseline snapshots the model-compat lane diffs during
/// `mcptest run`: `baseline` is the saved reference and `candidate` is the
/// new model's snapshot. Both paths resolve against the suite directory. A
/// clean diff passes, a breaking change fails the run, and non-breaking drift
/// is advisory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelCompatBlock {
    /// Path to the baseline snapshot JSON (left-hand side of the diff),
    /// resolved relative to the suite file.
    pub baseline: std::path::PathBuf,
    /// Path to the candidate snapshot JSON (right-hand side of the diff),
    /// resolved relative to the suite file.
    pub candidate: std::path::PathBuf,
}

/// Parsed view of the top-level `security:` block.
///
/// Holds the suite's deterministic security assertions for an agent run. Today
/// that is the cross-server trust boundary; the `capability_negotiation`
/// sub-block targets the forward stateless model and is not yet honored, so it
/// is not modeled here. `None` on [`ResolvedSuite::security`] means the suite
/// declared no `security:` block at all.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SecurityAssertions {
    /// The cross-server trust boundary the run enforces. `None` when the
    /// `security:` block omits `trust_boundary:`. Detector lives in
    /// [`crate::security::cross_server_trust_findings`].
    pub trust_boundary: Option<crate::security::TrustBoundaryAssertion>,
    /// Opt-in for the deterministic security scan lane. When `true`,
    /// `mcptest run` connects the suite's servers, snapshots each `tools/list`,
    /// runs the deterministic security lanes, and folds the findings into the
    /// single report and exit code. Defaults to `false`, so an existing
    /// `security:` block that only carries `trust_boundary` is unchanged.
    pub scan: bool,
}

/// Suite-level baseline test block merged into every test.
///
/// `defaultTest` lets a suite set a baseline assertion list, combined
/// threshold, and derived-metric list once instead of repeating them on
/// every test. The merge rule: the baseline's assertions and assert-sets
/// are appended to each test's own (the test keeps its own first), and
/// each scalar option (`threshold`) is used only when the test does not
/// set its own. Derived metrics are appended.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct DefaultTest {
    /// Assertions appended to every test's own assertion list.
    pub expectations: Vec<Assertion>,
    /// Assert-sets appended to every test's own assert-set list.
    pub assert_sets: Vec<AssertSet>,
    /// Default combined-score threshold; used only when a test omits its
    /// own `threshold:`.
    pub threshold: Option<f64>,
    /// Derived metrics appended to every test's own list.
    pub derived_metrics: Vec<DerivedMetric>,
    /// Default transform; used only when a test declares no `transform:`
    /// of its own. A per-test transform overrides this baseline
    /// wholesale (it is not field-merged).
    pub transform: Option<TransformSpec>,
}

/// Request/response transform step for a tool test.
///
/// Each phase rewrites a single JSON value: the request transform mutates
/// the outbound `tools/call` params before they are sent, and the response
/// transform mutates the response envelope before assertions run. Either
/// phase may be omitted, and either may be a subprocess command or an inline
/// jq program (see [`TransformStep`]). The contract is identical for both
/// forms: one JSON value in, one JSON value out.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TransformSpec {
    /// Step run on the request params before the call is sent. `None`
    /// leaves the request untouched.
    pub request: Option<TransformStep>,
    /// Step run on the response envelope before assertions run. `None`
    /// leaves the response untouched.
    pub response: Option<TransformStep>,
}

impl TransformSpec {
    /// True when neither phase declares a step. A test whose transform
    /// is empty behaves exactly as a test with no transform at all.
    pub fn is_empty(&self) -> bool {
        self.request.is_none() && self.response.is_none()
    }
}

/// One transform phase, either a subprocess command or an inline jq program.
///
/// The bare-string YAML form (`node ./wrap.js`) parses to [`Self::Command`]
/// and runs as a one-shot stdin-to-stdout JSON subprocess (see
/// [`crate::transform`]). The mapping form (`{ jq: ".result.content" }`)
/// parses to [`Self::Jq`] and runs in-process via the jaq engine (see
/// [`crate::transform_jq`]), the ergonomic shortcut for the common
/// reshape/rename/unwrap case that needs no helper on `$PATH`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransformStep {
    /// A subprocess command, the unchanged bare-string form.
    Command(TransformCommand),
    /// An inline jq program run in-process. Carries the program
    /// source; it is compiled at load time to surface a syntax error early
    /// and recompiled per run by [`crate::transform_jq::run`].
    Jq(String),
}

/// One transform command, parsed into a program plus argv.
///
/// The YAML form is a single shell command string (`node ./wrap.js`,
/// `jq '.arguments.q |= ascii_downcase'`) split with POSIX shell rules,
/// the same splitter the  hooks use. Splitting at parse time means
/// an unterminated quote is a load-time error, not a runtime spawn failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransformCommand {
    /// Program to execute. Looked up via `$PATH` when not absolute.
    pub program: String,
    /// Arguments passed after the program name.
    pub args: Vec<String>,
}

/// Metamorphic (oracle-free) relations declared on a tool test.
///
/// Each relation asserts a property between the base call and a transformed
/// call, so a tool with no stable golden output is still testable. The relation
/// engine lives in [`crate::eval::metamorphic`]; this is the parsed YAML view
/// the executor runs. An empty `expect` applies the default gate (fail on any
/// relation violation); a non-empty `expect` gates the `metamorphic.*` targets
/// directly instead.
#[derive(Debug, Clone, PartialEq)]
pub struct MetamorphicSpec {
    /// Relations to check, in declared order.
    pub relations: Vec<crate::eval::metamorphic::Relation>,
    /// Assertions over the `metamorphic.*` targets. Empty means the default
    /// gate (fail on any relation violation) applies.
    pub expect: Vec<Assertion>,
}

/// Schema-driven input fuzzing declared on a tool test (WOR-1238).
///
/// The executor derives malformed argument cases from the tool's `inputSchema`,
/// issues each one, and checks the server fails cleanly. The case generator and
/// the report live in [`crate::eval::fuzz`]. An empty `expect` applies the
/// default gate (fail on any crash, hang, protocol violation, or leak).
#[derive(Debug, Clone, PartialEq)]
pub struct FuzzSpec {
    /// Seed for the deterministic case generator.
    pub seed: u64,
    /// Maximum number of cases to run.
    pub cases: usize,
    /// Per-call timeout in milliseconds; a slower call counts as a hang.
    pub max_call_ms: u64,
    /// Assertions over the `fuzz.*` targets. Empty means the default gate.
    pub expect: Vec<Assertion>,
}

/// Negative-path JSON-RPC error conformance declared on a tool test (WOR-1239).
///
/// Runs a taxonomy-keyed set of bad-request probes and gates on whether the
/// server rejected each cleanly. The probe set and the pass rule live in
/// [`crate::eval::negative_path`]. An empty `expect` applies the default gate
/// (no probe failed the contract).
#[derive(Debug, Clone, PartialEq)]
pub struct NegativePathSpec {
    /// The probes to run. Empty means run the full set.
    pub checks: Vec<crate::eval::negative_path::ProbeKind>,
    /// Assertions over the `negative_path.*` targets. Empty means the default.
    pub expect: Vec<Assertion>,
}

/// How to reach a single MCP server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerSpec {
    /// Spawn a subprocess and talk JSON-RPC over its stdio.
    Stdio {
        /// Executable to run. Mirrors the YAML `command: [arg0, ...]`
        /// first element.
        command: String,
        /// Remaining argv elements.
        args: Vec<String>,
        /// Extra environment variables to set on the child.
        env: BTreeMap<String, String>,
    },
    /// Connect over the streamable HTTP transport. Session-based by
    /// default (2025-03-26 shape); `stateless` selects the 2026-07-28
    /// stateless shape (no `Mcp-Session-Id`, `_meta` on every request,
    /// `server/discover` instead of the `initialize` handshake).
    StreamableHttp {
        /// Base URL the runner POSTs requests to.
        url: String,
        /// Optional headers (Authorization, custom values).
        headers: BTreeMap<String, String>,
        /// Optional OAuth client-credentials config. When set (and no explicit
        /// Authorization header is present) the connector fetches a token at
        /// connect time and attaches it.
        oauth: Option<OAuthClientCredentials>,
        /// When true, drive the 2026-07-28 stateless transport
        /// (`transport: stateless-http`); otherwise the session-based
        /// shape (WOR-1383).
        stateless: bool,
    },
    /// Connect over the MCP 2024-11-05 SSE transport.
    LegacySse {
        /// Base URL of the SSE endpoint.
        url: String,
        /// Optional headers (Authorization, custom values).
        headers: BTreeMap<String, String>,
        /// Optional OAuth client-credentials config (see `StreamableHttp`).
        oauth: Option<OAuthClientCredentials>,
    },
    /// Replay a recorded cassette instead of reaching a live server. The
    /// run never touches the network: every request is matched against the
    /// recorded exchanges. See [`crate::transport::ReplayTransport`].
    Cassette {
        /// Path to the cassette JSON file, resolved relative to the suite.
        path: String,
        /// Optional live refresh target declared next to `cassette:`
        /// (WOR-1279): the argv `--update-cassettes` spawns to re-record.
        /// Replay never uses it; it is preferred over the cassette's own
        /// `meta.source_command`.
        refresh_command: Option<Vec<String>>,
        /// URL counterpart of `refresh_command`; declaring both is
        /// rejected at load so the refresh target is never ambiguous.
        refresh_url: Option<String>,
    },
}

/// OAuth 2.0 client-credentials configuration for a URL server.
///
/// Machine-to-machine auth: the connector POSTs `grant_type=client_credentials`
/// at connect time and uses the returned token for the run. The secret is named
/// by env var (`client_secret_env`) and resolved at connect, never stored here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthClientCredentials {
    /// Token endpoint to POST the grant to.
    pub token_endpoint: String,
    /// OAuth client id.
    pub client_id: String,
    /// Name of the env var holding the client secret.
    pub client_secret_env: String,
    /// Optional space-delimited scope string.
    pub scope: Option<String>,
}

/// Step-level budgets and header checks lifted from the long-form
/// `expect:` mapping (WOR-1280).
///
/// The short-form `expect:` array carries only assertions, so a test that
/// uses it gets the [`Default`] here (no budgets, no header checks) and
/// behaves exactly as before. The long form (`expect: { assertions: [...],
/// max_duration_ms, ... }`) populates these fields so the executor can
/// gate the step on them instead of silently dropping them, which was the
/// WOR-1280 bug.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ExpectBudgets {
    /// Wall-clock budget for the live call, in milliseconds. `None`
    /// means no duration gate. Cache hits skip the check by convention;
    /// today the executor only measures live calls, so the skip is
    /// structural rather than a runtime branch.
    pub max_duration_ms: Option<u64>,
    /// Response token budget. `None` means no token gate.
    pub max_response_tokens: Option<TokenBudgetSpec>,
    /// Header expectations in declaration order, evaluated together by
    /// `matchers::headers`. Empty means no presence/value checks.
    pub response_headers: Vec<(String, crate::matchers::headers::HeaderMatch)>,
    /// Header names that must not appear in the response. Empty means no
    /// absence checks.
    pub response_headers_absent: Vec<String>,
}

impl ExpectBudgets {
    /// True when no budget or header field was declared, which keeps the
    /// executor on the legacy no-gate path with zero extra work.
    pub fn is_empty(&self) -> bool {
        self.max_duration_ms.is_none()
            && self.max_response_tokens.is_none()
            && !self.has_header_checks()
    }

    /// True when the test declares any header expectation, present or
    /// absent. The executor uses this to decide whether the target
    /// transport even has headers to check.
    pub fn has_header_checks(&self) -> bool {
        !self.response_headers.is_empty() || !self.response_headers_absent.is_empty()
    }
}

/// Parsed view of the `max_response_tokens` field (WOR-1280).
///
/// The short YAML form (`max_response_tokens: 200`) lowers into this with
/// the defaults; the long form names the tokenizer, the overage mode, and
/// the per-image cost explicitly. Kept separate from
/// [`crate::matchers::budgets::TokenBudget`] because the matcher struct
/// resolves the tokenizer at evaluation time while the suite must carry
/// the name the YAML declared.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenBudgetSpec {
    /// Maximum allowed token count for the response content.
    pub budget: usize,
    /// Tokenizer name, validated against the known set at load time so a
    /// typo fails the suite load instead of the first run.
    pub tokenizer: String,
    /// Behavior on overage: fail the step or pass with a warning note.
    pub mode: crate::matchers::budgets::BudgetMode,
    /// Token cost charged per image content block.
    pub image_cost: usize,
}

/// One executable test. Each test names exactly one action against one
/// server plus a list of assertions to evaluate against the response.
#[derive(Debug, Clone, PartialEq)]
pub struct TypedTest {
    /// Human-readable name from the YAML `name:` field.
    pub name: String,
    /// Server reference; the executor's lookup against
    /// [`ResolvedSuite::servers`] surfaces unknown references at run
    /// time so the loader stays tolerant of templated YAML.
    pub server: String,
    /// What the test invokes.
    pub action: Action,
    /// Assertions evaluated against the call's response. Empty list is
    /// legal and means "just make sure the call succeeds".
    pub expectations: Vec<Assertion>,
    /// Step-level budgets and header checks from the long-form `expect:`
    /// mapping (WOR-1280). Default (empty) for the short form, so legacy
    /// suites are byte-for-byte unchanged.
    pub budgets: ExpectBudgets,
    /// Optional `tags: [...]` consumed by `mcptest run --tag` /
    /// `--skip-tag` to select a subset of the suite. Empty when omitted.
    pub tags: Vec<String>,
    /// Optional `skip: "<reason>"` marker (WOR-1267). `Some` parks the
    /// test: the runner reports it as SKIP with this reason and never
    /// executes it. The reason is mandatory so a parked test always says
    /// why; the parser rejects booleans and empty strings.
    pub skip: Option<String>,
    /// `only: true` focus marker (WOR-1267). When any test in the suite
    /// sets it, exactly the marked tests run and everything else is
    /// implicitly skipped, so an author can iterate on one test without
    /// deleting YAML. The run path refuses focused suites under CI=true.
    pub only: bool,
    /// Named assert-set bundles. Empty when none are declared.
    pub assert_sets: Vec<AssertSet>,
    /// Optional combined-score threshold on a 0..1 scale. `None`
    /// keeps the legacy "every assertion must pass" semantics; `Some(t)`
    /// passes the test only when its weighted score is at or above `t`.
    pub threshold: Option<f64>,
    /// Derived metrics reported for this test. Empty when none.
    pub derived_metrics: Vec<DerivedMetric>,
    /// Optional request/response transform. A test that declares
    /// a transform is treated as user logic, so it opts out of caching.
    pub transform: Option<TransformSpec>,
    /// Optional named-error fixture injection. When set, the
    /// runner returns the resolved fixture's error envelope instead of
    /// dispatching to the server. Validated at parse time.
    pub inject_error: Option<String>,
    /// Optional metamorphic relations. Tool-test only; `None` on resource and
    /// prompt tests. When set, the executor issues one transformed call per
    /// relation and gates on the result (WOR-1237).
    pub metamorphic: Option<MetamorphicSpec>,
    /// Optional schema-driven input fuzzing. Tool-test only. When set, the
    /// executor fuzzes the tool's arguments and gates on clean failure handling
    /// (WOR-1238).
    pub fuzz: Option<FuzzSpec>,
    /// Optional negative-path JSON-RPC error conformance. Tool-test only. When
    /// set, the executor runs the taxonomy-keyed bad-request probes and gates on
    /// clean rejection (WOR-1239).
    pub negative_path: Option<NegativePathSpec>,
    /// Optional static answers for a 2026-07-28 `InputRequiredResult`
    /// elicitation (SEP-2322). When the tool answers a `tools/call` with an
    /// input-required result, the runner satisfies it from this map and
    /// retries (WOR-1383). Mutually exclusive with [`Self::input_responder`].
    pub input_responses: Option<InputResponseSpec>,
    /// Optional per-test elicitation answer source that overrides the
    /// suite-level [`ResolvedSuite::input_responder`]. Mutually exclusive with
    /// [`Self::input_responses`] (WOR-1383).
    pub input_responder: Option<InputResponderSpec>,
}

/// Static answers for an `InputRequiredResult` elicitation: each
/// [`crate::protocol::elicitation::InputRequest`] id resolves to a value
/// (WOR-1383).
#[derive(Debug, Clone, PartialEq)]
pub struct InputResponseSpec {
    /// `inputRequest.id` to the answer value.
    pub values: BTreeMap<String, Value>,
}

/// Parsed (not yet live) configuration for a dynamic elicitation answer
/// source. The runtime responder that holds a `reqwest::Client` is built
/// from this at plan-build time (WOR-1383).
#[derive(Debug, Clone, PartialEq)]
pub enum InputResponderSpec {
    /// Answer elicitations by POSTing them to a REST endpoint.
    Rest(RestInputResponderSpec),
}

/// REST elicitation responder config: the runner POSTs the `inputRequests`
/// to `url` and reads back `inputResponses` (WOR-1383).
#[derive(Debug, Clone, PartialEq)]
pub struct RestInputResponderSpec {
    /// Endpoint the runner POSTs each elicitation to.
    pub url: String,
    /// Static headers sent on every POST (for example an `Authorization`
    /// token). Values are redacted in diagnostics.
    pub headers: BTreeMap<String, String>,
    /// Per-request timeout in milliseconds. Defaults to 30s when unset.
    pub timeout_ms: Option<u64>,
}

/// What a test invokes against the server.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
    /// `tools/call` against the named tool with the given args object.
    ToolCall {
        /// Tool name from the YAML `tool:` field.
        tool: String,
        /// Arguments object passed verbatim as `params.arguments`.
        args: Value,
    },
    /// `resources/read` against the given URI.
    ResourceRead {
        /// Resource URI from the YAML `uri:` field.
        uri: String,
    },
    /// `prompts/get` against the named prompt.
    PromptGet {
        /// Prompt name from the YAML `prompt:` field.
        name: String,
        /// Optional argument object passed as `params.arguments`.
        args: Option<Value>,
    },
}

/// One assertion: take a path inside the response, run a matcher
/// against the value found there.
#[derive(Debug, Clone, PartialEq)]
pub struct Assertion {
    /// Dot-and-bracket path into the response envelope. Examples:
    /// `result.content[0].text`, `result.isError`,
    /// `result.structuredContent.status`. The empty string addresses
    /// the response envelope as a whole.
    pub target: String,
    /// What to check at `target`.
    pub matcher: Matcher,
    /// Relative weight when a test computes a combined score.
    /// Defaults to `1.0`. The weight only matters under the score model:
    /// a test with no `threshold:` still requires every assertion to
    /// pass regardless of weight, so a weight on an unthresholded test
    /// is inert. Carried as a plain `f64` because a negative or zero
    /// weight is a user error the parser rejects, not a state to model.
    pub weight: f64,
    /// Optional name used to reference this assertion's score from a
    /// `derivedMetrics` aggregation. `None` when the user did
    /// not name it; an unnamed assertion still contributes to the test's
    /// combined score, it just cannot be referenced by a derived metric.
    pub name: Option<String>,
    /// Optional command that rewrites the extracted value before the
    /// matcher sees it. Distinct from the request/response
    /// [`TransformSpec`] because an assertion transform has one value, not
    /// a request/response pair: the value at `target` is written to the
    /// command's stdin and the parsed stdout replaces it. `None` keeps the
    /// no-transform path, where the matcher judges the value as extracted.
    pub transform: Option<TransformCommand>,
}

/// A named bundle of assertions with its own pass threshold.
///
/// The set passes when its internal weighted pass-fraction (sum of
/// passing assertion weights over the sum of all assertion weights in
/// the set) is at or above [`AssertSet::threshold`]. The set then
/// contributes to the parent test's combined score as a single unit
/// worth [`AssertSet::weight`]: a passing set adds its weight, a failing
/// set adds zero. This lets a suite group "any 2 of these 3 string
/// checks" without forcing every one to pass.
#[derive(Debug, Clone, PartialEq)]
pub struct AssertSet {
    /// Optional name used to reference this set's pass/fail (1.0/0.0)
    /// from a `derivedMetrics` aggregation. `None` when unnamed.
    pub name: Option<String>,
    /// Pass threshold on a 0..1 scale. The set passes when its internal
    /// weighted pass-fraction is at or above this value. A threshold of
    /// `1.0` means every assertion in the set must pass; `0.0` means the
    /// set always passes.
    pub threshold: f64,
    /// Weight this set carries in the parent test's combined score.
    /// Defaults to `1.0`.
    pub weight: f64,
    /// Assertions evaluated to compute the set's internal pass-fraction.
    pub assertions: Vec<Assertion>,
}

/// A reported metric derived by weighted aggregation over named
/// assertion and assert-set scores.
///
/// The `value` is intentionally not an expression language: it is a list
/// of `{ ref, weight }` terms naming other scores, combined by weighted
/// sum or weighted average. This keeps the feature auditable (no
/// arbitrary code runs) at the cost of expressiveness. A derived metric
/// is reported on the run; it optionally gates the test when it carries
/// a `threshold`.
#[derive(Debug, Clone, PartialEq)]
pub struct DerivedMetric {
    /// Name surfaced in the report.
    pub name: String,
    /// Weighted aggregation that produces the metric's numeric value.
    pub value: DerivedAggregation,
    /// Optional pass threshold on a 0..1 scale. When set, the metric's
    /// value must be at or above it for the test to pass; when `None`
    /// the metric is reported but does not gate.
    pub threshold: Option<f64>,
}

/// How a [`DerivedMetric`] combines the scores it references.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DerivedKind {
    /// Weighted sum of the referenced scores: `sum(score_i * weight_i)`.
    WeightedSum,
    /// Weighted average: `sum(score_i * weight_i) / sum(weight_i)`.
    /// Falls back to `0.0` when every weight is zero.
    WeightedAverage,
}

/// One weighted aggregation over named scores.
#[derive(Debug, Clone, PartialEq)]
pub struct DerivedAggregation {
    /// Whether to combine the terms by weighted sum or weighted average.
    pub kind: DerivedKind,
    /// The `(referenced-name, weight)` terms. Each name must match a
    /// named assertion or assert-set in the same test; an unknown name
    /// contributes a score of `0.0` (the executor reports it).
    pub terms: Vec<DerivedTerm>,
}

/// One `(ref, weight)` term inside a [`DerivedAggregation`].
#[derive(Debug, Clone, PartialEq)]
pub struct DerivedTerm {
    /// Name of the assertion or assert-set whose score this term pulls in.
    pub reference: String,
    /// Multiplier applied to the referenced score.
    pub weight: f64,
}

/// Suite-level lifecycle hooks run around the whole run and each test.
///
/// Each hook is a list of [`HookStep`]s run in declaration order. A step is
/// either a plain command string or a context-aware command that
/// opts into the JSON-over-stdio ABI. A YAML scalar (single
/// command) is sugar for a one-element list. A non-zero exit from a
/// `before_all` or `before_each` hook fails the affected test(s); an
/// `after_each` or `after_all` failure is reported but does not by itself
/// fail a test. Declaring any hooks opts the run out of result caching (the
/// schema already notes this).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Hooks {
    /// Steps run once before any test executes.
    pub before_all: Vec<HookStep>,
    /// Steps run once after every test finishes.
    pub after_all: Vec<HookStep>,
    /// Steps run before each test.
    pub before_each: Vec<HookStep>,
    /// Steps run after each test.
    pub after_each: Vec<HookStep>,
}

impl Hooks {
    /// True when no hook is declared. The runner skips all hook plumbing
    /// in that case so the no-hooks path stays exactly as it was.
    pub fn is_empty(&self) -> bool {
        self.before_all.is_empty()
            && self.after_all.is_empty()
            && self.before_each.is_empty()
            && self.after_each.is_empty()
    }
}
