use std::{collections::BTreeMap, time::UNIX_EPOCH};

use serde_json::{json, Value};

use crate::{
    automation::{self, PromptRequirement},
    error::HenError,
    request::{
        AssertionOutcome, AssertionStatus, ExecutionRecord, RequestFailure, RequestFailureKind,
    },
};

#[derive(Debug, Clone, Copy)]
pub struct BodyReportOptions {
    pub include_body: bool,
    pub max_body_chars: Option<usize>,
}

impl Default for BodyReportOptions {
    fn default() -> Self {
        Self {
            include_body: true,
            max_body_chars: None,
        }
    }
}

pub fn run_outcome_json(
    outcome: &automation::RunOutcome,
    body_options: BodyReportOptions,
) -> Value {
    json!({
        "collection": {
            "path": outcome.collection.path.display().to_string(),
            "name": outcome.collection.name,
            "description": outcome.collection.description,
            "requiredInputs": outcome.collection.required_inputs.iter().map(prompt_requirement_json).collect::<Vec<_>>(),
            "requests": outcome.collection.requests.iter().map(|request| {
                json!({
                    "index": request.index,
                    "description": request.description,
                    "method": request.method,
                    "url": request.url,
                    "dependencies": request.dependencies,
                })
            }).collect::<Vec<_>>()
        },
        "plan": outcome.plan,
        "selectedRequests": outcome.selected_requests,
        "primaryTarget": outcome.primary_target,
        "executionFailed": outcome.execution_failed,
        "interrupted": outcome.interrupted.is_some(),
        "interruptSignal": outcome.interrupted.map(|signal| signal.as_str()),
        "records": outcome.records.iter().map(|record| run_record_json(record, body_options)).collect::<Vec<_>>(),
        "failures": outcome.failures.iter().map(request_failure_json).collect::<Vec<_>>(),
    })
}

pub fn verification_result_json(result: &automation::VerificationResult) -> Value {
    json!({
        "path": result.path.as_ref().map(|path| path.display().to_string()),
        "name": result.summary.name,
        "description": result.summary.description,
        "requiredInputs": result.required_inputs.iter().map(prompt_requirement_json).collect::<Vec<_>>(),
        "requests": result.summary.requests.iter().map(|request| {
            json!({
                "index": request.index,
                "description": request.description,
                "method": request.method,
                "url": request.url,
            })
        }).collect::<Vec<_>>(),
    })
}

pub fn hen_error_json(error: &HenError) -> Value {
    json!({
        "error": {
            "kind": error.kind().label(),
            "summary": error.summary(),
            "details": error.details(),
            "exitCode": error.exit_code(),
        }
    })
}

pub fn run_outcome_ndjson(
    outcome: &automation::RunOutcome,
    body_options: BodyReportOptions,
) -> String {
    let mut lines = vec![json!({
        "type": "run",
        "collection": {
            "path": outcome.collection.path.display().to_string(),
            "name": outcome.collection.name,
            "description": outcome.collection.description,
        },
        "plan": outcome.plan,
        "selectedRequests": outcome.selected_requests,
        "primaryTarget": outcome.primary_target,
        "executionFailed": outcome.execution_failed,
        "interrupted": outcome.interrupted.is_some(),
        "interruptSignal": outcome.interrupted.map(|signal| signal.as_str()),
        "recordCount": outcome.records.len(),
        "failureCount": outcome.failures.len(),
    })];

    lines.extend(
        outcome
            .records
            .iter()
            .map(|record| with_type(run_record_json(record, body_options), "record")),
    );
    lines.extend(
        outcome
            .failures
            .iter()
            .map(|failure| with_type(request_failure_json(failure), "failure")),
    );

    render_ndjson(lines)
}

pub fn verification_result_ndjson(result: &automation::VerificationResult) -> String {
    let mut lines = vec![json!({
        "type": "verify",
        "path": result.path.as_ref().map(|path| path.display().to_string()),
        "name": result.summary.name,
        "description": result.summary.description,
    })];

    lines.extend(result.summary.requests.iter().map(|request| {
        json!({
            "type": "request",
            "index": request.index,
            "description": request.description,
            "method": request.method,
            "url": request.url,
        })
    }));

    lines.extend(result.required_inputs.iter().map(|prompt| {
        json!({
            "type": "requiredInput",
            "name": prompt.name,
            "default": prompt.default,
        })
    }));

    render_ndjson(lines)
}

pub fn hen_error_ndjson(error: &HenError) -> String {
    render_ndjson(vec![with_type(hen_error_json(error), "error")])
}

pub fn run_outcome_junit(outcome: &automation::RunOutcome) -> String {
    let suite_name = report_suite_name(
        &outcome.collection.name,
        &outcome.collection.path.display().to_string(),
    );

    let mut records_by_index: BTreeMap<usize, &ExecutionRecord> = outcome
        .records
        .iter()
        .map(|record| (record.index, record))
        .collect();
    let mut failures_by_index: BTreeMap<usize, &RequestFailure> = outcome
        .failures
        .iter()
        .filter_map(|failure| failure.index().map(|index| (index, failure)))
        .collect();
    let mut cases = Vec::new();
    let request_summaries: BTreeMap<usize, &automation::RequestSummary> = outcome
        .collection
        .requests
        .iter()
        .map(|request| (request.index, request))
        .collect();

    for index in &outcome.plan {
        if let Some(record) = records_by_index.remove(index) {
            cases.extend(record_cases(
                &suite_name,
                request_summaries.get(index).copied(),
                record,
            ));
            continue;
        }

        if let Some(failure) = failures_by_index.remove(index) {
            cases.extend(failure_cases(
                &suite_name,
                request_summaries.get(index).copied(),
                failure,
            ));
        }
    }

    cases.extend(records_by_index.into_values().flat_map(|record| {
        record_cases(
            &suite_name,
            request_summaries.get(&record.index).copied(),
            record,
        )
    }));
    cases.extend(failures_by_index.into_values().flat_map(|failure| {
        failure_cases(
            &suite_name,
            failure
                .index()
                .and_then(|index| request_summaries.get(&index).copied()),
            failure,
        )
    }));
    cases.extend(
        outcome
            .failures
            .iter()
            .filter(|failure| failure.index().is_none())
            .flat_map(|failure| failure_cases(&suite_name, None, failure)),
    );

    if let Some(signal) = outcome.interrupted {
        cases.push(JUnitCase {
            class_name: suite_name.clone(),
            name: format!("run interrupted by {}", signal.as_str()),
            time_secs: 0.0,
            status: JUnitCaseStatus::Error {
                kind: "interrupted".to_string(),
                message: format!("Execution interrupted by {}", signal.as_str()),
                details: format!(
                    "Execution interrupted by {} before completing all planned requests",
                    signal.as_str()
                ),
            },
        });
    }

    render_junit_suite(
        &suite_name,
        total_elapsed(&outcome.records)
            .unwrap_or_default()
            .as_secs_f64(),
        &[
            ("path", outcome.collection.path.display().to_string()),
            (
                "selectedRequests",
                outcome
                    .selected_requests
                    .iter()
                    .map(|index| index.to_string())
                    .collect::<Vec<_>>()
                    .join(","),
            ),
            (
                "plan",
                outcome
                    .plan
                    .iter()
                    .map(|index| index.to_string())
                    .collect::<Vec<_>>()
                    .join(","),
            ),
            ("executionFailed", outcome.execution_failed.to_string()),
            ("interrupted", outcome.interrupted.is_some().to_string()),
            (
                "interruptSignal",
                outcome
                    .interrupted
                    .map(|signal| signal.as_str().to_string())
                    .unwrap_or_default(),
            ),
        ],
        &cases,
    )
}

pub fn verification_result_junit(result: &automation::VerificationResult) -> String {
    let fallback = result
        .path
        .as_ref()
        .map(|path| path.display().to_string())
        .unwrap_or_else(|| "hen verify".to_string());
    let suite_name = report_suite_name(&result.summary.name, &fallback);

    render_junit_suite(
        &suite_name,
        0.0,
        &[("path", fallback.clone())],
        &[JUnitCase {
            class_name: suite_name.clone(),
            name: "syntax validation".to_string(),
            time_secs: 0.0,
            status: JUnitCaseStatus::Passed,
        }],
    )
}

pub fn hen_error_junit(suite_name: &str, case_name: &str, error: &HenError) -> String {
    render_junit_suite(
        suite_name,
        0.0,
        &[],
        &[JUnitCase {
            class_name: suite_name.to_string(),
            name: case_name.to_string(),
            time_secs: 0.0,
            status: JUnitCaseStatus::Error {
                kind: error.kind().label().to_string(),
                message: error.summary().to_string(),
                details: error.to_string(),
            },
        }],
    )
}

fn request_failure_json(failure: &RequestFailure) -> Value {
    json!({
        "index": failure.index(),
        "request": failure.request(),
        "kind": format!("{:?}", failure.kind()),
        "message": failure.to_string(),
        "assertions": assertion_results_json(failure.assertions()),
    })
}

fn prompt_requirement_json(prompt: &PromptRequirement) -> Value {
    json!({
        "name": prompt.name,
        "default": prompt.default,
    })
}

fn run_record_json(record: &ExecutionRecord, body_options: BodyReportOptions) -> Value {
    let body = describe_text(&record.execution.output, body_options);

    json!({
        "index": record.index,
        "description": record.description,
        "method": record.method.as_str(),
        "url": record.url,
        "status": record.execution.snapshot.status.as_u16(),
        "statusText": record.execution.snapshot.status.canonical_reason(),
        "startedAtUnixMs": record.started_at.duration_since(UNIX_EPOCH).ok().map(|value| value.as_millis() as u64),
        "durationMs": record.duration.as_millis() as u64,
        "body": body.value,
        "bodyChars": body.char_count as u64,
        "bodyCharLimit": body.char_limit.map(|value| value as u64),
        "bodyTruncated": body.truncated,
        "assertions": assertion_results_json(&record.execution.assertions),
    })
}

fn assertion_results_json(assertions: &[AssertionOutcome]) -> Vec<Value> {
    assertions
        .iter()
        .map(|assertion| {
            json!({
                "assertion": assertion.assertion,
                "status": assertion.status.as_str(),
                "message": assertion.message,
            })
        })
        .collect()
}

fn with_type(mut value: Value, kind: &str) -> Value {
    if let Value::Object(ref mut map) = value {
        map.insert("type".to_string(), Value::String(kind.to_string()));
    }
    value
}

fn render_ndjson(lines: Vec<Value>) -> String {
    lines
        .into_iter()
        .map(|line| serde_json::to_string(&line).expect("ndjson line should serialize"))
        .collect::<Vec<_>>()
        .join("\n")
}

#[derive(Debug, Clone)]
struct JUnitCase {
    class_name: String,
    name: String,
    time_secs: f64,
    status: JUnitCaseStatus,
}

#[derive(Debug, Clone)]
enum JUnitCaseStatus {
    Passed,
    Failure {
        kind: String,
        message: String,
        details: String,
    },
    Skipped {
        message: String,
    },
    Error {
        kind: String,
        message: String,
        details: String,
    },
}

fn success_case(
    suite_name: &str,
    request_summary: Option<&automation::RequestSummary>,
    record: &ExecutionRecord,
) -> JUnitCase {
    JUnitCase {
        class_name: suite_name.to_string(),
        name: request_case_name(
            record.index,
            request_summary,
            record.method.as_str(),
            &record.url,
            &record.description,
        ),
        time_secs: record.duration.as_secs_f64(),
        status: JUnitCaseStatus::Passed,
    }
}

fn record_cases(
    suite_name: &str,
    request_summary: Option<&automation::RequestSummary>,
    record: &ExecutionRecord,
) -> Vec<JUnitCase> {
    let request_name = request_case_name(
        record.index,
        request_summary,
        record.method.as_str(),
        &record.url,
        &record.description,
    );

    if record.execution.assertions.is_empty() {
        return vec![success_case(suite_name, request_summary, record)];
    }

    let case_time_secs = record.duration.as_secs_f64() / record.execution.assertions.len() as f64;

    assertion_cases(
        suite_name,
        &request_name,
        &record.execution.assertions,
        case_time_secs,
        "assertion",
    )
}

fn failure_case(
    suite_name: &str,
    request_summary: Option<&automation::RequestSummary>,
    failure: &RequestFailure,
) -> JUnitCase {
    let name = match failure.index() {
        Some(index) => request_case_name(
            index,
            request_summary,
            request_summary
                .map(|summary| summary.method.as_str())
                .unwrap_or("request"),
            request_summary
                .map(|summary| summary.url.as_str())
                .unwrap_or(""),
            failure.request(),
        ),
        None => failure.request().to_string(),
    };

    let status = match failure.kind() {
        RequestFailureKind::Execution { message, .. } => JUnitCaseStatus::Failure {
            kind: "execution".to_string(),
            message: message.clone(),
            details: failure.to_string(),
        },
        RequestFailureKind::Dependency { dependency } => JUnitCaseStatus::Skipped {
            message: format!("dependency '{}' failed", dependency),
        },
        RequestFailureKind::MissingDependency { dependency } => JUnitCaseStatus::Skipped {
            message: format!("missing dependency '{}'", dependency),
        },
        RequestFailureKind::MapAborted { group, cause } => JUnitCaseStatus::Skipped {
            message: format!("'{}' aborted after failure in '{}'", group, cause),
        },
        RequestFailureKind::Join { message } => JUnitCaseStatus::Error {
            kind: "join".to_string(),
            message: message.clone(),
            details: failure.to_string(),
        },
    };

    JUnitCase {
        class_name: suite_name.to_string(),
        name,
        time_secs: 0.0,
        status,
    }
}

fn failure_cases(
    suite_name: &str,
    request_summary: Option<&automation::RequestSummary>,
    failure: &RequestFailure,
) -> Vec<JUnitCase> {
    match failure.kind() {
        RequestFailureKind::Execution { assertions, .. } if !assertions.is_empty() => {
            let request_name = match failure.index() {
                Some(index) => request_case_name(
                    index,
                    request_summary,
                    request_summary
                        .map(|summary| summary.method.as_str())
                        .unwrap_or("request"),
                    request_summary
                        .map(|summary| summary.url.as_str())
                        .unwrap_or(""),
                    failure.request(),
                ),
                None => failure.request().to_string(),
            };

            assertion_cases(suite_name, &request_name, assertions, 0.0, "assertion")
        }
        _ => vec![failure_case(suite_name, request_summary, failure)],
    }
}

fn assertion_cases(
    suite_name: &str,
    request_name: &str,
    assertions: &[AssertionOutcome],
    time_secs: f64,
    failure_kind: &str,
) -> Vec<JUnitCase> {
    assertions
        .iter()
        .map(|assertion| JUnitCase {
            class_name: suite_name.to_string(),
            name: assertion_case_name(request_name, &assertion.assertion),
            time_secs,
            status: assertion_status_to_junit(&assertion.status, &assertion.message, &assertion.assertion, failure_kind),
        })
        .collect()
}

fn assertion_status_to_junit(
    status: &AssertionStatus,
    message: &Option<String>,
    assertion: &str,
    failure_kind: &str,
) -> JUnitCaseStatus {
    match status {
        AssertionStatus::Passed => JUnitCaseStatus::Passed,
        AssertionStatus::Skipped => JUnitCaseStatus::Skipped {
            message: message
                .clone()
                .unwrap_or_else(|| "assertion skipped".to_string()),
        },
        AssertionStatus::Failed => {
            let details = message.clone().unwrap_or_else(|| assertion.to_string());
            JUnitCaseStatus::Failure {
                kind: failure_kind.to_string(),
                message: details.clone(),
                details,
            }
        }
    }
}

fn assertion_case_name(request_name: &str, assertion: &str) -> String {
    format!("{} :: {}", request_name, assertion)
}

fn request_case_name(
    index: usize,
    request_summary: Option<&automation::RequestSummary>,
    method: &str,
    url: &str,
    fallback: &str,
) -> String {
    match request_summary {
        Some(summary) => format!("#{} {} {}", summary.index, summary.method, summary.url),
        None if !url.is_empty() => format!("#{} {} {}", index, method, url),
        None => format!("#{} {}", index, fallback),
    }
}

fn render_junit_suite(
    suite_name: &str,
    suite_time_secs: f64,
    properties: &[(&str, String)],
    cases: &[JUnitCase],
) -> String {
    let failure_count = cases
        .iter()
        .filter(|case| matches!(case.status, JUnitCaseStatus::Failure { .. }))
        .count();
    let skipped_count = cases
        .iter()
        .filter(|case| matches!(case.status, JUnitCaseStatus::Skipped { .. }))
        .count();
    let error_count = cases
        .iter()
        .filter(|case| matches!(case.status, JUnitCaseStatus::Error { .. }))
        .count();

    let mut xml = String::new();
    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    xml.push_str(&format!(
        "<testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\" time=\"{}\">",
        escape_xml(suite_name),
        cases.len(),
        failure_count,
        error_count,
        skipped_count,
        format_junit_time(suite_time_secs),
    ));

    if !properties.is_empty() {
        xml.push_str("\n  <properties>");
        for (name, value) in properties {
            xml.push_str(&format!(
                "\n    <property name=\"{}\" value=\"{}\"/>",
                escape_xml(name),
                escape_xml(value),
            ));
        }
        xml.push_str("\n  </properties>");
    }

    for case in cases {
        xml.push_str(&format!(
            "\n  <testcase classname=\"{}\" name=\"{}\" time=\"{}\">",
            escape_xml(&case.class_name),
            escape_xml(&case.name),
            format_junit_time(case.time_secs),
        ));

        match &case.status {
            JUnitCaseStatus::Passed => {}
            JUnitCaseStatus::Failure {
                kind,
                message,
                details,
            } => {
                xml.push_str(&format!(
                    "\n    <failure type=\"{}\" message=\"{}\">{}</failure>",
                    escape_xml(kind),
                    escape_xml(message),
                    escape_xml(details),
                ));
            }
            JUnitCaseStatus::Skipped { message } => {
                xml.push_str(&format!(
                    "\n    <skipped message=\"{}\"/>",
                    escape_xml(message),
                ));
            }
            JUnitCaseStatus::Error {
                kind,
                message,
                details,
            } => {
                xml.push_str(&format!(
                    "\n    <error type=\"{}\" message=\"{}\">{}</error>",
                    escape_xml(kind),
                    escape_xml(message),
                    escape_xml(details),
                ));
            }
        }

        xml.push_str("\n  </testcase>");
    }

    xml.push_str("\n</testsuite>\n");
    xml
}

fn report_suite_name(name: &str, fallback: &str) -> String {
    let trimmed = name.trim();
    if trimmed.is_empty() {
        fallback.to_string()
    } else {
        trimmed.to_string()
    }
}

fn format_junit_time(time_secs: f64) -> String {
    if time_secs == 0.0 {
        "0".to_string()
    } else {
        format!("{:.3}", time_secs)
    }
}

fn escape_xml(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

fn total_elapsed(records: &[ExecutionRecord]) -> Option<std::time::Duration> {
    if records.is_empty() {
        return None;
    }

    let mut earliest: Option<std::time::Duration> = None;
    let mut latest: Option<std::time::Duration> = None;

    for record in records {
        let start = match record.started_at.duration_since(UNIX_EPOCH) {
            Ok(duration) => duration,
            Err(_) => continue,
        };

        let finish = match record
            .started_at
            .checked_add(record.duration)
            .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
        {
            Some(duration) => duration,
            None => continue,
        };

        earliest = Some(match earliest {
            Some(current) => current.min(start),
            None => start,
        });
        latest = Some(match latest {
            Some(current) => current.max(finish),
            None => finish,
        });
    }

    match (earliest, latest) {
        (Some(start), Some(end)) if end >= start => Some(end - start),
        _ => None,
    }
}

struct TextDescription {
    value: Option<String>,
    truncated: bool,
    char_count: usize,
    char_limit: Option<usize>,
}

fn describe_text(value: &str, options: BodyReportOptions) -> TextDescription {
    let char_count = value.chars().count();
    let char_limit = options.max_body_chars;

    if !options.include_body {
        return TextDescription {
            value: None,
            truncated: false,
            char_count,
            char_limit,
        };
    }

    match char_limit {
        Some(limit) if char_count > limit => TextDescription {
            value: Some(value.chars().take(limit).collect()),
            truncated: true,
            char_count,
            char_limit,
        },
        _ => TextDescription {
            value: Some(value.to_string()),
            truncated: false,
            char_count,
            char_limit,
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{
        collections::HashMap,
        path::PathBuf,
        time::{Duration, SystemTime},
    };

    use http::{Method, StatusCode};

    use crate::{
        automation::{CollectionSummary, RunOutcome, VerificationResult},
        parser::{SyntaxRequestSummary, SyntaxSummary},
        request::{
            AssertionOutcome, AssertionStatus, ExecutionRecord, RequestExecution,
            ResponseSnapshot,
        },
    };

    #[test]
    fn verification_result_json_matches_golden_fixture() {
        let result = VerificationResult {
            path: Some(PathBuf::from("/workspace/collection.hen")),
            summary: SyntaxSummary {
                name: "Fixture Collection".to_string(),
                description: "Collection used for structured output fixtures.".to_string(),
                requests: vec![
                    SyntaxRequestSummary {
                        index: 0,
                        description: "Get one".to_string(),
                        method: "GET".to_string(),
                        url: "https://example.com/one".to_string(),
                    },
                    SyntaxRequestSummary {
                        index: 1,
                        description: "Get two".to_string(),
                        method: "POST".to_string(),
                        url: "https://example.com/two".to_string(),
                    },
                ],
            },
            required_inputs: vec![
                PromptRequirement {
                    name: "api_token".to_string(),
                    default: None,
                },
                PromptRequirement {
                    name: "region".to_string(),
                    default: Some("us-east-1".to_string()),
                },
            ],
        };

        assert_json_fixture(
            verification_result_json(&result),
            include_str!("../tests/fixtures/golden/verify_hen_syntax.json"),
        );
    }

    #[test]
    fn run_outcome_json_matches_golden_fixture() {
        let result = RunOutcome {
            collection: CollectionSummary {
                path: PathBuf::from("/workspace/collection.hen"),
                name: "Fixture Collection".to_string(),
                description: "Collection used for structured output fixtures.".to_string(),
                requests: vec![crate::automation::RequestSummary {
                    index: 0,
                    description: "Get one".to_string(),
                    method: "GET".to_string(),
                    url: "https://example.com/one".to_string(),
                    dependencies: vec![],
                }],
                required_inputs: vec![PromptRequirement {
                    name: "api_token".to_string(),
                    default: None,
                }],
            },
            plan: vec![0],
            selected_requests: vec![0],
            primary_target: Some(0),
            records: vec![ExecutionRecord {
                index: 0,
                description: "Get one".to_string(),
                method: Method::GET,
                url: "https://example.com/one".to_string(),
                execution: RequestExecution {
                    output: "hello world".to_string(),
                    export_env: HashMap::new(),
                    snapshot: ResponseSnapshot {
                        status: StatusCode::OK,
                        headers: Default::default(),
                        body: "hello world".to_string(),
                        json: None,
                    },
                    assertions: vec![
                        AssertionOutcome {
                            assertion: "^ & body.ok == true".to_string(),
                            status: AssertionStatus::Passed,
                            message: None,
                        },
                        AssertionOutcome {
                            assertion: "[ true == false ] ^ & body.service == 'hen'".to_string(),
                            status: AssertionStatus::Skipped,
                            message: Some("guard evaluated to false".to_string()),
                        },
                    ],
                },
                started_at: SystemTime::UNIX_EPOCH + Duration::from_millis(1_704_067_200_000),
                duration: Duration::from_millis(25),
            }],
            failures: vec![],
            execution_failed: false,
            interrupted: None,
        };

        assert_json_fixture(
            run_outcome_json(
                &result,
                BodyReportOptions {
                    include_body: true,
                    max_body_chars: Some(5),
                },
            ),
            include_str!("../tests/fixtures/golden/run_hen.json"),
        );
    }

    #[test]
    fn run_outcome_ndjson_emits_summary_and_record_lines() {
        let result = sample_run_outcome();

        let lines = run_outcome_ndjson(
            &result,
            BodyReportOptions {
                include_body: true,
                max_body_chars: None,
            },
        )
        .lines()
        .map(|line| serde_json::from_str::<Value>(line).expect("ndjson line should parse"))
        .collect::<Vec<_>>();

        assert_eq!(lines.len(), 2);
        assert_eq!(lines[0]["type"], "run");
        assert_eq!(lines[0]["interrupted"], false);
        assert_eq!(lines[0]["interruptSignal"], Value::Null);
        assert_eq!(lines[1]["type"], "record");
        assert_eq!(lines[1]["status"], 200);
        assert_eq!(lines[1]["assertions"][0]["status"], "passed");
        assert_eq!(lines[1]["assertions"][1]["status"], "skipped");
    }

    #[test]
    fn run_outcome_junit_renders_testcase() {
        let junit = run_outcome_junit(&sample_run_outcome());

        assert!(junit.contains("<testsuite"));
        assert!(junit.contains(
            "<testcase classname=\"Fixture Collection\" name=\"#0 GET https://example.com/one :: ^ &amp; body.ok == true\""
        ));
        assert!(junit.contains("tests=\"2\""));
        assert!(junit.contains("skipped=\"1\""));
        assert!(junit.contains("<skipped message=\"guard evaluated to false\"/>"));
    }

    #[test]
    fn run_outcome_junit_includes_interruption_case() {
        let mut result = sample_run_outcome();
        result.execution_failed = true;
        result.interrupted = Some(crate::request::InterruptSignal::Sigterm);

        let junit = run_outcome_junit(&result);

        assert!(junit.contains("errors=\"1\""));
        assert!(junit.contains("run interrupted by SIGTERM"));
        assert!(junit.contains("Execution interrupted by SIGTERM"));
    }

    fn sample_run_outcome() -> RunOutcome {
        RunOutcome {
            collection: CollectionSummary {
                path: PathBuf::from("/workspace/collection.hen"),
                name: "Fixture Collection".to_string(),
                description: "Collection used for structured output fixtures.".to_string(),
                requests: vec![crate::automation::RequestSummary {
                    index: 0,
                    description: "Get one".to_string(),
                    method: "GET".to_string(),
                    url: "https://example.com/one".to_string(),
                    dependencies: vec![],
                }],
                required_inputs: vec![PromptRequirement {
                    name: "api_token".to_string(),
                    default: None,
                }],
            },
            plan: vec![0],
            selected_requests: vec![0],
            primary_target: Some(0),
            records: vec![ExecutionRecord {
                index: 0,
                description: "Get one".to_string(),
                method: Method::GET,
                url: "https://example.com/one".to_string(),
                execution: RequestExecution {
                    output: "hello world".to_string(),
                    export_env: HashMap::new(),
                    snapshot: ResponseSnapshot {
                        status: StatusCode::OK,
                        headers: Default::default(),
                        body: "hello world".to_string(),
                        json: None,
                    },
                    assertions: vec![
                        AssertionOutcome {
                            assertion: "^ & body.ok == true".to_string(),
                            status: AssertionStatus::Passed,
                            message: None,
                        },
                        AssertionOutcome {
                            assertion: "[ true == false ] ^ & body.service == 'hen'".to_string(),
                            status: AssertionStatus::Skipped,
                            message: Some("guard evaluated to false".to_string()),
                        },
                    ],
                },
                started_at: SystemTime::UNIX_EPOCH + Duration::from_millis(1_704_067_200_000),
                duration: Duration::from_millis(25),
            }],
            failures: vec![],
            execution_failed: false,
            interrupted: None,
        }
    }

    fn assert_json_fixture(value: Value, expected: &str) {
        let actual = serde_json::to_string_pretty(&value).expect("json should serialize");
        assert_eq!(actual.trim(), expected.trim());
    }
}
