//! Newline-delimited JSON (NDJSON) reporter.
//!
//! Emits one JSON object per line: a `test` record for every result in
//! execution order, then a final `summary` record. The single-document
//! [`super::JsonReporter`] is the right choice for "hand me the whole run";
//! NDJSON is the right choice for log pipelines (Vector, Fluent Bit,
//! Logstash, `jq -c`) and for suites large enough that buffering one giant
//! document is awkward. Each line stands alone, so a consumer can stream and
//! act on results as they arrive.
//!
//! Every line is a complete, parseable JSON object. A `type` field
//! discriminates the record so a consumer can route on it without positional
//! assumptions.

use std::io::Write;
use std::sync::Arc;

use serde_json::json;

use crate::redaction::Redactor;
use crate::report::{Report, Reporter, TestResult, TestStatus};

/// NDJSON reporter; see module docs.
///
/// Accepts an optional [`Redactor`] via [`NdjsonReporter::with_redactor`] so
/// user-supplied strings (test name, failure message, diff) render through it.
/// The dispatch site already redacts via [`Report::redacted`]; the attached
/// redactor is belt-and-suspenders for callers that render a raw report.
#[derive(Default)]
pub struct NdjsonReporter {
    redactor: Option<Arc<Redactor>>,
}

impl NdjsonReporter {
    /// Attach a redactor so user-supplied strings render through it.
    pub fn with_redactor(mut self, redactor: Arc<Redactor>) -> Self {
        self.redactor = Some(redactor);
        self
    }

    fn redact<'a>(&self, s: &'a str) -> std::borrow::Cow<'a, str> {
        match &self.redactor {
            Some(r) => r.redact_value(s),
            None => std::borrow::Cow::Borrowed(s),
        }
    }

    fn test_line(&self, test: &TestResult) -> serde_json::Value {
        let status = match test.status {
            TestStatus::Pass => "pass",
            TestStatus::Fail => "fail",
            TestStatus::Skip => "skip",
            TestStatus::Inconclusive => "inconclusive",
        };
        let failure = test.failure.as_ref().map(|f| {
            json!({
                "message": self.redact(&f.message),
                "diff": f.diff.as_ref().map(|d| self.redact(d).into_owned()),
            })
        });
        json!({
            "type": "test",
            "name": self.redact(&test.name),
            "status": status,
            "duration_ms": test.duration_ms,
            "cache_hit": test.cache_hit,
            "file": test.file,
            "line": test.line,
            "rule_id": test.rule_id,
            "failure": failure,
        })
    }
}

impl Reporter for NdjsonReporter {
    fn name(&self) -> &'static str {
        "ndjson"
    }

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        for test in &report.tests {
            writeln!(out, "{}", self.test_line(test))?;
        }
        let summary = json!({
            "type": "summary",
            "total": report.summary.total,
            "passed": report.summary.passed,
            "failed": report.summary.failed,
            "skipped": report.summary.skipped,
            "cache_hits": report.summary.cache_hits,
            "duration_ms": report.summary.duration_ms,
        });
        writeln!(out, "{summary}")?;
        Ok(())
    }
}

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

    /// Parse every line of NDJSON output into a vector of JSON values.
    fn lines(buf: &[u8]) -> Vec<serde_json::Value> {
        String::from_utf8(buf.to_vec())
            .unwrap()
            .lines()
            .filter(|l| !l.trim().is_empty())
            .map(|l| serde_json::from_str(l).expect("each line is valid JSON"))
            .collect()
    }

    #[test]
    fn ndjson_emits_one_line_per_test_plus_a_summary() {
        let r = Report::sample();
        let mut buf = Vec::new();
        NdjsonReporter::default().render(&r, &mut buf).unwrap();
        let parsed = lines(&buf);
        // 3 test lines + 1 summary line.
        assert_eq!(parsed.len(), 4);
        assert_eq!(parsed[0]["type"], "test");
        assert_eq!(parsed[3]["type"], "summary");
        assert_eq!(parsed[3]["total"], 3);
        assert_eq!(parsed[3]["passed"], 2);
        assert_eq!(parsed[3]["failed"], 1);
    }

    #[test]
    fn ndjson_test_lines_carry_status_and_failure() {
        let r = Report::sample();
        let mut buf = Vec::new();
        NdjsonReporter::default().render(&r, &mut buf).unwrap();
        let parsed = lines(&buf);
        let failed = parsed
            .iter()
            .find(|l| l["status"] == "fail")
            .expect("a failed test line");
        assert_eq!(failed["name"], "create_issue rejects empty title");
        assert_eq!(
            failed["failure"]["message"],
            "expected error but tool returned ok"
        );
    }

    #[test]
    fn ndjson_name_is_stable() {
        assert_eq!(NdjsonReporter::default().name(), "ndjson");
    }

    #[test]
    fn ndjson_redacts_token_shaped_test_name() {
        let mut r = Report::sample();
        r.tests[0].name = "ghp_abcdefghijklmnop1234".into();
        let reporter = NdjsonReporter::default().with_redactor(Arc::new(Redactor::new()));
        let mut buf = Vec::new();
        reporter.render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("ghp_abcdefghijklmnop1234"));
        assert!(s.contains("[REDACTED:token]"));
    }
}
