//! Evidence-grade governance pack (WOR-1412).
//!
//! `mcptest evidence` already emits a portable, signable [`EvidenceArtifact`]
//! from a run's metadata. A release manager attaching evidence to change
//! control wants one artifact that also carries the coverage, fault-coverage,
//! and cost sections (plus any sidecar reports) without stitching files
//! together by hand. This module wraps the existing artifact in a versioned
//! [`EvidencePack`] envelope, reusing the artifact and its signing path rather
//! than forking them. The diff lives in [`super::evidence_pack_diff`].

use std::collections::BTreeMap;

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

use crate::coverage::CoverageReport;
use crate::evidence::EvidenceArtifact;
use crate::posture::PostureReport;
use crate::report::RunCost;
use crate::runtime_faults::FaultCoverageReport;

/// Schema id stamped on every pack so a consumer can route by version.
pub const EVIDENCE_PACK_SCHEMA_VERSION: &str = "mcptest.dev/evidence-pack/v1";

/// The typed sections folded into a pack. Each is optional and omitted when the
/// run did not produce it, so the pack carries exactly what is available.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EvidencePackSections {
    /// Cost breakdown from the run report.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cost: Option<RunCost>,
    /// Tool/argument/error-path coverage, from `mcptest coverage --format json`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub coverage: Option<CoverageReport>,
    /// Runtime fault family coverage, from `mcptest fault-coverage --format json`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fault_coverage: Option<FaultCoverageReport>,
    /// Runtime/gateway posture, from `mcptest posture --format json`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub posture: Option<PostureReport>,
    /// Named sidecar reports folded in verbatim (for example a model-compat
    /// diff or a jury report). Kept as raw JSON so the pack stays
    /// forward-compatible with sections this build does not model yet.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub attachments: BTreeMap<String, Value>,
}

impl EvidencePackSections {
    /// `true` when no section is present.
    pub fn is_empty(&self) -> bool {
        self.cost.is_none()
            && self.coverage.is_none()
            && self.fault_coverage.is_none()
            && self.posture.is_none()
            && self.attachments.is_empty()
    }
}

/// A versioned governance pack: the evidence artifact plus the available
/// quality sections, with a content digest a signature can bind to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvidencePack {
    /// Schema id, always [`EVIDENCE_PACK_SCHEMA_VERSION`] for packs this build
    /// emits.
    pub schema_version: String,
    /// RFC 3339 timestamp the pack was assembled.
    pub generated_at: String,
    /// The reused portable evidence artifact (provenance, grades, posture).
    pub artifact: EvidenceArtifact,
    /// The folded quality sections.
    #[serde(default, skip_serializing_if = "EvidencePackSections::is_empty")]
    pub sections: EvidencePackSections,
    /// `sha256:` digest over the pack's canonical JSON (computed with this
    /// field blank), the bytes a detached signature covers.
    pub digest: String,
}

/// Assemble a pack from an artifact and its sections, stamping the schema, the
/// timestamp, and the content digest.
pub fn build_pack(
    artifact: EvidenceArtifact,
    sections: EvidencePackSections,
    generated_at: String,
) -> EvidencePack {
    let mut pack = EvidencePack {
        schema_version: EVIDENCE_PACK_SCHEMA_VERSION.to_string(),
        generated_at,
        artifact,
        sections,
        digest: String::new(),
    };
    pack.digest = pack_digest(&pack);
    pack
}

/// SHA-256 digest of a pack's canonical JSON, formatted `sha256:<hex>`.
///
/// Computed with the `digest` field blanked so the value is reproducible and a
/// signature binds to exactly the rest of the pack, the same trick
/// `evidence::canonical_digest` uses for the artifact.
pub fn pack_digest(pack: &EvidencePack) -> String {
    let mut blanked = pack.clone();
    blanked.digest = String::new();
    let bytes = serde_json::to_vec(&blanked).unwrap_or_default();
    let mut hasher = Sha256::new();
    hasher.update(&bytes);
    format!("sha256:{:x}", hasher.finalize())
}

/// The comparable scalar signals a pack carries, extracted for the diff. Keeps
/// the diff a pure function over flat numbers rather than walking two trees.
#[derive(Debug, Clone, PartialEq)]
pub struct PackSignals {
    /// Passing test rows.
    pub tests_passed: usize,
    /// Failing test rows.
    pub tests_failed: usize,
    /// Total security findings across all severities.
    pub security_total: usize,
    /// Security findings at `critical` or `high` severity.
    pub security_high: usize,
    /// Compliance tier label, when graded.
    pub compliance_tier: Option<String>,
    /// Tool coverage percentage, when a coverage section is present.
    pub coverage_pct: Option<f64>,
    /// Runtime fault family coverage percentage, when present.
    pub fault_coverage_pct: Option<f64>,
    /// Total run cost in USD, when a cost section is present.
    pub cost_usd: Option<f64>,
}

/// Pull the comparable signals out of a pack.
pub fn extract_signals(pack: &EvidencePack) -> PackSignals {
    let grades = &pack.artifact.grades;
    let counts = grades.security_severity_counts.as_ref();
    let security_total = counts.map(|c| c.values().sum()).unwrap_or(0);
    let security_high = counts
        .map(|c| c.get("critical").copied().unwrap_or(0) + c.get("high").copied().unwrap_or(0))
        .unwrap_or(0);
    PackSignals {
        tests_passed: grades.tests_passed,
        tests_failed: grades.tests_failed,
        security_total,
        security_high,
        compliance_tier: grades.compliance_tier.clone(),
        coverage_pct: pack.sections.coverage.as_ref().map(|c| c.tool_coverage_pct),
        fault_coverage_pct: pack
            .sections
            .fault_coverage
            .as_ref()
            .map(|f| f.coverage_pct),
        cost_usd: pack.sections.cost.as_ref().map(|c| c.total_cost_usd),
    }
}

/// Render a pack as Markdown for human review: provenance and grades up front,
/// then the available sections.
pub fn render_pack_markdown(pack: &EvidencePack) -> String {
    let a = &pack.artifact;
    let g = &a.grades;
    let mut out = String::new();
    out.push_str("# Evidence pack\n\n");
    out.push_str(&format!("- Schema: `{}`\n", pack.schema_version));
    out.push_str(&format!("- Generated: {}\n", pack.generated_at));
    out.push_str(&format!("- Digest: `{}`\n", pack.digest));
    if let Some(src) = &a.source {
        if let Some(commit) = &src.commit_sha {
            out.push_str(&format!("- Commit: `{commit}`\n"));
        }
    }
    if let Some(spec) = &a.spec_version {
        out.push_str(&format!("- MCP spec: {spec}\n"));
    }
    out.push_str(&format!(
        "- Origin: {}\n",
        if a.unverifiable_origin {
            "unattested (no commit)"
        } else {
            "attested"
        }
    ));

    out.push_str("\n## Grades\n\n");
    out.push_str(&format!(
        "- Tests: {} passed, {} failed of {}\n",
        g.tests_passed, g.tests_failed, g.tests_total
    ));
    if let Some(tier) = &g.compliance_tier {
        out.push_str(&format!("- Compliance tier: {tier}\n"));
    }
    if let Some(counts) = &g.security_severity_counts {
        let rendered: Vec<String> = counts.iter().map(|(k, v)| format!("{v} {k}")).collect();
        out.push_str(&format!("- Security findings: {}\n", rendered.join(", ")));
    }
    push_sections_markdown(&mut out, &pack.sections);
    out
}

/// Append the section roll-ups to a pack's Markdown.
fn push_sections_markdown(out: &mut String, sections: &EvidencePackSections) {
    if let Some(cov) = &sections.coverage {
        out.push_str(&format!(
            "\n## Coverage\n\n- Tool coverage: {:.0}%\n- Argument coverage: {:.0}%\n",
            cov.tool_coverage_pct, cov.arg_coverage_pct
        ));
    }
    if let Some(fc) = &sections.fault_coverage {
        out.push_str(&format!(
            "\n## Runtime fault coverage\n\n- {:.0}% of applicable faults exercised; {} of {} families covered\n",
            fc.coverage_pct, fc.covered_families, fc.applicable_families
        ));
    }
    if let Some(p) = &sections.posture {
        out.push_str(&format!(
            "\n## Runtime posture\n\n- {} pass, {} warn, {} fail, {} unknown ({:.0}% of scored checks pass)\n",
            p.passed, p.warned, p.failed, p.unknown, p.score_pct()
        ));
    }
    if let Some(cost) = &sections.cost {
        out.push_str(&format!(
            "\n## Cost\n\n- Total: ${:.4}\n",
            cost.total_cost_usd
        ));
    }
    if !sections.attachments.is_empty() {
        let names: Vec<&str> = sections.attachments.keys().map(String::as_str).collect();
        out.push_str(&format!("\n## Attachments\n\n- {}\n", names.join(", ")));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::evidence::{EvidenceArtifact, EvidenceGrades, EVIDENCE_SCHEMA_VERSION};

    fn artifact() -> EvidenceArtifact {
        EvidenceArtifact {
            schema_version: EVIDENCE_SCHEMA_VERSION.to_string(),
            server_identity: vec![],
            spec_version: Some("2025-06-18".to_string()),
            corpus_version: None,
            source: None,
            generated_at: Some("2026-06-21T00:00:00Z".to_string()),
            cassette_hash: None,
            grades: EvidenceGrades {
                tests_total: 10,
                tests_passed: 9,
                tests_failed: 1,
                security_severity_counts: None,
                compliance_tier: Some("A".to_string()),
                tdqs_mean_tool_score: None,
                tdqs_min_tool_score: None,
            },
            reproducible: true,
            unverifiable_origin: true,
        }
    }

    #[test]
    fn build_pack_stamps_schema_and_digest() {
        let pack = build_pack(
            artifact(),
            EvidencePackSections::default(),
            "2026-06-21T00:00:00Z".to_string(),
        );
        assert_eq!(pack.schema_version, EVIDENCE_PACK_SCHEMA_VERSION);
        assert!(pack.digest.starts_with("sha256:"));
        // The stored digest matches a recompute with the field blanked.
        assert_eq!(pack.digest, pack_digest(&pack));
    }

    #[test]
    fn digest_is_independent_of_the_digest_field() {
        let mut pack = build_pack(
            artifact(),
            EvidencePackSections::default(),
            "2026-06-21T00:00:00Z".to_string(),
        );
        let original = pack.digest.clone();
        pack.digest = "sha256:tampered".to_string();
        // Recomputing blanks the field, so the value is unchanged.
        assert_eq!(pack_digest(&pack), original);
    }

    #[test]
    fn pack_round_trips_through_json() {
        let pack = build_pack(
            artifact(),
            EvidencePackSections::default(),
            "2026-06-21T00:00:00Z".to_string(),
        );
        let json = serde_json::to_string(&pack).unwrap();
        let back: EvidencePack = serde_json::from_str(&json).unwrap();
        assert_eq!(back, pack);
    }

    #[test]
    fn extract_signals_reads_grades_and_sections() {
        let pack = build_pack(
            artifact(),
            EvidencePackSections::default(),
            "2026-06-21T00:00:00Z".to_string(),
        );
        let signals = extract_signals(&pack);
        assert_eq!(signals.tests_passed, 9);
        assert_eq!(signals.tests_failed, 1);
        assert_eq!(signals.compliance_tier.as_deref(), Some("A"));
        assert_eq!(signals.coverage_pct, None);
    }

    #[test]
    fn markdown_leads_with_grades() {
        let pack = build_pack(
            artifact(),
            EvidencePackSections::default(),
            "2026-06-21T00:00:00Z".to_string(),
        );
        let md = render_pack_markdown(&pack);
        assert!(md.contains("# Evidence pack"));
        assert!(md.contains("9 passed, 1 failed"));
        assert!(md.contains("Compliance tier: A"));
    }
}
