//! SEP conformance lane for `mcptest run` (WOR-1484).
//!
//! Extracted from `run_live_lanes` so that module stays under the
//! production-size cap. When a suite declares a `conformance:` block, this lane
//! connects the suite's server, captures the MCP handshake evidence the
//! `mcptest conformance run --server` command captures (reusing its evidence
//! builder), runs the universal and extended probes, and folds each probe into a
//! report row. A failed MUST probe gates the run like a failing test; a failed
//! SHOULD or MAY probe renders as an informational passing row so an advisory
//! finding never breaks CI.

use anyhow::{Context, Result};

use mcptest_core::checks::CheckOutcome;
use mcptest_core::conformance::{
    extended_probes, universal_probes, ConformanceEvidence, ProbeOutcome, ProbeResult,
};
use mcptest_core::connector::ServerPool;
use mcptest_core::suite::ResolvedSuite;

use super::conformance_probe::evidence_from_client;
use super::run_live_lanes::{LaneCtx, LaneGating, LaneVerdict};

/// Run the conformance lane against the post-drive context.
///
/// Returns `Ok(None)` when the suite declares no `conformance:` block, so a run
/// that does not opt in stays byte-identical. Otherwise connects the suite's
/// servers on a fresh OS thread (the connect is async and the lanes run inside
/// the run's ambient tokio runtime, which would panic on a nested `block_on`),
/// captures handshake evidence for the primary server, runs the probes, and maps
/// every probe outcome to a row.
pub(super) fn run(ctx: &LaneCtx<'_>) -> Result<Vec<LaneVerdict>> {
    let Some(block) = &ctx.suite.conformance else {
        return Ok(vec![]);
    };

    let evidence = capture_evidence(ctx.suite)?;

    // The command's `--server` path probes with an empty target so any
    // negotiated version passes; this lane mirrors that default and only
    // pins the version when the suite declares `target_version`.
    let target_version = block.target_version.as_deref().unwrap_or("");
    let mut outcomes = universal_probes(target_version, &evidence);
    outcomes.extend(extended_probes(&evidence));

    // WOR-1518: a failed MUST is a gating failing row; a failed SHOULD/MAY is an
    // advisory failing row (reported as `passed: false` but kept out of the exit
    // code) rather than the old passing-row dodge. Passing probes are gating
    // passing rows. The probes are a deterministic function of the evidence, so
    // the verdict is reproducible.
    let mut gating_rows = Vec::new();
    let mut advisory_rows = Vec::new();
    for outcome in &outcomes {
        let (row, advisory) = probe_to_row(outcome);
        if advisory {
            advisory_rows.push(row);
        } else {
            gating_rows.push(row);
        }
    }
    let mut verdicts = vec![LaneVerdict {
        any_failed: gating_rows.iter().any(|r| !r.passed),
        rows: gating_rows,
        gating: LaneGating::Gating,
        label: "conformance".into(),
    }];
    if !advisory_rows.is_empty() {
        verdicts.push(LaneVerdict {
            any_failed: true,
            rows: advisory_rows,
            gating: LaneGating::Advisory,
            label: "conformance (advisory)".into(),
        });
    }
    Ok(verdicts)
}

/// Map one probe outcome to a report row plus whether it is an advisory finding.
///
/// A passing probe is a passing gating row (`false`). A failed MUST is a gating
/// failing row (`false`). A failed SHOULD or MAY is a failing row flagged
/// advisory (`true`) so the caller routes it into the advisory verdict the spine
/// keeps out of the exit code; before WOR-1518 it was hidden as a passing row.
fn probe_to_row(outcome: &ProbeOutcome) -> (CheckOutcome, bool) {
    let name = format!("conformance/{}", outcome.check);
    match &outcome.result {
        ProbeResult::Passed => (
            CheckOutcome {
                name,
                passed: true,
                failure: None,
            },
            false,
        ),
        ProbeResult::Failed { reason } => {
            // Only a MUST failure gates; a SHOULD/MAY failure is advisory.
            let advisory = outcome.level != "MUST";
            (
                CheckOutcome {
                    name,
                    passed: false,
                    failure: Some(format!("[{}] {reason}", outcome.level)),
                },
                advisory,
            )
        }
    }
}

/// Connect the suite's servers and capture conformance evidence for the primary
/// (first declared) connected server.
///
/// Runs on a fresh OS thread with its own tokio runtime, mirroring the security
/// lane: connecting and probing are async and the lanes run inside the run's
/// ambient runtime, where a nested `block_on` would panic. The pool is shut down
/// before returning so no subprocess server is left running.
fn capture_evidence(suite: &ResolvedSuite) -> Result<ConformanceEvidence> {
    // The closure cannot borrow `suite` across the thread boundary, so clone it
    // into an owned value the thread takes ownership of. The clone owns its
    // data, so it is `Send`, and a connect-time clone is cheap relative to the
    // spawn.
    let suite = suite.clone();
    std::thread::spawn(move || -> Result<ConformanceEvidence> {
        let rt = tokio::runtime::Runtime::new().context("could not build tokio runtime")?;
        rt.block_on(async move {
            let pool = ServerPool::new();
            crate::commands::compliance_suite::connect_suite_servers(&suite, &pool).await?;
            let result = build_evidence(&suite, &pool).await;
            for err in pool.shutdown_all().await {
                eprintln!("mcptest run: conformance lane shutdown warning: {err}");
            }
            result
        })
    })
    .join()
    .map_err(|_| anyhow::anyhow!("conformance lane worker thread panicked"))?
}

/// Build evidence for the first connected server in declaration order.
///
/// The suite's servers are a `BTreeMap`, so iterating its keys is sorted and
/// deterministic; the first key the pool actually connected is the primary
/// server probed. A suite with no connected server (every server is referenced
/// only by skipped tests) yields no evidence to probe, which is an error rather
/// than a silent empty pass.
async fn build_evidence(suite: &ResolvedSuite, pool: &ServerPool) -> Result<ConformanceEvidence> {
    for server_name in suite.servers.keys() {
        if let Some(server) = pool.get(server_name).await {
            return Ok(evidence_from_client(&server.client).await);
        }
    }
    anyhow::bail!(
        "conformance lane: no server was connected to probe; \
         the suite must declare a server reachable by a runnable test"
    )
}

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

    fn minimal_suite() -> ResolvedSuite {
        let yaml: serde_yaml::Value =
            serde_yaml::from_str("servers:\n  s: { command: [\"echo\"] }\n").unwrap();
        mcptest_core::suite::parse(&yaml).unwrap()
    }

    #[test]
    fn no_conformance_block_is_a_no_op() {
        // A suite with no `conformance:` block must short-circuit before the
        // lane spawns its worker thread or connects any server, so a run with
        // only tool tests stays byte-identical. The happy path (a real probe
        // folding outcomes into rows) needs a live mock server, so it lives in
        // the cli_run_conformance_lane integration test.
        let suite = minimal_suite();
        assert!(suite.conformance.is_none());
        let ctx = LaneCtx {
            suite: &suite,
            config_path: std::path::Path::new("suite.yml"),
            quiet: true,
        };
        assert!(run(&ctx).unwrap().is_empty());
    }

    #[test]
    fn a_failed_must_is_a_gating_failing_row() {
        let outcome = ProbeOutcome {
            check: "initialize-handshake",
            level: "MUST",
            result: ProbeResult::Failed {
                reason: "the initialize handshake did not complete".to_string(),
            },
        };
        let (row, advisory) = probe_to_row(&outcome);
        assert_eq!(row.name, "conformance/initialize-handshake");
        assert!(!row.passed, "a failed MUST gates the run");
        assert!(!advisory, "a MUST failure is not advisory");
        assert!(row.failure.is_some());
    }

    #[test]
    fn a_failed_should_is_an_advisory_failing_row() {
        let outcome = ProbeOutcome {
            check: "sep-2164-error-code",
            level: "SHOULD",
            result: ProbeResult::Failed {
                reason: "errored with -32000, not the standard -32602".to_string(),
            },
        };
        let (row, advisory) = probe_to_row(&outcome);
        // WOR-1518: a SHOULD failure is now a failing row flagged advisory, so a
        // --json consumer sees the finding while the caller routes it into the
        // advisory verdict the spine keeps out of the exit code.
        assert!(!row.passed, "a failed SHOULD is reported as a finding");
        assert!(advisory, "a SHOULD failure is advisory");
        assert!(row.failure.is_some());
    }

    #[test]
    fn a_passed_probe_is_a_passing_row() {
        let outcome = ProbeOutcome {
            check: "capabilities-advertised",
            level: "MUST",
            result: ProbeResult::Passed,
        };
        let (row, advisory) = probe_to_row(&outcome);
        assert!(row.passed);
        assert!(!advisory);
        assert!(row.failure.is_none());
    }
}
