//! Integration tests for `mcptest report`.
//!
//! These exercise the report pipeline against the in-process
//! `Report::sample()` fixture serialized to a temporary file, which keeps the
//! format assertions independent of any live server. The end-to-end path
//! (`mcptest run --output` writes a report these reporters then re-render)
//! is covered in `cli_run_output.rs`.

use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

use assert_cmd::Command;
use predicates::prelude::*;

#[test]
fn report_pretty_renders_sample_fixture() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains(
            "[PASS] search returns at least one hit",
        ))
        .stdout(predicate::str::contains("Summary: 2 passed, 1 failed"));
}

#[test]
fn report_json_round_trips() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    let assert = Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "json"])
        .assert()
        .success();

    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(parsed["summary"]["passed"], 2);
    assert_eq!(parsed["summary"]["failed"], 1);
}

#[test]
fn report_junit_emits_xml() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "junit"])
        .assert()
        .success()
        .stdout(predicate::str::contains(
            r#"<testsuites tests="3" failures="1""#,
        ))
        .stdout(predicate::str::contains("<testcase"));
}

#[test]
fn report_matrix_renders_html_grid() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_matrix_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "matrix"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Comparison matrix"))
        .stdout(predicate::str::contains("gpt-4o"))
        .stdout(predicate::str::contains("class=\"fail\""))
        .stdout(predicate::str::contains("<details>"));
}

#[test]
fn report_matrix_md_renders_markdown_grid() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_matrix_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "matrix-md"])
        .assert()
        .success()
        .stdout(predicate::str::contains("## Comparison matrix"))
        .stdout(predicate::str::contains("| test \\ model |"))
        .stdout(predicate::str::contains("FAIL"))
        .stdout(predicate::str::contains("### Failures"));
}

#[test]
fn report_writes_to_output_file_when_requested() {
    let dir = tempfile::tempdir().unwrap();
    let input = dir.path().join("report.json");
    let output = dir.path().join("rendered.txt");
    fs::write(&input, sample_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "report",
            input.to_str().unwrap(),
            "--output",
            output.to_str().unwrap(),
        ])
        .assert()
        .success();

    let contents = fs::read_to_string(&output).unwrap();
    assert!(contents.contains("Summary: 2 passed"));
}

#[test]
fn report_rejects_missing_input_with_exit_1() {
    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", "/nonexistent/path/does-not-exist.json"])
        .assert()
        .code(1);
}

#[test]
fn report_md_emits_summary_heading() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "md"])
        .assert()
        .success()
        .stdout(predicate::str::contains("## mcptest run"))
        // The audited markdown reporter collapses failures
        // into <details> blocks instead of a `[FAIL]` bullet, and the
        // summary table is the canonical place to confirm counts.
        .stdout(predicate::str::contains(
            "| Passed | Failed | Cached | Total | Pass rate |",
        ))
        .stdout(predicate::str::contains("<details><summary>"));
}

#[test]
fn report_html_emits_document_skeleton() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "html"])
        .assert()
        .success()
        .stdout(predicate::str::contains("<!DOCTYPE html>"))
        .stdout(predicate::str::contains("<table"))
        .stdout(predicate::str::contains("</html>"));
}

#[test]
fn report_sarif_emits_2_1_0_envelope() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    let assert = Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "sarif"])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(parsed["version"], "2.1.0");
    let results = parsed["runs"][0]["results"].as_array().unwrap();
    assert_eq!(results.len(), 1);
}

#[test]
fn report_gitlab_emits_code_quality_array() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    let assert = Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "gitlab"])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    let entries = parsed.as_array().unwrap();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0]["severity"], "major");
    let fp = entries[0]["fingerprint"].as_str().unwrap();
    assert_eq!(fp.len(), 64);
}

#[test]
fn report_upload_posts_json_to_endpoint() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    let (addr, handle, received) = spawn_one_shot_collector(200, "");

    Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "report",
            path.to_str().unwrap(),
            "--format",
            "upload",
            "--upload-endpoint",
            &format!("http://{addr}/collect"),
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("uploaded run"));

    handle.join().expect("collector thread");
    let captured = received.lock().unwrap();
    let captured = captured.as_ref().expect("collector saw a request");
    assert!(captured
        .headers
        .iter()
        .any(|h| h.eq_ignore_ascii_case("X-Mcptest-Wire-Version: v0")));
    let body: serde_json::Value = serde_json::from_slice(&captured.body).unwrap();
    assert_eq!(body["wire_version"], "v0");
    assert_eq!(body["run"]["summary"]["passed"], 2);
}

#[test]
fn report_upload_returns_2_on_collector_error() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    let (addr, handle, _received) = spawn_one_shot_collector(403, "forbidden, wrong org");

    Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "report",
            path.to_str().unwrap(),
            "--format",
            "upload",
            "--upload-endpoint",
            &format!("http://{addr}/collect"),
        ])
        .assert()
        .code(2);
    handle.join().expect("collector thread");
}

#[test]
fn report_upload_requires_endpoint_flag() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();
    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "upload"])
        .assert()
        .code(1);
}

#[test]
fn report_ndjson_emits_one_record_per_line() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    let assert = Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "ndjson"])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
    // 3 test lines + 1 summary line, each a standalone JSON object.
    assert_eq!(lines.len(), 4);
    for l in &lines {
        serde_json::from_str::<serde_json::Value>(l).expect("each line is valid JSON");
    }
    let summary: serde_json::Value = serde_json::from_str(lines[3]).unwrap();
    assert_eq!(summary["type"], "summary");
    assert_eq!(summary["passed"], 2);
    assert_eq!(summary["failed"], 1);
}

#[test]
fn report_tap_emits_version_plan_and_results() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap(), "--format", "tap"])
        .assert()
        .success()
        .stdout(predicate::str::starts_with("TAP version 14\n1..3\n"))
        .stdout(predicate::str::contains(
            "ok 1 - search returns at least one hit",
        ))
        .stdout(predicate::str::contains(
            "not ok 3 - create_issue rejects empty title",
        ));
}

#[test]
fn report_annotations_always_emits_workflow_commands_on_stderr() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");
    fs::write(&path, sample_json()).unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args([
            "report",
            path.to_str().unwrap(),
            "--format",
            "json",
            "--annotations",
            "always",
        ])
        .assert()
        .success()
        // The sample has one failing test; the workflow command lands on
        // stderr so GitHub Actions turns it into an inline PR annotation.
        .stderr(predicate::str::contains("::error"))
        .stderr(predicate::str::contains("create_issue rejects empty title"));
}

#[test]
fn report_rejects_invalid_json_with_exit_1() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("bad.json");
    fs::write(&path, "{not valid json}").unwrap();

    Command::cargo_bin("mcptest")
        .unwrap()
        .args(["report", path.to_str().unwrap()])
        .assert()
        .code(1);
}

/// Captured HTTP request fields the upload tests assert on.
struct CapturedRequest {
    headers: Vec<String>,
    body: Vec<u8>,
}

/// Spawn a tiny single-shot HTTP/1.1 collector on `127.0.0.1:0`, accept
/// one request, then write `status` + `response_body` and return. Avoids
/// pulling wiremock into the CLI crate just for one assertion.
fn spawn_one_shot_collector(
    status: u16,
    response_body: &str,
) -> (
    SocketAddr,
    JoinHandle<()>,
    Arc<Mutex<Option<CapturedRequest>>>,
) {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let addr = listener.local_addr().unwrap();
    let captured: Arc<Mutex<Option<CapturedRequest>>> = Arc::new(Mutex::new(None));
    let captured_clone = captured.clone();
    let response_body = response_body.to_string();
    let handle = thread::spawn(move || {
        let (mut stream, _) = listener.accept().expect("accept");
        let req = read_request(&mut stream);
        *captured_clone.lock().unwrap() = Some(req);
        let reason = match status {
            200 => "OK",
            202 => "Accepted",
            401 => "Unauthorized",
            403 => "Forbidden",
            500 => "Internal Server Error",
            _ => "Status",
        };
        let response = format!(
            "HTTP/1.1 {status} {reason}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{response_body}",
            len = response_body.len()
        );
        let _ = stream.write_all(response.as_bytes());
        let _ = stream.flush();
    });
    (addr, handle, captured)
}

/// Read one HTTP/1.1 request from `stream` and pull out headers + body.
/// Naive but enough for the assert: we only need to see the wire-version
/// header and the JSON envelope.
fn read_request(stream: &mut TcpStream) -> CapturedRequest {
    let mut buf = Vec::with_capacity(8192);
    let mut tmp = [0u8; 4096];
    let mut header_end = None;
    while header_end.is_none() {
        let n = stream.read(&mut tmp).expect("read");
        if n == 0 {
            break;
        }
        buf.extend_from_slice(&tmp[..n]);
        header_end = find_header_end(&buf);
    }
    let header_end = header_end.unwrap_or(buf.len());
    let head = String::from_utf8_lossy(&buf[..header_end]).to_string();
    let mut headers: Vec<String> = Vec::new();
    let mut content_length = 0usize;
    for line in head.split("\r\n").skip(1) {
        if line.is_empty() {
            continue;
        }
        if let Some((name, value)) = line.split_once(':') {
            let trimmed = format!("{}: {}", name.trim(), value.trim());
            if name.eq_ignore_ascii_case("content-length") {
                content_length = value.trim().parse().unwrap_or(0);
            }
            headers.push(trimmed);
        }
    }
    let body_start = header_end + 4;
    let mut body: Vec<u8> = if buf.len() > body_start {
        buf[body_start..].to_vec()
    } else {
        Vec::new()
    };
    while body.len() < content_length {
        let n = stream.read(&mut tmp).expect("read body");
        if n == 0 {
            break;
        }
        body.extend_from_slice(&tmp[..n]);
    }
    body.truncate(content_length);
    CapturedRequest { headers, body }
}

/// Return the index where the `\r\n\r\n` header terminator starts, if any.
fn find_header_end(buf: &[u8]) -> Option<usize> {
    buf.windows(4).position(|w| w == b"\r\n\r\n")
}

fn sample_json() -> String {
    // Mirrors `Report::sample()` in mcptest_core. Hardcoded here so the
    // integration test does not depend on the library crate's serialization
    // (any drift in field names will surface as a test failure).
    serde_json::json!({
        "schema_version": "0.1",
        "mcptest_version": "0.1.0",
        "generated_at": "2026-05-15T22:14:03Z",
        "run_id": "01JCQQYV4S3H6KX7M8RN2T5XZA",
        "summary": {
            "total": 3,
            "passed": 2,
            "failed": 1,
            "skipped": 0,
            "duration_ms": 250
        },
        "tests": [
            {
                "name": "search returns at least one hit",
                "status": "pass",
                "duration_ms": 80,
                "failure": null,
                "file": "tests/search.yml",
                "line": 3
            },
            {
                "name": "list_issues respects pagination",
                "status": "pass",
                "duration_ms": 120,
                "failure": null,
                "file": "tests/list_issues.yml",
                "line": 8
            },
            {
                "name": "create_issue rejects empty title",
                "status": "fail",
                "duration_ms": 50,
                "failure": {
                    "message": "expected error but tool returned ok",
                    "diff": "  expected: error code -32602\n  actual:   ok with id=42"
                },
                "file": "tests/create_issue.yml",
                "line": 22
            }
        ]
    })
    .to_string()
}

/// Serialize the library's matrix fixture so the matrix reporters render a
/// real grid (two agent tests across two models, one failing cell). Built from
/// `Report::sample_matrix()` so the matrix section serializes exactly as a run
/// would write it.
fn sample_matrix_json() -> String {
    serde_json::to_string(&mcptest_core::report::Report::sample_matrix()).unwrap()
}
