//! Handler for `mcptest evidence`.
//!
//! Emits a portable evidence artifact from a run report, optionally signing it
//! with the release Sigstore cosign path, and verifies one. The artifact shape
//! and the trust policy live in `mcptest_core::evidence`; this handler does the
//! IO: reading the report, folding in a security report's severity counts,
//! shelling out to cosign and git, and computing the artifact's age against the
//! clock so the pure verifier only weighs already-decided facts.

use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use mcptest_core::evidence::{build_evidence, verify_evidence, EvidenceArtifact, VerifyInput};
use mcptest_core::evidence_pack::{
    build_pack, render_pack_markdown, EvidencePack, EvidencePackSections,
};
use mcptest_core::evidence_pack_diff::{diff_packs, render_diff_markdown, render_diff_text};
use mcptest_core::report::Report;
use serde_json::Value;

use crate::cli::args::{
    DiffOutFormat, EvidenceArgs, EvidenceCommand, EvidenceDiffArgs, EvidencePackArgs,
    EvidenceVerifyArgs, PackFormat,
};

/// Handle `mcptest evidence <report.json>` and its subcommands.
///
/// Exit code 2 when an input cannot be read or parsed (or `--sign` cannot run),
/// 1 when verification rejects the artifact or a diff regresses, otherwise 0.
pub(crate) fn evidence_command(args: EvidenceArgs) -> Result<i32> {
    match args.command {
        Some(EvidenceCommand::Verify(verify_args)) => return verify(verify_args),
        Some(EvidenceCommand::Pack(pack_args)) => return pack(pack_args),
        Some(EvidenceCommand::Diff(diff_args)) => return diff(diff_args),
        None => {}
    }
    let Some(report_path) = &args.report else {
        eprintln!("mcptest evidence: a run report path or a subcommand is required");
        return Ok(2);
    };
    emit(report_path, &args)
}

/// Assemble a governance pack from a run report plus the available sections.
fn pack(args: EvidencePackArgs) -> Result<i32> {
    let report: Report = match read_json(&args.report) {
        Ok(v) => match serde_json::from_value(v) {
            Ok(r) => r,
            Err(e) => {
                eprintln!(
                    "mcptest evidence pack: {:?} is not a run report: {e}",
                    args.report
                );
                return Ok(2);
            }
        },
        Err(code) => return Ok(code),
    };
    let Some(metadata) = &report.metadata else {
        eprintln!(
            "mcptest evidence pack: the report carries no run metadata; re-run `mcptest run --format json`"
        );
        return Ok(2);
    };

    let security_counts = match &args.security {
        Some(path) => match read_json(path) {
            Ok(v) => Some(security_severity_counts(&v)),
            Err(code) => return Ok(code),
        },
        None => None,
    };

    let tests = (
        report.summary.total,
        report.summary.passed,
        report.summary.failed,
    );
    let artifact = build_evidence(metadata, tests, security_counts, args.reproducible);

    let sections = match collect_sections(&args, &report) {
        Ok(sections) => sections,
        Err(code) => return Ok(code),
    };
    // A governance pack is stamped with its assembly time; the run's own
    // timestamps live inside the embedded artifact's metadata.
    let pack = build_pack(artifact, sections, now_rfc3339());

    write_pack(&pack, &args)
}

/// Read the optional section inputs into the typed pack sections.
fn collect_sections(
    args: &EvidencePackArgs,
    report: &Report,
) -> std::result::Result<EvidencePackSections, i32> {
    let mut sections = EvidencePackSections {
        cost: report.cost,
        ..Default::default()
    };
    if let Some(path) = &args.coverage {
        let value = read_json(path)?;
        sections.coverage = Some(serde_json::from_value(value).map_err(|e| {
            eprintln!("mcptest evidence pack: {path:?} is not a coverage report: {e}");
            2
        })?);
    }
    if let Some(path) = &args.fault_coverage {
        let value = read_json(path)?;
        sections.fault_coverage = Some(serde_json::from_value(value).map_err(|e| {
            eprintln!("mcptest evidence pack: {path:?} is not a fault-coverage report: {e}");
            2
        })?);
    }
    if let Some(path) = &args.posture {
        let value = read_json(path)?;
        sections.posture = Some(serde_json::from_value(value).map_err(|e| {
            eprintln!("mcptest evidence pack: {path:?} is not a posture report: {e}");
            2
        })?);
    }
    for spec in &args.sections {
        let Some((name, path)) = spec.split_once('=') else {
            eprintln!("mcptest evidence pack: --section expects NAME=PATH, got `{spec}`");
            return Err(2);
        };
        let value = read_json(std::path::Path::new(path))?;
        sections.attachments.insert(name.to_string(), value);
    }
    Ok(sections)
}

/// Serialize the pack to the requested sink and format, signing when asked.
fn write_pack(pack: &EvidencePack, args: &EvidencePackArgs) -> Result<i32> {
    let rendered = match args.format {
        PackFormat::Markdown => render_pack_markdown(pack),
        PackFormat::Json => serde_json::to_string_pretty(pack).context("serializing pack")?,
    };
    match &args.output {
        Some(path) => {
            super::overwrite::ensure_overwritable(path, args.force)?;
            std::fs::write(path, format!("{rendered}\n"))
                .with_context(|| format!("writing pack to {path:?}"))?;
            if args.sign {
                return sign_with_cosign(path);
            }
        }
        None => {
            if args.sign {
                eprintln!("mcptest evidence pack: --sign requires --output (cosign signs a file)");
                return Ok(2);
            }
            println!("{rendered}");
        }
    }
    Ok(0)
}

/// Diff two governance packs and gate on regressions.
fn diff(args: EvidenceDiffArgs) -> Result<i32> {
    let old: EvidencePack = match load_pack(&args.old) {
        Ok(p) => p,
        Err(code) => return Ok(code),
    };
    let new: EvidencePack = match load_pack(&args.new) {
        Ok(p) => p,
        Err(code) => return Ok(code),
    };
    let report = diff_packs(&old, &new);
    match args.format {
        DiffOutFormat::Json => {
            println!(
                "{}",
                serde_json::to_string_pretty(&report).context("serializing diff")?
            );
        }
        DiffOutFormat::Markdown => print!("{}", render_diff_markdown(&report)),
        DiffOutFormat::Pretty => print!("{}", render_diff_text(&report)),
    }
    if args.fail_on_regression && report.has_regression() {
        return Ok(1);
    }
    Ok(0)
}

/// Read and parse an evidence pack, mapping failures to exit code 2.
fn load_pack(path: &Path) -> std::result::Result<EvidencePack, i32> {
    let value = read_json(path)?;
    serde_json::from_value(value).map_err(|e| {
        eprintln!("mcptest evidence diff: {path:?} is not an evidence pack: {e}");
        2
    })
}

/// Build the evidence artifact from a run report and write it out, then sign it
/// when `--sign` is set.
fn emit(report_path: &Path, args: &EvidenceArgs) -> Result<i32> {
    let report: Report = match read_json(report_path) {
        Ok(v) => match serde_json::from_value(v) {
            Ok(r) => r,
            Err(e) => {
                eprintln!("mcptest evidence: {report_path:?} is not a run report: {e}");
                return Ok(2);
            }
        },
        Err(code) => return Ok(code),
    };
    let Some(metadata) = &report.metadata else {
        eprintln!(
            "mcptest evidence: the report carries no run metadata; re-run `mcptest run --format json`"
        );
        return Ok(2);
    };

    let security_counts = match &args.security {
        Some(path) => match read_json(path) {
            Ok(v) => Some(security_severity_counts(&v)),
            Err(code) => return Ok(code),
        },
        None => None,
    };

    let tests = (
        report.summary.total,
        report.summary.passed,
        report.summary.failed,
    );
    let artifact = build_evidence(metadata, tests, security_counts, args.reproducible);
    let json = serde_json::to_string_pretty(&artifact).context("serializing evidence")?;

    match &args.output {
        Some(path) => {
            super::overwrite::ensure_overwritable(path, args.force)?;
            std::fs::write(path, format!("{json}\n"))
                .with_context(|| format!("writing evidence to {path:?}"))?;
            if args.sign {
                return sign_with_cosign(path);
            }
        }
        None => {
            if args.sign {
                eprintln!("mcptest evidence: --sign requires --output (cosign signs a file)");
                return Ok(2);
            }
            println!("{json}");
        }
    }
    Ok(0)
}

/// Verify an evidence artifact, rejecting stale, forked, or unsigned evidence.
fn verify(args: EvidenceVerifyArgs) -> Result<i32> {
    let artifact: EvidenceArtifact = match read_json(&args.evidence) {
        Ok(v) => match serde_json::from_value(v) {
            Ok(a) => a,
            Err(e) => {
                eprintln!(
                    "mcptest evidence verify: {:?} is not an evidence artifact: {e}",
                    args.evidence
                );
                return Ok(2);
            }
        },
        Err(code) => return Ok(code),
    };

    // A signature is present when --signature points at a real file, or a
    // `<evidence>.sig` sits next to the artifact.
    let sig_path = args
        .signature
        .clone()
        .or_else(|| sibling_signature(&args.evidence));
    let signature_present = sig_path.as_deref().map(Path::exists).unwrap_or(false);

    // WOR-1510: cryptographically verify the signature when the caller supplied
    // an expected identity + issuer (cosign keyless). Without them, fall back to
    // the presence check and say so below, rather than implying verification.
    let cert_path = args
        .certificate
        .clone()
        .or_else(|| sibling_cert(&args.evidence));
    let signature_valid = match (
        sig_path.as_deref(),
        cert_path.as_deref(),
        args.certificate_identity.as_deref(),
        args.certificate_oidc_issuer.as_deref(),
    ) {
        (Some(sig), Some(cert), Some(identity), Some(issuer))
            if signature_present && cert.exists() =>
        {
            cosign_verify_blob(&args.evidence, sig, cert, identity, issuer)
        }
        _ => None,
    };

    let within_max_age = args
        .max_age
        .map(|budget| artifact_within_age(artifact.generated_at.as_deref(), budget));
    let commit_is_ancestor = artifact
        .source
        .as_ref()
        .and_then(|s| s.commit_sha.as_deref())
        .and_then(commit_is_ancestor_of_head);

    let outcome = verify_evidence(&VerifyInput {
        artifact: &artifact,
        within_max_age,
        commit_is_ancestor,
        signature_present,
        require_signed: args.require_signed,
        signature_valid,
    });

    if outcome.accepted {
        println!("evidence verify: accepted");
        match signature_valid {
            Some(true) => {
                println!("  signature: cryptographically verified (cosign verify-blob)")
            }
            None if signature_present => println!(
                "  note: signature present but NOT cryptographically verified; pass \
                 --certificate-identity + --certificate-oidc-issuer (and install cosign) to verify it"
            ),
            _ => {}
        }
        if artifact.unverifiable_origin {
            println!("  note: unverifiable_origin (no attestable commit); badge it as unattested");
        }
        Ok(0)
    } else {
        println!("evidence verify: rejected");
        for reason in &outcome.reasons {
            println!("  - {reason}");
        }
        Ok(1)
    }
}

/// Sign the artifact file with Sigstore cosign keyless, writing `<out>.sig` and
/// `<out>.cert`. The same `cosign sign-blob` invocation the release workflow
/// uses. Exit 2 when cosign is absent or the signature step fails, so an
/// unsigned artifact never reads as signed.
fn sign_with_cosign(out: &Path) -> Result<i32> {
    let sig = append_ext(out, "sig");
    let cert = append_ext(out, "cert");
    let result = Command::new("cosign")
        .arg("sign-blob")
        .arg("--yes")
        .arg("--output-signature")
        .arg(&sig)
        .arg("--output-certificate")
        .arg(&cert)
        .arg(out)
        .status();
    match result {
        Ok(status) if status.success() => {
            println!("signed {out:?} -> {sig:?} (+ {cert:?})");
            Ok(0)
        }
        Ok(status) => {
            eprintln!("mcptest evidence: cosign sign-blob failed ({status})");
            Ok(2)
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            eprintln!(
                "mcptest evidence: cosign not found on PATH. Install it (https://docs.sigstore.dev) \
                 to sign, or omit --sign to emit an unsigned artifact."
            );
            Ok(2)
        }
        Err(e) => {
            eprintln!("mcptest evidence: could not run cosign: {e}");
            Ok(2)
        }
    }
}

/// Count security findings by severity from a `mcptest security --format json`
/// report. Reads both the bundled `report.findings` and the imported
/// `report.supplement` arrays so a combined report is counted whole.
fn security_severity_counts(doc: &Value) -> BTreeMap<String, usize> {
    let mut counts = BTreeMap::new();
    let report = doc.get("report").unwrap_or(doc);
    for key in ["findings", "supplement"] {
        if let Some(arr) = report.get(key).and_then(Value::as_array) {
            for finding in arr {
                if let Some(sev) = finding.get("severity").and_then(Value::as_str) {
                    *counts.entry(sev.to_string()).or_insert(0) += 1;
                }
            }
        }
    }
    counts
}

/// Whether the artifact's `generated_at` is within `budget` of now. Returns
/// false for a missing or unparseable timestamp, so an un-ageable artifact
/// fails a max-age gate rather than passing it.
fn artifact_within_age(generated_at: Option<&str>, budget: Duration) -> bool {
    let Some(ts) = generated_at else {
        return false;
    };
    let Some(generated) = rfc3339_to_epoch(ts) else {
        return false;
    };
    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
        return false;
    };
    let age = now.as_secs() as i64 - generated;
    age >= 0 && age <= budget.as_secs() as i64
}

/// Whether `sha` is an ancestor of the current `HEAD`. `None` when it cannot be
/// determined (not a git repo, or git is absent), which the verifier treats as
/// "could not check" rather than a rejection.
fn commit_is_ancestor_of_head(sha: &str) -> Option<bool> {
    let output = Command::new("git")
        .args(["merge-base", "--is-ancestor", sha, "HEAD"])
        .output()
        .ok()?;
    match output.status.code() {
        Some(0) => Some(true),
        Some(1) => Some(false),
        // 128 (not a repo / unknown commit) or any other code: indeterminate.
        _ => None,
    }
}

/// Parse an RFC 3339 timestamp's `YYYY-MM-DDTHH:MM:SS` prefix into Unix epoch
/// seconds, treating it as UTC. Fractional seconds and the timezone offset are
/// ignored: a sub-day skew never changes a max-age verdict measured in hours or
/// days, and this keeps the age check dependency-free.
fn rfc3339_to_epoch(ts: &str) -> Option<i64> {
    if ts.len() < 19 {
        return None;
    }
    let year: i64 = ts.get(0..4)?.parse().ok()?;
    let month: i64 = ts.get(5..7)?.parse().ok()?;
    let day: i64 = ts.get(8..10)?.parse().ok()?;
    let hour: i64 = ts.get(11..13)?.parse().ok()?;
    let minute: i64 = ts.get(14..16)?.parse().ok()?;
    let second: i64 = ts.get(17..19)?.parse().ok()?;
    let days = days_from_civil(year, month, day)?;
    Some(days * 86_400 + hour * 3_600 + minute * 60 + second)
}

/// The current UTC time as an RFC 3339 `YYYY-MM-DDTHH:MM:SSZ` string.
///
/// Dependency-free: the wall clock to civil-date conversion is the inverse of
/// [`days_from_civil`] (Howard Hinnant's `civil_from_days`). A pack always
/// carries its assembly time, even when the source run recorded none.
fn now_rfc3339() -> String {
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let days = secs.div_euclid(86_400);
    let tod = secs.rem_euclid(86_400);
    let (year, month, day) = civil_from_days(days);
    let (hour, minute, second) = (tod / 3_600, (tod % 3_600) / 60, tod % 60);
    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

/// Civil UTC date for a count of days since the Unix epoch (Howard Hinnant's
/// `civil_from_days`), the inverse of [`days_from_civil`].
fn civil_from_days(z: i64) -> (i64, i64, i64) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let day = doy - (153 * mp + 2) / 5 + 1;
    let month = if mp < 10 { mp + 3 } else { mp - 9 };
    (if month <= 2 { y + 1 } else { y }, month, day)
}

/// Days since the Unix epoch for a civil UTC date (Howard Hinnant's
/// `days_from_civil`). Returns `None` for an out-of-range month or day.
fn days_from_civil(year: i64, month: i64, day: i64) -> Option<i64> {
    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
        return None;
    }
    let y = if month <= 2 { year - 1 } else { year };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400;
    let m = if month > 2 { month - 3 } else { month + 9 };
    let doy = (153 * m + 2) / 5 + day - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    Some(era * 146_097 + doe - 719_468)
}

/// Append an extension component to a path: `evidence.json` + `sig` ->
/// `evidence.json.sig`, so the signature sits beside the artifact it covers.
fn append_ext(path: &Path, ext: &str) -> std::path::PathBuf {
    let mut s = path.as_os_str().to_os_string();
    s.push(".");
    s.push(ext);
    std::path::PathBuf::from(s)
}

/// The conventional `<evidence>.sig` path beside an artifact.
fn sibling_signature(evidence: &Path) -> Option<std::path::PathBuf> {
    Some(append_ext(evidence, "sig"))
}

/// The conventional `<evidence>.cert` path beside an artifact (the certificate
/// `cosign sign-blob` writes alongside the signature).
fn sibling_cert(evidence: &Path) -> Option<std::path::PathBuf> {
    Some(append_ext(evidence, "cert"))
}

/// Cryptographically verify a detached signature with `cosign verify-blob`
/// (keyless: expected identity + issuer). Returns `Some(true)` when cosign
/// accepts it, `Some(false)` when cosign runs and rejects it, and `None` when
/// cosign is not installed, so the caller can fall back to a presence check and
/// say the signature was not verified. Output is captured so cosign's own lines
/// do not pollute the verdict stream.
fn cosign_verify_blob(
    blob: &Path,
    sig: &Path,
    cert: &Path,
    identity: &str,
    issuer: &str,
) -> Option<bool> {
    let result = Command::new("cosign")
        .arg("verify-blob")
        .arg("--signature")
        .arg(sig)
        .arg("--certificate")
        .arg(cert)
        .arg("--certificate-identity")
        .arg(identity)
        .arg("--certificate-oidc-issuer")
        .arg(issuer)
        .arg(blob)
        .output();
    match result {
        Ok(out) if out.status.success() => Some(true),
        Ok(_) => Some(false),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
        Err(_) => None,
    }
}

/// Read and parse a JSON file, mapping read and parse failures to exit code 2.
fn read_json(path: &Path) -> std::result::Result<Value, i32> {
    let body = match std::fs::read_to_string(path) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("mcptest evidence: cannot read {path:?}: {e}");
            return Err(2);
        }
    };
    serde_json::from_str(&body).map_err(|e| {
        eprintln!("mcptest evidence: {path:?} is not valid JSON: {e}");
        2
    })
}

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

    #[test]
    fn rfc3339_epoch_matches_known_anchor() {
        // 2024-01-01T00:00:00Z is 1704067200 seconds after the epoch.
        assert_eq!(
            rfc3339_to_epoch("2024-01-01T00:00:00Z"),
            Some(1_704_067_200)
        );
        // Tolerates a fractional-second suffix.
        assert_eq!(
            rfc3339_to_epoch("2024-01-01T00:00:00.500Z"),
            Some(1_704_067_200)
        );
        assert_eq!(rfc3339_to_epoch("not-a-time"), None);
    }

    #[test]
    fn within_age_rejects_missing_or_unparseable_timestamps() {
        assert!(!artifact_within_age(None, Duration::from_secs(3600)));
        assert!(!artifact_within_age(
            Some("garbage"),
            Duration::from_secs(3600)
        ));
    }

    #[test]
    fn within_age_rejects_a_timestamp_far_in_the_past() {
        // A year-2000 timestamp is well past any sane max age.
        assert!(!artifact_within_age(
            Some("2000-01-01T00:00:00Z"),
            Duration::from_secs(3600)
        ));
    }

    #[test]
    fn security_counts_tally_findings_and_supplement_by_severity() {
        let doc = serde_json::json!({
            "report": {
                "findings": [{ "severity": "high" }, { "severity": "high" }],
                "supplement": [{ "severity": "critical" }]
            }
        });
        let counts = security_severity_counts(&doc);
        assert_eq!(counts.get("high"), Some(&2));
        assert_eq!(counts.get("critical"), Some(&1));
    }

    #[test]
    fn civil_from_days_inverts_days_from_civil() {
        // Round-trip a known anchor and a leap day.
        for (y, m, d) in [(2024, 1, 1), (2026, 6, 21), (2024, 2, 29), (1970, 1, 1)] {
            let days = days_from_civil(y, m, d).unwrap();
            assert_eq!(civil_from_days(days), (y, m, d), "round-trip {y}-{m}-{d}");
        }
    }

    #[test]
    fn now_rfc3339_is_well_formed_and_current() {
        let ts = now_rfc3339();
        assert_eq!(ts.len(), 20, "expected YYYY-MM-DDTHH:MM:SSZ, got {ts}");
        assert!(ts.ends_with('Z'));
        // It parses back through the epoch reader, and lands after 2025.
        let epoch = rfc3339_to_epoch(&ts).expect("now parses");
        assert!(epoch > 1_735_689_600, "expected a post-2025 timestamp");
    }

    #[test]
    fn append_ext_sits_beside_the_artifact() {
        assert_eq!(
            append_ext(Path::new("dir/evidence.json"), "sig"),
            Path::new("dir/evidence.json.sig")
        );
    }
}
