//! Executor trait + test description types for the runner engine.
//!
//! Keeping the runner generic over a [`TestExecutor`] trait is the
//! transport-free wedge: the engine here knows about parallelism,
//! timeouts, retries, and result aggregation, but it does not know how
//! to talk to an MCP server. The CLI's `run` command plugs in a
//! concrete executor that wraps the protocol client; tests in this
//! crate plug in lightweight mocks. The seam keeps `mcptest-core`
//! buildable without `reqwest` or stdio plumbing.

use std::time::Duration;

use async_trait::async_trait;

use crate::report::{TestFailure, TestStatus};

/// Minimal description of one test the runner can execute.
///
/// The wire-protocol shape that lives on disk (YAML, JSON Schema
/// v1.json) is richer than this. We pass the runner a pre-resolved
/// view so the engine never has to understand the YAML grammar. The
/// CLI command builds this from the loaded config.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestSpec {
    /// Human-readable test name from the YAML `name:` field. Surfaces
    /// in the canonical [`TestResult.name`](crate::report::TestResult).
    pub name: String,

    /// Source file path relative to the suite root, if known. The
    /// runner forwards this into [`TestResult.file`](crate::report::TestResult)
    /// so reporters can render clickable references.
    pub file: Option<String>,

    /// One-based line number in `file`, if known. Pairs with `file`.
    pub line: Option<u32>,

    /// Optional per-test timeout override. When set, the runner caps
    /// this single test's wall-clock duration at the supplied value
    /// instead of the suite default. The CLI honors the YAML
    /// `timeout:` key by populating this field.
    pub timeout: Option<Duration>,

    /// Optional per-test retry override. When `Some(n)`, the runner
    /// will replay this test up to `n` extra times before giving up.
    /// When `None` the runner uses
    /// [`RunnerOptions::retry`](super::options::RunnerOptions).
    pub retry: Option<u32>,

    /// Declared skip reason (WOR-1267). When `Some`, the engine reports
    /// this test as SKIP carrying the reason and never calls the
    /// executor, so a parked test still occupies a report row but does
    /// no work. The CLI populates this from the YAML `skip:` field and
    /// from the implicit `only:` exclusion.
    pub skip_reason: Option<String>,
}

impl TestSpec {
    /// Convenience constructor used by tests and simple call sites.
    ///
    /// Production callers should build the struct field by field so the
    /// optional metadata (file, line, per-test timeout, per-test retry)
    /// stays explicit at the call site.
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            file: None,
            line: None,
            timeout: None,
            retry: None,
            skip_reason: None,
        }
    }

    /// Build a spec the engine reports as SKIP (with the reason) instead
    /// of executing. Used by the CLI for YAML `skip:` tests and for tests
    /// excluded by an `only:` focus set (WOR-1267).
    pub fn skipped(name: impl Into<String>, reason: impl Into<String>) -> Self {
        Self {
            skip_reason: Some(reason.into()),
            ..Self::new(name)
        }
    }
}

/// Single outcome emitted by a [`TestExecutor`].
///
/// The runner does not stitch protocol-level decisions (cache hits,
/// step breakdown, jury consensus) here; those land later when the
/// CLI's concrete executor calls
/// [`runner::time_step`](super::time_step) and the consensus helpers.
/// This struct is the irreducible "did the test pass or fail" answer
/// the executor returns, plus a failure payload for the fail branch.
#[derive(Debug, Clone, PartialEq)]
pub struct TestOutcome {
    /// Pass, fail, or skip.
    pub status: TestStatus,
    /// Populated only when `status == Fail`. The runner forwards this
    /// onto [`TestResult.failure`](crate::report::TestResult).
    pub failure: Option<TestFailure>,
    /// Combined weighted score on a 0..1 scale when the test ran under
    /// the score model. `None` for legacy all-pass tests so the
    /// report omits the field. The runner forwards this onto
    /// [`TestResult.score`](crate::report::TestResult).
    pub score: Option<f64>,
    /// Derived metrics computed for the test. Empty for tests
    /// that declared none. Forwarded onto
    /// [`TestResult.derived_metrics`](crate::report::TestResult).
    pub derived_metrics: Vec<crate::report::DerivedMetricReport>,
    /// Per-juror consensus from a single `llm-jury` assertion, when the test
    /// ran one (WOR-1384). The runner forwards this onto
    /// [`TestResult.consensus`](crate::report::TestResult) so reporters render
    /// the juror breakdown. `None` for tests with no jury assertion.
    pub consensus: Option<crate::report::JuryConsensus>,
}

impl TestOutcome {
    /// Build a passing outcome. The runner sets `duration_ms` and
    /// timestamps itself, so the executor only has to report the
    /// verdict.
    pub fn pass() -> Self {
        Self {
            status: TestStatus::Pass,
            failure: None,
            score: None,
            derived_metrics: Vec::new(),
            consensus: None,
        }
    }

    /// Build a failing outcome with the given message. Use this when
    /// the executor has nothing structured to report beyond a single
    /// human-readable line.
    pub fn fail(message: impl Into<String>) -> Self {
        Self {
            status: TestStatus::Fail,
            failure: Some(TestFailure {
                message: message.into(),
                diff: None,
                network_diagnostic: None,
            }),
            score: None,
            derived_metrics: Vec::new(),
            consensus: None,
        }
    }

    /// Build a failing outcome from a fully-populated [`TestFailure`].
    /// The executor uses this when it has a structured diff or a
    /// network diagnostic to attach.
    pub fn fail_with(failure: TestFailure) -> Self {
        Self {
            status: TestStatus::Fail,
            failure: Some(failure),
            score: None,
            derived_metrics: Vec::new(),
            consensus: None,
        }
    }

    /// Build a skipped outcome. Reserved for cases where the executor
    /// itself decides to skip (filter mismatch, conditional skip).
    /// Fail-fast bookkeeping (see
    /// [`RunnerOptions::fail_fast`](super::options::RunnerOptions)) is
    /// the runner's job, not the executor's.
    pub fn skip() -> Self {
        Self {
            status: TestStatus::Skip,
            failure: None,
            score: None,
            derived_metrics: Vec::new(),
            consensus: None,
        }
    }

    /// Build an inconclusive outcome for a non-deterministic probe whose
    /// trials neither confirmed a pass nor a fail at the configured
    /// confidence (WOR-1240). The reason rides on the failure payload so
    /// reporters can surface why the verdict was undecided.
    pub fn inconclusive(reason: impl Into<String>) -> Self {
        Self {
            status: TestStatus::Inconclusive,
            failure: Some(crate::report::TestFailure {
                message: reason.into(),
                diff: None,
                network_diagnostic: None,
            }),
            score: None,
            derived_metrics: Vec::new(),
            consensus: None,
        }
    }

    /// Attach the combined score and derived metrics computed under the
    /// score model. Returns `self` so the executor can build
    /// the verdict and then layer the scoring telemetry on in one
    /// expression.
    pub fn with_score(
        mut self,
        score: Option<f64>,
        derived_metrics: Vec<crate::report::DerivedMetricReport>,
    ) -> Self {
        self.score = score;
        self.derived_metrics = derived_metrics;
        self
    }

    /// Attach the per-juror consensus from the test's `llm-jury` assertion
    /// (WOR-1384). Returns `self` so the executor can layer the consensus onto
    /// the verdict in one expression. `None` leaves the report field absent.
    pub fn with_consensus(mut self, consensus: Option<crate::report::JuryConsensus>) -> Self {
        self.consensus = consensus;
        self
    }
}

/// Pluggable adapter the runner uses to execute one test.
///
/// Implementations should be pure with respect to the test (no shared
/// mutable state across calls) so the runner can drive them in
/// parallel without race conditions. The runner enforces parallelism,
/// timeout, and retry policy around each call; implementations only
/// need to do the work and report the verdict.
#[async_trait]
pub trait TestExecutor: Send + Sync {
    /// Execute one test and return its [`TestOutcome`].
    ///
    /// The runner serializes this call's start/end with the runtime
    /// clock, so the executor itself does not have to populate
    /// timestamps or `duration_ms`. The runner will wrap the call in a
    /// `tokio::time::timeout` based on
    /// [`TestSpec::timeout`] or the suite default, so executors should
    /// be cancel-safe: returning early on drop is fine, holding a lock
    /// across await points that the runner cancels is not.
    async fn execute_one(&self, test: &TestSpec) -> TestOutcome;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pass_outcome_has_no_failure_payload() {
        let outcome = TestOutcome::pass();
        assert_eq!(outcome.status, TestStatus::Pass);
        assert!(outcome.failure.is_none());
    }

    #[test]
    fn fail_outcome_carries_message() {
        let outcome = TestOutcome::fail("boom");
        assert_eq!(outcome.status, TestStatus::Fail);
        let failure = outcome.failure.expect("failure present");
        assert_eq!(failure.message, "boom");
        assert!(failure.diff.is_none());
    }

    #[test]
    fn fail_with_preserves_structured_payload() {
        let f = TestFailure {
            message: "diffed".into(),
            diff: Some("a\nb".into()),
            network_diagnostic: None,
        };
        let outcome = TestOutcome::fail_with(f.clone());
        assert_eq!(outcome.failure.as_ref(), Some(&f));
    }

    #[test]
    fn skip_outcome_marks_status_only() {
        let outcome = TestOutcome::skip();
        assert_eq!(outcome.status, TestStatus::Skip);
        assert!(outcome.failure.is_none());
    }

    #[test]
    fn test_spec_new_leaves_optional_fields_empty() {
        let s = TestSpec::new("smoke");
        assert_eq!(s.name, "smoke");
        assert!(s.file.is_none());
        assert!(s.line.is_none());
        assert!(s.timeout.is_none());
        assert!(s.retry.is_none());
        assert!(s.skip_reason.is_none());
    }

    #[test]
    fn skipped_spec_carries_the_reason() {
        let s = TestSpec::skipped("parked", "flaky upstream");
        assert_eq!(s.name, "parked");
        assert_eq!(s.skip_reason.as_deref(), Some("flaky upstream"));
    }
}
