//! Security-posture signals for the scorecard.
//!
//! The security section of a scorecard reports two things: what broke (the
//! findings) and what defenses the server has (posture). This module produces
//! the posture half as a set of signals, each tagged with the MCP-DPT defense
//! layer it speaks to (arXiv:2604.07551) so the scorecard reads as a coverage
//! map rather than a flat checklist. ETDI (arXiv:2506.01333) is the source for
//! the integrity and auth-posture signals.
//!
//! The honesty rule from the spec: a signal mcptest cannot observe black-box is
//! reported as such, never scored as a silent pass. Two posture signals depend
//! on active probes that ship with ; until then they report
//! "requires an active probe" rather than a verdict. The whole-server controls
//! a black-box client can never see (server-side WAF, payload inspection,
//! runtime intent verification) are listed explicitly in
//! [`PostureReport::undetectable`].

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// An MCP-DPT defense layer a posture signal speaks to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpDptLayer {
    /// Transport-level controls (TLS, Origin, rebinding).
    Transport,
    /// Authentication and authorization at the handshake.
    Auth,
    /// Tool-definition layer (versioning, pinning).
    Tool,
    /// Supply-chain integrity (signing, attestation).
    SupplyChain,
    /// Host-level controls (rate limiting, quotas).
    Host,
    /// Server-internal behavior (error handling).
    Server,
}

impl McpDptLayer {
    /// Lowercase label used in scorecard output.
    pub fn as_str(self) -> &'static str {
        match self {
            McpDptLayer::Transport => "transport",
            McpDptLayer::Auth => "auth",
            McpDptLayer::Tool => "tool",
            McpDptLayer::SupplyChain => "supply-chain",
            McpDptLayer::Host => "host",
            McpDptLayer::Server => "server",
        }
    }
}

/// How confidently a black-box client can read a posture signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Detectability {
    /// Read directly and reliably from observable behavior.
    Detected,
    /// Read in part; the black-box view misses some of the picture.
    Partial,
    /// Needs an active probe that has not run (the transport and rate-limit
    /// probes ship with ).
    RequiresActiveProbe,
    /// Cannot be seen black-box at all.
    NotDetectableBlackBox,
}

impl Detectability {
    /// Lowercase label used in scorecard output.
    pub fn as_str(self) -> &'static str {
        match self {
            Detectability::Detected => "detected",
            Detectability::Partial => "partial",
            Detectability::RequiresActiveProbe => "requires-active-probe",
            Detectability::NotDetectableBlackBox => "not-detectable-black-box",
        }
    }
}

/// The auth posture a caller read from the connection handshake.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthPostureKind {
    /// No authentication required to connect.
    None,
    /// A static bearer token in a header.
    HeaderBearer,
    /// An OAuth flow.
    OAuth,
}

impl AuthPostureKind {
    /// Human-readable label for the scorecard line.
    pub fn label(self) -> &'static str {
        match self {
            AuthPostureKind::None => "none",
            AuthPostureKind::HeaderBearer => "header bearer token",
            AuthPostureKind::OAuth => "OAuth",
        }
    }
}

/// One posture signal: a defense, how mcptest read it, and how confidently.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostureSignal {
    /// Short signal name, for example `auth-posture`.
    pub name: String,
    /// How the signal is detected black-box.
    pub method: String,
    /// MCP-DPT defense layers this signal speaks to.
    pub layers: Vec<McpDptLayer>,
    /// How confidently a black-box client can read it.
    pub detectability: Detectability,
    /// The concrete reading, or why it could not be read.
    pub detail: String,
}

/// The posture half of a scorecard security section.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostureReport {
    /// One line per posture signal.
    pub signals: Vec<PostureSignal>,
    /// Controls a black-box client can never observe. Listed so a reader does
    /// not mistake an absent signal for a clean one.
    pub undetectable: Vec<String>,
}

/// Inputs to a posture assessment.
///
/// `snapshot` is a `tools/list`-style body, used for the version-stamp signal.
/// `auth` is the posture the caller read from the handshake. `error_envelope`
/// is a sampled error response, if one was captured; without it the error
/// hygiene signal reports that it needs a sample.
pub struct PostureInputs<'a> {
    /// The current tool catalog snapshot.
    pub snapshot: &'a Value,
    /// Auth posture read from the connection handshake.
    pub auth: AuthPostureKind,
    /// A sampled error response, if one was captured.
    pub error_envelope: Option<&'a Value>,
}

/// Assess the security posture of a server from black-box inputs.
pub fn assess_posture(inputs: &PostureInputs) -> PostureReport {
    PostureReport {
        signals: vec![
            auth_signal(inputs.auth),
            integrity_signal(inputs.snapshot),
            transport_signal(),
            rate_limit_signal(),
            error_hygiene_signal(inputs.error_envelope),
        ],
        undetectable: vec![
            "server-side WAF behavior".to_string(),
            "payload-inspection pipelines (MCP-Guard style)".to_string(),
            "runtime intent verification".to_string(),
        ],
    }
}

fn auth_signal(auth: AuthPostureKind) -> PostureSignal {
    PostureSignal {
        name: "auth-posture".to_string(),
        method: "connection requirements at handshake".to_string(),
        layers: vec![McpDptLayer::Transport, McpDptLayer::Auth],
        detectability: Detectability::Detected,
        detail: auth.label().to_string(),
    }
}

fn integrity_signal(snapshot: &Value) -> PostureSignal {
    let detail = match versioned_tool_count(snapshot) {
        None => "no readable tool catalog to assess".to_string(),
        Some((0, total)) => {
            format!("no version stamp on {total} tool(s); no ETDI-style integrity anchor")
        }
        Some((versioned, total)) => {
            format!("{versioned} of {total} tool(s) carry a version stamp")
        }
    };
    PostureSignal {
        name: "tool-definition-integrity".to_string(),
        method: "whether the server version-stamps or signs definitions".to_string(),
        layers: vec![McpDptLayer::Tool, McpDptLayer::SupplyChain],
        // Partial: a black-box client sees version stamps but cannot verify a
        // cryptographic signature, so this never reads as fully detected.
        detectability: Detectability::Partial,
        detail,
    }
}

fn transport_signal() -> PostureSignal {
    PostureSignal {
        name: "transport-hygiene".to_string(),
        method: "transport hygiene probe (TLS, Origin, rebinding)".to_string(),
        layers: vec![McpDptLayer::Transport],
        detectability: Detectability::RequiresActiveProbe,
        detail: "requires the active transport probe; not yet run".to_string(),
    }
}

fn rate_limit_signal() -> PostureSignal {
    PostureSignal {
        name: "rate-limiting".to_string(),
        method: "controlled burst probe".to_string(),
        layers: vec![McpDptLayer::Host, McpDptLayer::Transport],
        detectability: Detectability::RequiresActiveProbe,
        detail: "requires the active burst probe; not yet run".to_string(),
    }
}

fn error_hygiene_signal(envelope: Option<&Value>) -> PostureSignal {
    let (detectability, detail) = match envelope {
        None => (
            Detectability::RequiresActiveProbe,
            "no error sample captured; trigger an error to assess".to_string(),
        ),
        Some(env) => match error_envelope_leak(env) {
            Some(leak) => (Detectability::Detected, format!("leaks {leak}")),
            None => (
                Detectability::Detected,
                "error envelope carries no stack trace or obvious secret".to_string(),
            ),
        },
    };
    PostureSignal {
        name: "error-hygiene".to_string(),
        method: "inspect error envelopes for stack traces or leaked secrets".to_string(),
        layers: vec![McpDptLayer::Server],
        detectability,
        detail,
    }
}

/// Count tools that carry a version stamp. Returns `(versioned, total)`, or
/// `None` when there is no readable tool array. Mirrors the SEC-016 anchor:
/// a top-level `version` or a `version` inside `_meta`.
fn versioned_tool_count(snapshot: &Value) -> Option<(usize, usize)> {
    let tools = snapshot
        .get("tools")
        .and_then(|t| t.as_array())
        .or_else(|| {
            snapshot
                .get("result")
                .and_then(|r| r.get("tools"))
                .and_then(|t| t.as_array())
        })?;
    let versioned = tools
        .iter()
        .filter(|tool| {
            tool.get("version").is_some()
                || tool.get("_meta").and_then(|m| m.get("version")).is_some()
        })
        .count();
    Some((versioned, tools.len()))
}

/// Look for a stack trace or an obvious secret in a serialized error envelope.
/// Returns a short label for what leaked, or `None` when it looks clean. This
/// is a heuristic: it catches the common leaks, not every possible one.
fn error_envelope_leak(envelope: &Value) -> Option<&'static str> {
    let text = envelope.to_string();
    let stack_markers = [
        "Traceback (most recent call last)",
        "panicked at",
        "\\n    at ",
        ".java:",
        ".rs:",
        ".py\", line ",
    ];
    if stack_markers.iter().any(|m| text.contains(m)) {
        return Some("a stack trace");
    }
    let secret_markers = ["-----BEGIN", "AKIA", "sk-", "Bearer ey"];
    if secret_markers.iter().any(|m| text.contains(m)) {
        return Some("a possible secret");
    }
    None
}

/// Render a posture report as a human-readable block for a scorecard or CI log.
pub fn render_pretty(report: &PostureReport) -> String {
    let mut out = String::from("security posture:\n");
    for s in &report.signals {
        let layers = s
            .layers
            .iter()
            .map(|l| l.as_str())
            .collect::<Vec<_>>()
            .join("/");
        out.push_str(&format!(
            "  {:<26} [{}] {}: {}\n",
            s.name,
            layers,
            s.detectability.as_str(),
            s.detail
        ));
    }
    out.push_str("  cannot detect black-box:\n");
    for item in &report.undetectable {
        out.push_str(&format!("    - {item}\n"));
    }
    out
}

/// Render a posture report as pretty-printed JSON for the scorecard.
pub fn render_json(report: &PostureReport) -> serde_json::Result<String> {
    serde_json::to_string_pretty(report)
}

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

    fn inputs_with<'a>(
        snapshot: &'a Value,
        auth: AuthPostureKind,
        err: Option<&'a Value>,
    ) -> PostureInputs<'a> {
        PostureInputs {
            snapshot,
            auth,
            error_envelope: err,
        }
    }

    #[test]
    fn assess_emits_all_five_signals_and_the_undetectable_list() {
        let snapshot = json!({ "tools": [{ "name": "a" }] });
        let report = assess_posture(&inputs_with(&snapshot, AuthPostureKind::OAuth, None));
        let names: Vec<&str> = report.signals.iter().map(|s| s.name.as_str()).collect();
        assert_eq!(
            names,
            vec![
                "auth-posture",
                "tool-definition-integrity",
                "transport-hygiene",
                "rate-limiting",
                "error-hygiene",
            ]
        );
        assert_eq!(report.undetectable.len(), 3);
    }

    #[test]
    fn unprobed_signals_say_so_rather_than_passing() {
        let snapshot = json!({ "tools": [] });
        let report = assess_posture(&inputs_with(&snapshot, AuthPostureKind::None, None));
        let transport = report
            .signals
            .iter()
            .find(|s| s.name == "transport-hygiene")
            .expect("transport signal present");
        assert_eq!(transport.detectability, Detectability::RequiresActiveProbe);
        assert!(transport.detail.contains(""));
    }

    #[test]
    fn auth_posture_records_the_handshake_reading() {
        let snapshot = json!({});
        let report = assess_posture(&inputs_with(&snapshot, AuthPostureKind::HeaderBearer, None));
        let auth = &report.signals[0];
        assert_eq!(auth.name, "auth-posture");
        assert_eq!(auth.detectability, Detectability::Detected);
        assert_eq!(auth.detail, "header bearer token");
    }

    #[test]
    fn integrity_reads_version_stamps_from_the_catalog() {
        let stamped = json!({ "tools": [
            { "name": "a", "version": "1.0.0" },
            { "name": "b", "_meta": { "version": "2" } },
            { "name": "c" }
        ]});
        let report = assess_posture(&inputs_with(&stamped, AuthPostureKind::None, None));
        let integrity = report
            .signals
            .iter()
            .find(|s| s.name == "tool-definition-integrity")
            .expect("integrity signal present");
        assert_eq!(integrity.detectability, Detectability::Partial);
        assert!(integrity.detail.contains("2 of 3"));
    }

    #[test]
    fn error_hygiene_flags_a_stack_trace() {
        let snapshot = json!({});
        let leak = json!({ "error": { "message": "boom", "data": "panicked at src/lib.rs:42" } });
        let report = assess_posture(&inputs_with(&snapshot, AuthPostureKind::None, Some(&leak)));
        let hygiene = report
            .signals
            .iter()
            .find(|s| s.name == "error-hygiene")
            .expect("error-hygiene signal present");
        assert_eq!(hygiene.detectability, Detectability::Detected);
        assert!(hygiene.detail.contains("stack trace"));
    }

    #[test]
    fn error_hygiene_passes_a_clean_envelope() {
        let snapshot = json!({});
        let clean = json!({ "error": { "code": -32000, "message": "invalid params" } });
        let report = assess_posture(&inputs_with(&snapshot, AuthPostureKind::None, Some(&clean)));
        let hygiene = report
            .signals
            .iter()
            .find(|s| s.name == "error-hygiene")
            .expect("error-hygiene signal present");
        assert!(hygiene.detail.contains("no stack trace"));
    }

    #[test]
    fn pretty_lists_signals_and_the_undetectable_block() {
        let snapshot = json!({ "tools": [{ "name": "a" }] });
        let report = assess_posture(&inputs_with(&snapshot, AuthPostureKind::OAuth, None));
        let text = render_pretty(&report);
        assert!(text.contains("auth-posture"));
        assert!(text.contains("[transport/auth]"));
        assert!(text.contains("cannot detect black-box:"));
        assert!(text.contains("runtime intent verification"));
    }

    #[test]
    fn json_round_trips_the_report() {
        let snapshot = json!({ "tools": [{ "name": "a" }] });
        let report = assess_posture(&inputs_with(&snapshot, AuthPostureKind::OAuth, None));
        let encoded = render_json(&report).expect("serialize");
        let decoded: PostureReport = serde_json::from_str(&encoded).expect("deserialize");
        assert_eq!(decoded, report);
    }
}
