//! The [`ParseError`] type for the suite parser.
//!
//! Split out of [`super`] so the parser module stays under the module-size
//! ceiling; the variants are re-exported from [`crate::suite`] so external
//! paths such as `mcptest_core::suite::ParseError` keep resolving unchanged.

use thiserror::Error;

use super::duration::DurationParseError;

/// Errors raised by [`super::parse`].
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ParseError {
    /// A field the parser expected to find was missing.
    #[error("missing required field `{field}` at {path}")]
    Missing {
        /// JSON-pointer-ish location in the YAML where the field was
        /// expected.
        path: String,
        /// Name of the missing field.
        field: &'static str,
    },
    /// A field had the wrong shape.
    #[error("field `{field}` at {path} is not {expected}")]
    BadShape {
        /// Location of the offending field.
        path: String,
        /// Name of the offending field.
        field: &'static str,
        /// Human-readable description of the expected shape.
        expected: &'static str,
    },
    /// A matcher block specified more than one or no variants.
    #[error("matcher at {path} must specify exactly one of exact/contains/regex/subset/schema/snapshot/llm-judge/llm-jury/contains-all/contains-any/icontains/starts-with/is-json/is-valid-tools-call/levenshtein/not")]
    MatcherShape {
        /// Location of the offending matcher.
        path: String,
    },
    /// A matcher block named a key the typed model does not recognize.
    /// Catches YAML typos (e.g. `shema:`) at parse time instead of
    /// letting them tunnel through as a runtime "not yet evaluated"
    /// failure.
    #[error(
        "matcher at {path} uses unknown key `{key}` (supported: exact, contains, regex, subset, schema, snapshot, llm-judge, llm-jury, contains-all, contains-any, icontains, starts-with, is-json, is-valid-tools-call, levenshtein, not)"
    )]
    UnknownMatcher {
        /// Location of the offending matcher.
        path: String,
        /// The key the YAML used.
        key: String,
    },
    /// The server entry mixed `command:` with `url:`.
    #[error("server `{name}` declares both `command:` and `url:` (pick one)")]
    AmbiguousServer {
        /// Name of the server entry.
        name: String,
    },
    /// A derived metric referenced one declared later or itself (it can never
    /// resolve, since derived metrics evaluate in declaration order). The
    /// message names the metric, the path, and the offending reference.
    #[error("{0}")]
    DerivedMetricForwardRef(String),
    /// A duration-valued field (e.g. agent `time_budget`) did not parse as
    /// a human duration like `"30s"` or `"2m"`.
    #[error("field `{field}` at {path} is not a valid duration: {source}")]
    BadDuration {
        /// Location of the offending field.
        path: String,
        /// Name of the offending field.
        field: &'static str,
        /// Why the duration string failed to parse.
        source: DurationParseError,
    },
    /// An inline `transform: { jq: ... }` program did not compile.
    /// Caught at load time so a jq typo points at the test rather than
    /// surfacing as a run-time surprise.
    #[error("inline jq transform `{field}` at {path} did not compile: {detail}")]
    BadJq {
        /// Location of the offending jq program.
        path: String,
        /// Name of the phase (`request` or `response`).
        field: &'static str,
        /// One-line summary of the jq compile failure.
        detail: String,
    },
    /// A test used a `jury.*` analytics target but did not contain exactly
    /// one `llm-jury` assertion to produce those analytics. The
    /// `jury.*` targets reference the jury that runs in the same test, so
    /// zero or several juries leaves the target ambiguous or undefined.
    #[error(
        "test `{test}` uses a `jury.*` target but contains {jury_count} `llm-jury` assertions; \
         exactly one is required so the target references a single jury"
    )]
    JuryTargetArity {
        /// Name of the offending test.
        test: String,
        /// How many `llm-jury` assertions the test actually declared.
        jury_count: usize,
    },
    /// An agent test declared a `stability:` gate but is configured to run
    /// only once. The within-session stability targets aggregate the
    /// N run traces, so a sample of one cannot produce a meaningful variance or
    /// a cross-run weakest score. Raise `runs:` to at least 2.
    #[error(
        "agent test `{test}` declares a `stability:` gate but runs only {runs} time(s); \
         the stability.* targets aggregate across runs, so set `runs:` to at least 2"
    )]
    StabilityGateArity {
        /// Name of the offending agent test.
        test: String,
        /// The `runs:` value the agent test declared (always 1 here).
        runs: u32,
    },
    /// A `rubric:` block (on an agent `eval:` or a top-level `evals:` entry)
    /// failed to parse. Surfaces the shared rubric parser's error so both
    /// surfaces report the same shape.
    #[error("rubric at {path} is invalid: {detail}")]
    BadRubric {
        /// Position in the YAML document, e.g. `tests[1]/eval/rubric`.
        path: String,
        /// One-line description of what went wrong.
        detail: String,
    },
    /// A `compositions:` entry failed to parse or its DAG failed
    /// validation. Surfaces both the YAML shape errors (unknown
    /// field, missing `name`) and the structural errors (duplicate
    /// node, unknown need, undeclared reference, cycle, missing
    /// output) under one message so the loader speaks one error
    /// shape for the whole block.
    #[error("compositions entry at {path} is invalid: {detail}")]
    BadComposition {
        /// Position in the YAML document, e.g. `compositions[2]`.
        path: String,
        /// One-line description of what went wrong.
        detail: String,
    },
    /// A `scenarios:` entry failed to parse: a malformed transition,
    /// guard, effect, forbidden rule, or signal block. Surfaces the serde
    /// shape error under one message so the loader speaks one error shape
    /// for the whole block.
    #[error("scenarios entry at {path} is invalid: {detail}")]
    BadScenario {
        /// Position in the YAML document, e.g. `scenarios[0]/transitions`.
        path: String,
        /// One-line description of what went wrong.
        detail: String,
    },
    /// A `target_versions:` entry was not a recognized MCP protocol
    /// revision. Caught at parse time so a typo like
    /// `2026-07-29` never silently selects a session-based fallback.
    #[error("target_versions[{index}] `{value}` is not a known MCP protocol version")]
    UnknownProtocolVersion {
        /// Position in the `target_versions` array.
        index: usize,
        /// The raw string the YAML carried.
        value: String,
    },
    /// A test's `inject_error: <name>` references a name not declared
    /// under `fixtures.errors[]`. Caught at parse time so a
    /// typo fails the load rather than dispatching to the live server.
    #[error("test `{test}` references unknown fixture `{name}` via `inject_error:`")]
    UnknownFixture {
        /// Name of the offending test.
        test: String,
        /// The raw string the YAML carried.
        name: String,
    },
    /// A test's `inject_error: <name>` references a fixture whose
    /// `tool:` scope does not match the test's tool. The
    /// `applies_to: any` scope matches every tool; a tool-scoped
    /// fixture cannot be injected by a test that calls a different
    /// tool.
    #[error(
        "test `{test}` (tool `{test_tool}`) injects fixture `{name}` scoped to tool `{fixture_tool}`; \
         the fixture's tool must match the test's tool or use `applies_to: any`"
    )]
    MismatchedFixtureScope {
        /// Name of the offending test.
        test: String,
        /// Fixture name from `inject_error:`.
        name: String,
        /// Tool the fixture is scoped to.
        fixture_tool: String,
        /// Tool the test calls.
        test_tool: String,
    },
    /// An agent test's `inject: [name]` references a fault not declared under
    /// the top-level `faults:` block. Caught at parse time so a typo fails the
    /// load (exit 2) rather than silently injecting nothing.
    #[error("agent `{agent}` injects unknown fault `{name}`; declare it under `faults:`")]
    UnknownFault {
        /// Name of the offending agent test.
        agent: String,
        /// The fault name from `inject:`.
        name: String,
    },
    /// A test, agent, or check references a `server:` name that is not
    /// declared under the top-level `servers:` block. Surfaced at load time
    /// so a typo fails before any server connection is attempted, rather
    /// than partway through the run.
    #[error(
        "`{referrer}` references unknown server `{server}`; \
         declare it under `servers:` (declared: {known})"
    )]
    UnknownServerRef {
        /// The test/agent/check that carries the bad reference.
        referrer: String,
        /// The server name the reference asked for.
        server: String,
        /// Comma-separated list of declared server names.
        known: String,
    },
    /// A `command:` argv element was a `scheme://`-style secret reference
    /// (`vault://...`, `mock://...`). Process command lines are visible to
    /// any user via `ps`/proc listings, so resolving a secret into argv
    /// would leak it; references are rejected here and the author is told
    /// to pass the credential through `env:`, `headers:`, or `auth:`
    /// instead, where it stays in the process's own memory.
    #[error(
        "server command at {path} carries secret reference `{value}` in argv; \
         process listings expose argv, so resolve secrets via `env:`, `headers:`, or `auth:` instead"
    )]
    ReferenceInArgs {
        /// Location of the offending argv element in the YAML.
        path: String,
        /// The reference string the argv element carried.
        value: String,
    },
}
