//! `mcptest compliance invariants`.
//!
//! Runs the spec-derived conformance invariants against one or more captured
//! MCP sessions read from a JSON file, and, when two or more sessions are
//! present, the multi-server composition-safety checks. The capture is read
//! from disk so the run is deterministic and offline: no server is contacted
//! here, the same fixture always produces the same report and exit code, which
//! is what a CI gate needs. Recording the capture off a live server is the
//! runner's job (a follow-up); this command is the scoring half.

use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use mcptest_core::compliance::{
    check_composition, CapturedSession, CompositionReport, InvariantOutcome,
};
use serde::Deserialize;
use serde_json::json;

/// Output format for `mcptest compliance invariants`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvariantsOutputFormat {
    /// Plain text suitable for a terminal.
    Pretty,
    /// JSON envelope of every outcome plus composition findings.
    Json,
}

/// Plain-data input for [`run_invariants`], filled from the CLI flags.
#[derive(Debug, Clone)]
pub struct InvariantsRunCliArgs {
    /// Path to the JSON capture file (one session or an array of sessions).
    pub capture: PathBuf,
    /// Rendered output format.
    pub format: InvariantsOutputFormat,
}

/// Accepts either a single session object or an array of sessions so a
/// single-server capture does not need to be wrapped in a list.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum CaptureFile {
    /// One server's capture.
    Single(CapturedSession),
    /// Several servers' captures, for the composition mode.
    Many(Vec<CapturedSession>),
}

impl CaptureFile {
    fn into_sessions(self) -> Vec<CapturedSession> {
        match self {
            CaptureFile::Single(s) => vec![s],
            CaptureFile::Many(s) => s,
        }
    }
}

/// Entry point for `mcptest compliance invariants`.
///
/// Loads the capture, runs every invariant against each session, runs the
/// composition checks when more than one session is present, renders the
/// chosen format, and returns the process exit code: `0` when every invariant
/// passed and no composition hazard was found, `1` otherwise.
pub fn run_invariants(args: InvariantsRunCliArgs) -> Result<i32> {
    let sessions = load_capture(&args.capture)?;
    let report = check_composition(&sessions);
    let rendered = match args.format {
        InvariantsOutputFormat::Pretty => render_pretty(&report),
        InvariantsOutputFormat::Json => render_json(&report),
    };
    let mut stdout = io::stdout().lock();
    stdout.write_all(rendered.as_bytes())?;
    Ok(if report.all_safe() { 0 } else { 1 })
}

fn load_capture(path: &Path) -> Result<Vec<CapturedSession>> {
    let body =
        fs::read_to_string(path).with_context(|| format!("read capture {}", path.display()))?;
    let parsed: CaptureFile =
        serde_json::from_str(&body).with_context(|| format!("parse capture {}", path.display()))?;
    Ok(parsed.into_sessions())
}

/// Render the report as plain text grouped by server, then composition.
fn render_pretty(report: &CompositionReport) -> String {
    let mut out = String::new();
    out.push_str("Conformance invariants\n");
    for run in &report.server_runs {
        out.push_str(&format!("\nServer: {}\n", run.server_label));
        for outcome in &run.outcomes {
            out.push_str(&format!(
                "  {} {} {}\n",
                mark(outcome.passed()),
                outcome.id,
                outcome.description
            ));
            if !outcome.passed() {
                for diff in &outcome.result.diffs {
                    if let Some(note) = &diff.note {
                        out.push_str(&format!("      {note}\n"));
                    }
                }
            }
        }
    }
    if report.server_runs.len() > 1 {
        out.push_str("\nComposition safety\n");
        if report.composition_findings.is_empty() {
            out.push_str("  PASS no cross-server hazards\n");
        } else {
            for finding in &report.composition_findings {
                out.push_str(&format!(
                    "  FAIL [{}] {}\n",
                    finding.kind.as_str(),
                    finding.detail
                ));
            }
        }
    }
    out.push_str(&format!(
        "\n{}\n",
        if report.all_safe() {
            "Result: all invariants passed".to_string()
        } else {
            format!(
                "Result: {} invariant failure(s), {} composition hazard(s)",
                report.failed_invariant_count(),
                report.composition_findings.len()
            )
        }
    ));
    out
}

/// "PASS" / "FAIL" marker for an outcome.
fn mark(passed: bool) -> &'static str {
    if passed {
        "PASS"
    } else {
        "FAIL"
    }
}

/// Render the report as a stable JSON envelope.
fn render_json(report: &CompositionReport) -> String {
    let servers: Vec<_> = report
        .server_runs
        .iter()
        .map(|run| {
            json!({
                "server_label": run.server_label,
                "outcomes": run.outcomes.iter().map(outcome_json).collect::<Vec<_>>(),
            })
        })
        .collect();
    let composition: Vec<_> = report
        .composition_findings
        .iter()
        .map(|f| {
            json!({
                "kind": f.kind.as_str(),
                "detail": f.detail,
                "servers": f.servers,
            })
        })
        .collect();
    let envelope = json!({
        "all_safe": report.all_safe(),
        "failed_invariant_count": report.failed_invariant_count(),
        "servers": servers,
        "composition_findings": composition,
    });
    serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_string())
}

/// JSON for one invariant outcome, including failure notes.
fn outcome_json(outcome: &InvariantOutcome) -> serde_json::Value {
    let notes: Vec<&str> = outcome
        .result
        .diffs
        .iter()
        .filter_map(|d| d.note.as_deref())
        .collect();
    json!({
        "id": outcome.id,
        "category": outcome.category.as_str(),
        "description": outcome.description,
        "passed": outcome.passed(),
        "notes": notes,
    })
}

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

    fn write_capture(path: &Path, body: serde_json::Value) {
        fs::write(path, serde_json::to_vec_pretty(&body).unwrap()).unwrap();
    }

    fn clean_single() -> serde_json::Value {
        json!({
            "server_label": "stdio://x",
            "server_capabilities": { "tools": {} },
            "exchanges": [
                { "request": { "jsonrpc": "2.0", "id": 1, "method": "initialize" },
                  "response": { "jsonrpc": "2.0", "id": 1, "result": {} } },
                { "request": { "jsonrpc": "2.0", "id": 2, "method": "tools/list" },
                  "response": { "jsonrpc": "2.0", "id": 2, "result": { "tools": [] } } }
            ]
        })
    }

    fn base_args(capture: PathBuf, format: InvariantsOutputFormat) -> InvariantsRunCliArgs {
        InvariantsRunCliArgs { capture, format }
    }

    #[test]
    fn clean_single_session_exits_zero() {
        let dir = tempdir().unwrap();
        let capture = dir.path().join("capture.json");
        write_capture(&capture, clean_single());
        let code = run_invariants(base_args(capture, InvariantsOutputFormat::Json)).unwrap();
        assert_eq!(code, 0);
    }

    #[test]
    fn invariant_violation_exits_one() {
        // tools/list answered without advertising tools: INV-003 fails.
        let dir = tempdir().unwrap();
        let capture = dir.path().join("capture.json");
        write_capture(
            &capture,
            json!({
                "server_label": "x",
                "server_capabilities": {},
                "exchanges": [
                    { "request": { "jsonrpc": "2.0", "id": 1, "method": "initialize" },
                      "response": { "jsonrpc": "2.0", "id": 1, "result": {} } },
                    { "request": { "jsonrpc": "2.0", "id": 2, "method": "tools/list" },
                      "response": { "jsonrpc": "2.0", "id": 2, "result": { "tools": [] } } }
                ]
            }),
        );
        let code = run_invariants(base_args(capture, InvariantsOutputFormat::Pretty)).unwrap();
        assert_eq!(code, 1);
    }

    #[test]
    fn composition_array_flags_overlap_and_exits_one() {
        let dir = tempdir().unwrap();
        let capture = dir.path().join("capture.json");
        let session = |label: &str| {
            json!({
                "server_label": label,
                "server_capabilities": { "tools": {} },
                "exchanges": [
                    { "request": { "jsonrpc": "2.0", "id": 1, "method": "initialize" },
                      "response": { "jsonrpc": "2.0", "id": 1, "result": {} } },
                    { "request": { "jsonrpc": "2.0", "id": 2, "method": "tools/list" },
                      "response": { "jsonrpc": "2.0", "id": 2,
                                    "result": { "tools": [ { "name": "search" } ] } } }
                ]
            })
        };
        write_capture(&capture, json!([session("alpha"), session("beta")]));
        let code = run_invariants(base_args(capture, InvariantsOutputFormat::Pretty)).unwrap();
        assert_eq!(code, 1);
    }

    #[test]
    fn missing_capture_file_is_an_error() {
        let dir = tempdir().unwrap();
        let capture = dir.path().join("nope.json");
        let err = run_invariants(base_args(capture, InvariantsOutputFormat::Json)).unwrap_err();
        assert!(err.to_string().contains("read capture"));
    }

    #[test]
    fn json_output_carries_all_safe_and_ids() {
        let dir = tempdir().unwrap();
        let capture = dir.path().join("capture.json");
        write_capture(&capture, clean_single());
        let sessions = load_capture(&capture).unwrap();
        let report = check_composition(&sessions);
        let rendered = render_json(&report);
        assert!(rendered.contains("\"all_safe\": true"));
        assert!(rendered.contains("INV-001"));
        assert!(rendered.contains("\"category\": \"capability\""));
    }

    #[test]
    fn pretty_output_lists_composition_section_for_multi_server() {
        let dir = tempdir().unwrap();
        let capture = dir.path().join("capture.json");
        // Distinct tool names AND distinct request ids so the composition is
        // clean: the section prints the "no hazards" line.
        let session = |label: &str, tool: &str, base: i64| {
            json!({
                "server_label": label,
                "server_capabilities": { "tools": {} },
                "exchanges": [
                    { "request": { "jsonrpc": "2.0", "id": base, "method": "initialize" },
                      "response": { "jsonrpc": "2.0", "id": base, "result": {} } },
                    { "request": { "jsonrpc": "2.0", "id": base + 1, "method": "tools/list" },
                      "response": { "jsonrpc": "2.0", "id": base + 1,
                                    "result": { "tools": [ { "name": tool } ] } } }
                ]
            })
        };
        write_capture(
            &capture,
            json!([session("a", "a_tool", 1), session("b", "b_tool", 10)]),
        );
        let sessions = load_capture(&capture).unwrap();
        let report = check_composition(&sessions);
        let rendered = render_pretty(&report);
        assert!(rendered.contains("Composition safety"));
        assert!(rendered.contains("no cross-server hazards"));
    }

    #[test]
    fn single_session_file_loads_without_array_wrapper() {
        let dir = tempdir().unwrap();
        let capture = dir.path().join("capture.json");
        write_capture(&capture, clean_single());
        let sessions = load_capture(&capture).unwrap();
        assert_eq!(sessions.len(), 1);
        assert_eq!(sessions[0].server_label, "stdio://x");
    }
}
