//! Portable signed evidence artifact (WOR-1080).
//!
//! Nearly every field a registry needs to trust a run already exists on
//! [`RunMetadata`]. This module aggregates those fields into one small,
//! schema-stable [`EvidenceArtifact`] a registry can ingest, plus the
//! anti-gaming [`verify_evidence`] checks that make the artifact worth
//! trusting. No registry publishes a schema today, so we define the minimal
//! one and let registries adopt it; this is deliberately not a public
//! directory.
//!
//! ## What this module owns and what it does not
//!
//! - **Owns**: the artifact shape, building it from a run's metadata, the
//!   canonical content digest, and the verifier that rejects stale, forked, or
//!   unsigned evidence. All pure and testable: the verifier takes the
//!   already-computed facts (is the commit an ancestor of HEAD, is the artifact
//!   within its max age) rather than reading the clock or shelling out to git
//!   itself, so the policy lives in one tested place.
//! - **Does not own**: the cryptographic signature. Signing reuses the release
//!   Sigstore/cosign path (GitHub OIDC keyless), driven from the CLI; this
//!   module records only whether a signature is present, mirroring how
//!   `sbom --verify` re-hashes rather than re-deriving trust.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::report::{RunMetadata, ServerPosture};

/// Schema identifier stamped on every artifact so a registry can route by
/// version. Bumped when the artifact shape changes incompatibly.
pub const EVIDENCE_SCHEMA_VERSION: &str = "mcptest.dev/evidence/v1";

/// One server's identity in the evidence: name, transport, and auth posture.
/// Carries no secret material, only the posture the run negotiated.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceServer {
    /// YAML server name the suite referenced.
    pub name: String,
    /// Transport family (`stdio`, `streamable_http`, ...).
    pub transport: String,
    /// Authentication scheme (`none`, `bearer`, `oauth2`, ...).
    pub auth: String,
}

impl From<&ServerPosture> for EvidenceServer {
    fn from(p: &ServerPosture) -> Self {
        Self {
            name: p.name.clone(),
            transport: p.transport.clone(),
            auth: p.auth.clone(),
        }
    }
}

/// Source-control provenance for the run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceSource {
    /// Git remote URL, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// Git branch, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
    /// Full git commit SHA, when known. The verifier binds to this.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commit_sha: Option<String>,
}

/// The grade signals a registry surfaces as a badge.
///
/// Test counts always come from the run summary. The security severity counts,
/// compliance tier, and TDQS tool scores are present only when the run folded
/// in those checks, so each is optional and omitted when absent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvidenceGrades {
    /// Total test rows in the run.
    pub tests_total: usize,
    /// Passing test rows.
    pub tests_passed: usize,
    /// Failing test rows.
    pub tests_failed: usize,
    /// Security finding counts keyed by severity (`critical`, `high`, ...),
    /// folded in from a `mcptest security --format json` report.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub security_severity_counts: Option<BTreeMap<String, usize>>,
    /// Compliance tier label (for example `A`), when a compliance grade ran.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub compliance_tier: Option<String>,
    /// TDQS mean tool score, when a `tool_quality:` check ran.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tdqs_mean_tool_score: Option<f64>,
    /// TDQS minimum tool score, when a `tool_quality:` check ran.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tdqs_min_tool_score: Option<f64>,
}

/// The portable evidence artifact: what a registry ingests to trust a run.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvidenceArtifact {
    /// Schema id, always [`EVIDENCE_SCHEMA_VERSION`] for artifacts this build
    /// emits.
    pub schema_version: String,
    /// Per-server identity and posture.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub server_identity: Vec<EvidenceServer>,
    /// MCP protocol version the run negotiated.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub spec_version: Option<String>,
    /// `sha256:` digest over the resolved config and on-disk corpus.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub corpus_version: Option<String>,
    /// Source-control provenance.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<EvidenceSource>,
    /// RFC 3339 timestamp the run ended (or started). The registry enforces a
    /// max age against this to defeat stale evidence.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub generated_at: Option<String>,
    /// Digest of the cassette the run replayed, from the enterprise registry's
    /// publish acknowledgement. Absent for a non-replayed or OSS run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cassette_hash: Option<String>,
    /// The grade signals.
    pub grades: EvidenceGrades,
    /// Whether the run is byte-reproducible (the `sbom --verify` /
    /// `SOURCE_DATE_EPOCH` parity signal).
    pub reproducible: bool,
    /// True when the run's origin cannot be attested (no git commit, or a
    /// private server). Such evidence is accepted but flagged, never silently
    /// rejected, so a private deployment can still publish a badge.
    pub unverifiable_origin: bool,
}

/// Build the evidence artifact from a run's metadata and summary.
///
/// `tests` is `(total, passed, failed)` from the run summary. `security_counts`
/// folds in a security report's severity histogram when one was supplied;
/// `reproducible` is the build-reproducibility signal. The artifact's
/// `unverifiable_origin` is set when no commit SHA is known, which is the
/// registry's cue to badge the run as unattested rather than reject it.
pub fn build_evidence(
    metadata: &RunMetadata,
    tests: (usize, usize, usize),
    security_counts: Option<BTreeMap<String, usize>>,
    reproducible: bool,
) -> EvidenceArtifact {
    let source =
        if metadata.repo.is_some() || metadata.branch.is_some() || metadata.commit_sha.is_some() {
            Some(EvidenceSource {
                repo: metadata.repo.clone(),
                branch: metadata.branch.clone(),
                commit_sha: metadata.commit_sha.clone(),
            })
        } else {
            None
        };
    EvidenceArtifact {
        schema_version: EVIDENCE_SCHEMA_VERSION.to_string(),
        server_identity: metadata.servers.iter().map(EvidenceServer::from).collect(),
        spec_version: metadata.mcp_protocol_version.clone(),
        corpus_version: metadata.config_hash.clone(),
        source,
        generated_at: metadata
            .ended_at
            .clone()
            .or_else(|| metadata.started_at.clone()),
        cassette_hash: None,
        grades: EvidenceGrades {
            tests_total: tests.0,
            tests_passed: tests.1,
            tests_failed: tests.2,
            security_severity_counts: security_counts,
            compliance_tier: None,
            tdqs_mean_tool_score: None,
            tdqs_min_tool_score: None,
        },
        reproducible,
        unverifiable_origin: metadata.commit_sha.is_none(),
    }
}

/// SHA-256 digest of an artifact's canonical JSON, formatted `sha256:<hex>`.
///
/// The struct's serde field order is stable, so this is reproducible across
/// machines for the same artifact, which is what binds a detached signature to
/// exactly these bytes.
pub fn canonical_digest(artifact: &EvidenceArtifact) -> String {
    let bytes = serde_json::to_vec(artifact).unwrap_or_default();
    let mut hasher = Sha256::new();
    hasher.update(&bytes);
    format!("sha256:{:x}", hasher.finalize())
}

/// The already-computed facts the verifier weighs. The CLI fills these in
/// (reading the clock, shelling out to git, looking for a signature); keeping
/// them as inputs makes the trust policy a pure, tested function.
#[derive(Debug, Clone, Copy)]
pub struct VerifyInput<'a> {
    /// The artifact under inspection.
    pub artifact: &'a EvidenceArtifact,
    /// Whether the artifact's `generated_at` is within the requested max age.
    /// `None` means no max age was requested.
    pub within_max_age: Option<bool>,
    /// Whether the artifact's commit SHA is an ancestor of the claimed HEAD.
    /// `None` means it could not be determined (not in a git repo).
    pub commit_is_ancestor: Option<bool>,
    /// Whether a signature accompanies the artifact.
    pub signature_present: bool,
    /// Whether the caller requires the artifact to be signed.
    pub require_signed: bool,
    /// Cryptographic verification result for the signature, when attempted:
    /// `Some(true)` verified, `Some(false)` cosign rejected it, `None` not
    /// attempted (no cert/identity supplied, or cosign absent). A `Some(false)`
    /// always fails: a present-but-invalid signature is worse than none.
    pub signature_valid: Option<bool>,
}

/// The verdict plus the reasons an artifact was rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyOutcome {
    /// True when every check passed.
    pub accepted: bool,
    /// One human-readable line per failed check. Empty on acceptance.
    pub reasons: Vec<String>,
}

/// Decide whether to trust an evidence artifact.
///
/// Rejects three ways, matching the anti-gaming rules: stale evidence (past its
/// max age), forked-repo identity theft (a commit SHA that is not an ancestor of
/// the claimed HEAD), and unsigned evidence when a signature was required. An
/// artifact that openly declares `unverifiable_origin` is not failed for a
/// missing ancestry result, since it never claimed an attestable origin.
pub fn verify_evidence(input: &VerifyInput) -> VerifyOutcome {
    let mut reasons = Vec::new();
    if input.within_max_age == Some(false) {
        reasons.push("evidence is older than the allowed maximum age (stale)".to_string());
    }
    if input.within_max_age.is_some() && input.artifact.generated_at.is_none() {
        reasons.push("evidence carries no generated_at to age-check".to_string());
    }
    if input.commit_is_ancestor == Some(false) {
        reasons.push("commit_sha is not an ancestor of HEAD (forked-repo identity)".to_string());
    }
    if input.require_signed && !input.signature_present {
        reasons.push("evidence is unsigned".to_string());
    }
    // A present signature that cosign actively rejected always fails, even
    // without --require-signed: a bad signature is worse than none (WOR-1510).
    if input.signature_present && input.signature_valid == Some(false) {
        reasons.push(
            "signature failed cryptographic verification (cosign verify-blob rejected it)"
                .to_string(),
        );
    }
    VerifyOutcome {
        accepted: reasons.is_empty(),
        reasons,
    }
}

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

    fn metadata() -> RunMetadata {
        let mut m = RunMetadata {
            run_id: "01J0".into(),
            started_at: Some("2026-06-02T10:00:00Z".into()),
            ended_at: Some("2026-06-02T10:01:00Z".into()),
            duration_ms: Some(60_000),
            mcptest_version: "1.0.0".into(),
            mcp_protocol_version: Some("2025-03-26".into()),
            repo: Some("git@github.com:acme/server.git".into()),
            branch: Some("main".into()),
            commit_sha: Some("a".repeat(40)),
            commit_message: Some("ship it".into()),
            hostname: None,
            runner_environment: "local".into(),
            os: "macos".into(),
            arch: "aarch64".into(),
            config_hash: Some("sha256:beef".into()),
            profile: None,
            servers: vec![ServerPosture {
                name: "weather".into(),
                transport: "stdio".into(),
                auth: "none".into(),
            }],
        };
        m.duration_ms = Some(60_000);
        m
    }

    #[test]
    fn build_maps_metadata_fields_onto_the_artifact() {
        let art = build_evidence(&metadata(), (10, 9, 1), None, true);
        assert_eq!(art.schema_version, EVIDENCE_SCHEMA_VERSION);
        assert_eq!(art.spec_version.as_deref(), Some("2025-03-26"));
        assert_eq!(art.corpus_version.as_deref(), Some("sha256:beef"));
        assert_eq!(art.generated_at.as_deref(), Some("2026-06-02T10:01:00Z"));
        assert_eq!(art.server_identity[0].name, "weather");
        assert_eq!(art.grades.tests_total, 10);
        assert_eq!(art.grades.tests_failed, 1);
        assert!(!art.unverifiable_origin);
    }

    #[test]
    fn missing_commit_flags_unverifiable_origin() {
        let mut m = metadata();
        m.commit_sha = None;
        m.repo = None;
        m.branch = None;
        let art = build_evidence(&m, (1, 1, 0), None, false);
        assert!(art.unverifiable_origin);
        assert!(art.source.is_none());
    }

    #[test]
    fn digest_is_stable_for_the_same_artifact() {
        let art = build_evidence(&metadata(), (1, 1, 0), None, true);
        assert_eq!(canonical_digest(&art), canonical_digest(&art.clone()));
        assert!(canonical_digest(&art).starts_with("sha256:"));
    }

    fn accepted(art: &EvidenceArtifact) -> VerifyInput<'_> {
        VerifyInput {
            artifact: art,
            within_max_age: Some(true),
            commit_is_ancestor: Some(true),
            signature_present: true,
            require_signed: false,
            signature_valid: None,
        }
    }

    #[test]
    fn verify_rejects_a_signature_that_failed_cryptographic_verification() {
        let art = build_evidence(&metadata(), (1, 1, 0), None, true);
        let outcome = verify_evidence(&VerifyInput {
            signature_valid: Some(false),
            ..accepted(&art)
        });
        assert!(!outcome.accepted);
        assert!(outcome
            .reasons
            .iter()
            .any(|r| r.contains("failed cryptographic verification")));
    }

    #[test]
    fn verify_accepts_a_fresh_in_ancestry_artifact() {
        let art = build_evidence(&metadata(), (1, 1, 0), None, true);
        assert!(verify_evidence(&accepted(&art)).accepted);
    }

    #[test]
    fn verify_rejects_stale_evidence() {
        let art = build_evidence(&metadata(), (1, 1, 0), None, true);
        let outcome = verify_evidence(&VerifyInput {
            within_max_age: Some(false),
            ..accepted(&art)
        });
        assert!(!outcome.accepted);
        assert!(outcome.reasons.iter().any(|r| r.contains("stale")));
    }

    #[test]
    fn verify_rejects_forked_identity() {
        let art = build_evidence(&metadata(), (1, 1, 0), None, true);
        let outcome = verify_evidence(&VerifyInput {
            commit_is_ancestor: Some(false),
            ..accepted(&art)
        });
        assert!(!outcome.accepted);
        assert!(outcome.reasons.iter().any(|r| r.contains("ancestor")));
    }

    #[test]
    fn verify_rejects_unsigned_when_required() {
        let art = build_evidence(&metadata(), (1, 1, 0), None, true);
        let outcome = verify_evidence(&VerifyInput {
            signature_present: false,
            require_signed: true,
            ..accepted(&art)
        });
        assert!(!outcome.accepted);
        assert!(outcome.reasons.iter().any(|r| r.contains("unsigned")));
    }
}
