//! `mcptest conformance run --client` handler (WOR-1365).
//!
//! Stands mcptest up as an adversarial server, points the client-under-test at
//! it (see [`conformance_client_probe`](super::conformance_client_probe)), and
//! scores the client's handshake with the same MUST/SHOULD tier rule the
//! server lane uses. The client lane doubles the addressable audience: a
//! developer can now test their own MCP client, not just their server.

use std::fs;
use std::io::Write;

use anyhow::{Context, Result};
use serde::Serialize;

use super::conformance_client_probe;
use crate::cli::args::{ConformanceFormat, ConformanceRunArgs};
use mcptest_core::conformance::{
    client_handshake_probes, client_security_probes, score_tier, tally_by_level, ProbeOutcome,
    ProbeResult, Tier, TierInput,
};

/// One-line caveat: the client lane verifies the universal handshake facts; the
/// richer client behaviors (cancellation timing, server-request windowing) are
/// not probed yet.
const CLIENT_NOTE: &str = "client-probe scorecard: universal handshake MUSTs verified against \
the client under test; cancellation-timing and server-request-windowing checks are not probed yet";

/// Passed / total for one normative bucket.
#[derive(Debug, Serialize, PartialEq, Eq)]
struct BucketCounts {
    passed: u32,
    total: u32,
}

/// Wire shape of the client-lane JSON report.
#[derive(Debug, Serialize)]
struct ClientRunReport {
    /// Always `client-probe`; distinguishes this from the server lane's scopes.
    scope: &'static str,
    /// The client command that was probed.
    client: String,
    #[serde(rename = "must")]
    must_counts: BucketCounts,
    #[serde(rename = "should")]
    should_counts: BucketCounts,
    tier: Tier,
    badge: String,
    probes: Vec<ProbeOutcome>,
    /// Client/host security probes, kept in their own array so a security
    /// failure is visible apart from the universal handshake conformance. The
    /// outcomes still fold into the tier (a SHOULD failure lowers it), so CI
    /// sees the signal in the badge.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    security_probes: Vec<ProbeOutcome>,
}

fn build_report(
    client: &str,
    handshake: Vec<ProbeOutcome>,
    security: Vec<ProbeOutcome>,
) -> ClientRunReport {
    // Tier folds in both arrays so a security SHOULD failure lowers the grade,
    // but the report keeps them separate so the reader can tell a handshake
    // failure from a host-security one.
    let combined: Vec<ProbeOutcome> = handshake.iter().chain(security.iter()).cloned().collect();
    let (must_passed, must_total, should_passed, should_total) = tally_by_level(&combined);
    let tier = score_tier(TierInput {
        must_passed,
        must_total,
        should_passed,
        should_total,
    });
    ClientRunReport {
        scope: "client-probe",
        client: client.to_string(),
        must_counts: BucketCounts {
            passed: must_passed,
            total: must_total,
        },
        should_counts: BucketCounts {
            passed: should_passed,
            total: should_total,
        },
        badge: tier.badge().to_string(),
        tier,
        probes: handshake,
        security_probes: security,
    }
}

/// Entry point for `conformance run --client <COMMAND>`.
pub(crate) fn run_client_lane(args: &ConformanceRunArgs) -> Result<i32> {
    let command = args
        .client
        .as_deref()
        .expect("run_client_lane is only called when --client is set");
    let (evidence, security) = match conformance_client_probe::probe_client_evidence(command) {
        Ok(e) => e,
        Err(err) => {
            eprintln!("mcptest conformance: could not run the client `{command}`: {err:#}");
            // Exit 4 ("under-test process unreachable"), matching the server
            // lane's unreachable-server code in docs/conformance-cli.md.
            return Ok(4);
        }
    };
    let report = build_report(
        command,
        client_handshake_probes(&evidence),
        client_security_probes(&security),
    );
    let rendered = render(&report, args.format)?;
    write_output(&rendered, args.output.as_deref())?;
    // Like the server lane: a `Fail` tier is reported in the badge and JSON,
    // not the exit code, so a reachable-but-nonconformant client (exit 0) is
    // distinguished from an unrunnable one (exit 4).
    Ok(0)
}

fn render(report: &ClientRunReport, format: ConformanceFormat) -> Result<String> {
    match format {
        ConformanceFormat::Json => {
            serde_json::to_string_pretty(report).context("serializing JSON report")
        }
        ConformanceFormat::Pretty => Ok(render_pretty(report)),
        ConformanceFormat::Markdown => Ok(render_markdown(report)),
        ConformanceFormat::Html => Ok(render_html(report)),
    }
}

/// Format one probe outcome as an indented `[PASS|FAIL] check (LEVEL)` line.
fn probe_line(outcome: &ProbeOutcome) -> String {
    match &outcome.result {
        ProbeResult::Passed => format!("    [PASS] {} ({})", outcome.check, outcome.level),
        ProbeResult::Failed { reason } => {
            format!("    [FAIL] {} ({}): {reason}", outcome.check, outcome.level)
        }
    }
}

fn render_pretty(report: &ClientRunReport) -> String {
    let mut out = format!("client conformance scorecard ({})\n", report.badge);
    out.push_str(&format!("  client : {}\n", report.client));
    out.push_str(&format!("  scope  : {CLIENT_NOTE}\n"));
    out.push_str(&format!(
        "  must   : {} / {}\n  should : {} / {}\n",
        report.must_counts.passed,
        report.must_counts.total,
        report.should_counts.passed,
        report.should_counts.total,
    ));
    out.push_str("\n  probed checks:\n");
    for outcome in &report.probes {
        out.push_str(&probe_line(outcome));
        out.push('\n');
    }
    if !report.security_probes.is_empty() {
        out.push_str("\n  security checks:\n");
        for outcome in &report.security_probes {
            out.push_str(&probe_line(outcome));
            out.push('\n');
        }
    }
    out
}

fn render_markdown(report: &ClientRunReport) -> String {
    let mut out = format!("# mcptest client conformance ({})\n\n", report.badge);
    out.push_str(&format!("> {CLIENT_NOTE}\n\n"));
    out.push_str(&format!(
        "- **Client:** `{}`\n- **MUST:** {} / {}\n- **SHOULD:** {} / {}\n\n",
        report.client,
        report.must_counts.passed,
        report.must_counts.total,
        report.should_counts.passed,
        report.should_counts.total,
    ));
    out.push_str("## Probed checks\n\n");
    for outcome in &report.probes {
        out.push_str(&format!("- {}\n", probe_line(outcome).trim_start()));
    }
    if !report.security_probes.is_empty() {
        out.push_str("\n## Security checks\n\n");
        for outcome in &report.security_probes {
            out.push_str(&format!("- {}\n", probe_line(outcome).trim_start()));
        }
    }
    out
}

fn render_html(report: &ClientRunReport) -> String {
    let mut out = String::from("<!doctype html><html><body>");
    out.push_str(&format!(
        "<h1>mcptest client conformance ({})</h1>",
        report.badge
    ));
    out.push_str(&format!("<p><em>{CLIENT_NOTE}</em></p>"));
    out.push_str(&format!(
        "<p>MUST {} / {}, SHOULD {} / {}.</p><ul>",
        report.must_counts.passed,
        report.must_counts.total,
        report.should_counts.passed,
        report.should_counts.total
    ));
    for outcome in &report.probes {
        out.push_str(&format!("<li>{}</li>", probe_line(outcome).trim_start()));
    }
    out.push_str("</ul>");
    if !report.security_probes.is_empty() {
        out.push_str("<h2>Security checks</h2><ul>");
        for outcome in &report.security_probes {
            out.push_str(&format!("<li>{}</li>", probe_line(outcome).trim_start()));
        }
        out.push_str("</ul>");
    }
    out.push_str("</body></html>");
    out
}

fn write_output(body: &str, out: Option<&std::path::Path>) -> Result<()> {
    match out {
        None => {
            let stdout = std::io::stdout();
            let mut handle = stdout.lock();
            handle
                .write_all(body.as_bytes())
                .context("writing stdout")?;
            if !body.ends_with('\n') {
                handle.write_all(b"\n").context("writing stdout newline")?;
            }
        }
        Some(path) => {
            fs::write(path, body).with_context(|| format!("writing {}", path.display()))?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_core::conformance::{ClientConformanceEvidence, ClientSecurityEvidence};

    fn conformant_outcomes() -> Vec<ProbeOutcome> {
        client_handshake_probes(&ClientConformanceEvidence {
            sent_initialize_first: true,
            protocol_version_present: true,
            client_info_present: true,
            capabilities_present: true,
            sent_initialized_notification: true,
            responded_to_ping: Some(true),
        })
    }

    fn resisted_security() -> Vec<ProbeOutcome> {
        client_security_probes(&ClientSecurityEvidence {
            sampling_probed: true,
            sampling_echoed_canary: Some(false),
            ..Default::default()
        })
    }

    fn echoed_security() -> Vec<ProbeOutcome> {
        client_security_probes(&ClientSecurityEvidence {
            sampling_probed: true,
            sampling_echoed_canary: Some(true),
            ..Default::default()
        })
    }

    #[test]
    fn a_conformant_client_report_is_tier_one() {
        let report = build_report("good.sh", conformant_outcomes(), resisted_security());
        assert_eq!(report.scope, "client-probe");
        assert_eq!(report.tier, Tier::Tier1);
        assert_eq!(
            report.must_counts,
            BucketCounts {
                passed: 4,
                total: 4
            }
        );
    }

    #[test]
    fn a_missing_initialized_notification_fails_the_tier() {
        let outcomes = client_handshake_probes(&ClientConformanceEvidence {
            sent_initialize_first: true,
            protocol_version_present: true,
            sent_initialized_notification: false,
            responded_to_ping: Some(true),
            ..ClientConformanceEvidence::default()
        });
        let report = build_report("buggy.sh", outcomes, resisted_security());
        assert_eq!(report.tier, Tier::Fail);
        assert_eq!(report.badge, "F");
    }

    #[test]
    fn a_sampling_injection_echo_lowers_the_tier_but_keeps_musts() {
        // Handshake is perfect, but the security SHOULD fails, so the tier drops
        // below Tier1 while every MUST still passes.
        let report = build_report("echo.sh", conformant_outcomes(), echoed_security());
        assert_eq!(
            report.must_counts,
            BucketCounts {
                passed: 4,
                total: 4
            }
        );
        assert!(report.should_counts.passed < report.should_counts.total);
        assert_ne!(report.tier, Tier::Tier1);
    }

    #[test]
    fn pretty_render_lists_the_client_and_checks() {
        let pretty = render_pretty(&build_report(
            "good.sh",
            conformant_outcomes(),
            resisted_security(),
        ));
        assert!(pretty.contains("client : good.sh"));
        assert!(pretty.contains("[PASS] client-initialize-first"));
        assert!(pretty.contains("probed checks:"));
        assert!(pretty.contains("security checks:"));
        assert!(pretty.contains("client-resists-sampling-injection"));
    }

    #[test]
    fn json_render_tags_the_client_scope_and_security_probes() {
        let json = render(
            &build_report("echo.sh", conformant_outcomes(), echoed_security()),
            ConformanceFormat::Json,
        )
        .unwrap();
        assert!(json.contains("\"scope\": \"client-probe\""));
        assert!(json.contains("client-responds-to-ping"));
        assert!(json.contains("\"security_probes\""));
        assert!(json.contains("client-resists-sampling-injection"));
    }
}
