//! Security scan lane for `mcptest run` (WOR-1483).
//!
//! Extracted from `run_live_lanes` so that module stays under the
//! production-size cap. When a suite opts in with `security: { scan: true }`,
//! this lane connects the suite's servers, snapshots each `tools/list`, runs the
//! deterministic security scan ([`run_deterministic`]), and folds each finding
//! into a report row. It only ever runs the deterministic lanes; the advisory
//! LLM judge stays behind `mcptest security --model` (ADR 0041), so the lane is
//! reproducible and safe to gate CI on.

use anyhow::{Context, Result};
use serde_json::{json, Value};

use mcptest_core::checks::CheckOutcome;
use mcptest_core::connector::ServerPool;
use mcptest_core::security::{run_deterministic, SecurityRunOptions, SecuritySeverity};

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

/// The severity floor the lane gates on, mirroring the `mcptest security`
/// `--fail-on` default (`SecurityFailOn::High`). A finding at or above this
/// level fails the run; lower findings render as informational passing rows so
/// an info or low note never breaks CI.
const GATING_FLOOR: SecuritySeverity = SecuritySeverity::High;

/// Run the security scan lane against the post-drive context.
///
/// Returns `Ok(None)` when the suite declares no `security:` block or leaves
/// `scan` false, 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`), introspects each server's tool surface, runs the
/// deterministic scan, and maps every finding to a row.
pub(super) fn run(ctx: &LaneCtx<'_>) -> Result<Vec<LaneVerdict>> {
    let Some(security) = &ctx.suite.security else {
        return Ok(vec![]);
    };
    if !security.scan {
        return Ok(vec![]);
    }

    // Connecting and introspecting is async; `introspect_snapshot` runs it on a
    // fresh OS thread with its own runtime (the lanes run inside the run's
    // ambient runtime, where a nested `block_on` would panic) and returns an
    // owned snapshot.
    let snapshot = introspect_snapshot(ctx.suite)?;

    let run = run_deterministic(&snapshot, &SecurityRunOptions::default());
    // WOR-1518: a finding at or above the gating floor is a gating failing row;
    // a below-floor finding is an advisory failing row (reported as a finding,
    // `passed: false`, but kept out of the exit code) instead of the old
    // passing-row dodge that hid it from `--json` consumers.
    let mut gating_rows = Vec::new();
    let mut advisory_rows = Vec::new();
    for f in &run.report.findings {
        let mut msg = format!("[{}] {}", f.severity.as_str(), f.message);
        if !f.evidence.is_empty() {
            msg.push_str(&format!(" (evidence: {})", f.evidence));
        }
        let row = CheckOutcome {
            name: format!("security/{}", f.rule_id),
            passed: false,
            failure: Some(msg),
        };
        if f.severity >= GATING_FLOOR {
            gating_rows.push(row);
        } else {
            advisory_rows.push(row);
        }
    }
    // Always emit the gating verdict (empty rows = a clean PASS) so the per-lane
    // summary still prints "security: PASS" on a clean scan. The scan runs only
    // the deterministic lanes (no model), so the verdict is reproducible.
    let mut verdicts = vec![LaneVerdict {
        any_failed: !gating_rows.is_empty(),
        rows: gating_rows,
        gating: LaneGating::Gating,
        label: "security".into(),
    }];
    if !advisory_rows.is_empty() {
        verdicts.push(LaneVerdict {
            any_failed: true,
            rows: advisory_rows,
            gating: LaneGating::Advisory,
            label: "security (advisory)".into(),
        });
    }
    Ok(verdicts)
}

/// Connect the suite's servers, snapshot every `tools/list`, and assemble the
/// snapshot Value the deterministic scan reads.
///
/// Runs on a fresh OS thread with its own tokio runtime, mirroring the
/// compliance lane: connecting is 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 introspect_snapshot(suite: &mcptest_core::suite::ResolvedSuite) -> Result<Value> {
    // 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<Value> {
        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_snapshot(&suite, &pool).await;
            for err in pool.shutdown_all().await {
                eprintln!("mcptest run: security lane shutdown warning: {err}");
            }
            result
        })
    })
    .join()
    .map_err(|_| anyhow::anyhow!("security lane worker thread panicked"))?
}

/// Snapshot the `tools/list` of every connected server into the Value shape
/// [`run_deterministic`] reads.
///
/// A single connected server yields `{ "tools": [...] }`, exactly the
/// `tools/list`-style body the surface lane expects. Two or more servers yield
/// `{ "servers": [{ "name", "tools" }, ...], "tools": [...flattened...] }`: the
/// `servers` array feeds the namespace lane ([`servers_from_snapshot`]) and the
/// flattened `tools` keep the surface lane scanning every definition. Only
/// servers the pool actually connected are snapshotted, so a server referenced
/// solely by skipped tests is absent (and never spawned), the same surface the
/// run itself exercised.
async fn build_snapshot(
    suite: &mcptest_core::suite::ResolvedSuite,
    pool: &ServerPool,
) -> Result<Value> {
    let mut per_server: Vec<(String, Value)> = Vec::new();
    // BTreeMap iteration is sorted, so the snapshot order is deterministic.
    for server_name in suite.servers.keys() {
        let Some(server) = pool.get(server_name).await else {
            // The server was not connected (only skipped tests reference it), so
            // there is no tool surface to scan for it.
            continue;
        };
        let listing = server
            .client
            .request("tools/list", json!({}))
            .await
            .with_context(|| {
                format!("security lane: tools/list on server `{server_name}` failed")
            })?;
        let tools = listing
            .get("tools")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        per_server.push((server_name.clone(), Value::Array(tools)));
    }

    if per_server.len() == 1 {
        let (_, tools) = per_server.into_iter().next().unwrap_or_default();
        return Ok(json!({ "tools": tools }));
    }

    let mut all_tools: Vec<Value> = Vec::new();
    let mut servers: Vec<Value> = Vec::with_capacity(per_server.len());
    for (name, tools) in per_server {
        if let Value::Array(arr) = &tools {
            all_tools.extend(arr.iter().cloned());
        }
        servers.push(json!({ "name": name, "tools": tools }));
    }
    Ok(json!({ "servers": servers, "tools": all_tools }))
}

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

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

    fn scan_suite() -> mcptest_core::suite::ResolvedSuite {
        let yaml: serde_yaml::Value = serde_yaml::from_str(
            "security:\n  scan: true\nservers:\n  s: { command: [\"echo\"] }\n",
        )
        .unwrap();
        mcptest_core::suite::parse(&yaml).unwrap()
    }

    #[test]
    fn no_security_block_is_a_no_op() {
        // A suite with no `security:` 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 scan folding
        // findings into rows) needs a live mock server, so it lives in the
        // cli_run_security_lane integration test.
        let suite = minimal_suite();
        assert!(suite.security.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 scan_false_is_a_no_op() {
        // A `security:` block that only carries `trust_boundary` (scan absent,
        // defaulting false) must not trigger the scan lane.
        let yaml: serde_yaml::Value = serde_yaml::from_str(
            "security:\n  trust_boundary: {}\nservers:\n  s: { command: [\"echo\"] }\n",
        )
        .unwrap();
        let suite = mcptest_core::suite::parse(&yaml).unwrap();
        let security = suite.security.as_ref().expect("security block present");
        assert!(!security.scan);
        let ctx = LaneCtx {
            suite: &suite,
            config_path: std::path::Path::new("suite.yml"),
            quiet: true,
        };
        assert!(run(&ctx).unwrap().is_empty());
    }

    #[test]
    fn scan_true_opts_the_suite_into_the_lane() {
        // The opt-in flag is what the lane keys off; the actual connect/scan is
        // covered live in the integration test.
        let suite = scan_suite();
        assert!(suite.security.as_ref().expect("security block").scan);
    }

    #[test]
    fn single_server_snapshot_is_a_tools_list_body() {
        // The single-server shape is the plain `{ "tools": [...] }` body the
        // surface lane reads, with no `servers` array (which would switch on the
        // namespace lane).
        let tools = serde_json::json!([{ "name": "echo", "description": "Echo." }]);
        let snapshot = serde_json::json!({ "tools": tools });
        assert!(snapshot.get("servers").is_none());
        assert_eq!(
            snapshot
                .get("tools")
                .and_then(Value::as_array)
                .map(Vec::len),
            Some(1)
        );
    }

    #[test]
    fn multi_server_snapshot_carries_servers_and_flattened_tools() {
        // The multi-server shape carries a `servers` array (so `servers_from_snapshot`
        // feeds the namespace lane) plus a flattened `tools` array (so the surface
        // lane still scans every definition).
        let snapshot = serde_json::json!({
            "servers": [
                { "name": "a", "tools": [{ "name": "x" }] },
                { "name": "b", "tools": [{ "name": "x" }] }
            ],
            "tools": [{ "name": "x" }, { "name": "x" }]
        });
        let servers = servers_from_snapshot(&snapshot);
        assert_eq!(servers.len(), 2);
        assert_eq!(
            snapshot
                .get("tools")
                .and_then(Value::as_array)
                .map(Vec::len),
            Some(2)
        );
    }
}
