//! Test Anything Protocol (TAP version 14) reporter.
//!
//! TAP is the lingua franca of older and Perl-adjacent CI stacks and of
//! `prove`/`tappy`-style consumers. The shape this reporter commits to:
//!
//! * A `TAP version 14` header and a `1..N` plan line.
//! * One `ok`/`not ok` line per test in execution order, numbered from 1,
//!   with the test name as the description.
//! * Skipped tests render as `ok N - name # SKIP`.
//! * A failing test is followed by a YAML diagnostic block (`---` ... `...`)
//!   carrying the message and, when present, the diff under `data.diff` as a
//!   literal block scalar.
//!
//! Every user-supplied string is run through the attached [`Redactor`] (the
//! dispatch site also redacts via [`Report::redacted`]) and YAML-quoted so a
//! stray colon or quote never breaks the diagnostic block.

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

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

/// TAP version 14 reporter; see module docs.
///
/// Accepts an optional [`Redactor`] via [`TapReporter::with_redactor`].
#[derive(Default)]
pub struct TapReporter {
    redactor: Option<Arc<Redactor>>,
}

impl TapReporter {
    /// 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 render_test(&self, n: usize, test: &TestResult, out: &mut dyn Write) -> std::io::Result<()> {
        let desc = sanitize_description(&self.redact(&test.name));
        match test.status {
            TestStatus::Pass => writeln!(out, "ok {n} - {desc}")?,
            TestStatus::Skip => writeln!(out, "ok {n} - {desc} # SKIP")?,
            TestStatus::Inconclusive => writeln!(out, "ok {n} - {desc} # TODO inconclusive")?,
            TestStatus::Fail => {
                writeln!(out, "not ok {n} - {desc}")?;
                self.render_diagnostic(test, out)?;
            }
        }
        Ok(())
    }

    /// Emit the YAML diagnostic block for a failing test.
    fn render_diagnostic(&self, test: &TestResult, out: &mut dyn Write) -> std::io::Result<()> {
        let Some(failure) = &test.failure else {
            return Ok(());
        };
        writeln!(out, "  ---")?;
        writeln!(
            out,
            "  message: {}",
            yaml_single_quote(&self.redact(&failure.message))
        )?;
        writeln!(out, "  severity: fail")?;
        if let Some(diff) = &failure.diff {
            let diff = self.redact(diff);
            writeln!(out, "  data:")?;
            writeln!(out, "    diff: |-")?;
            for line in diff.lines() {
                writeln!(out, "      {line}")?;
            }
        }
        writeln!(out, "  ...")?;
        Ok(())
    }
}

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

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        writeln!(out, "TAP version 14")?;
        writeln!(out, "1..{}", report.tests.len())?;
        for (i, test) in report.tests.iter().enumerate() {
            self.render_test(i + 1, test, out)?;
        }
        Ok(())
    }
}

/// Strip the characters that would break a TAP description line: a `#`
/// starts a directive and a newline ends the line. Both are replaced with a
/// space so the description stays on one line and never opens a spurious
/// `# SKIP`/`# TODO` directive.
fn sanitize_description(s: &str) -> String {
    s.replace(['#', '\n', '\r'], " ")
}

/// YAML single-quote a scalar: wrap in single quotes and double any embedded
/// single quote. Safe for arbitrary one-line content in the diagnostic block.
fn yaml_single_quote(s: &str) -> String {
    let escaped = s.replace('\'', "''").replace(['\n', '\r'], " ");
    format!("'{escaped}'")
}

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

    #[test]
    fn tap_emits_header_plan_and_one_line_per_test() {
        let r = Report::sample();
        let mut buf = Vec::new();
        TapReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.starts_with("TAP version 14\n1..3\n"));
        assert!(s.contains("ok 1 - search returns at least one hit"));
        assert!(s.contains("not ok 3 - create_issue rejects empty title"));
    }

    #[test]
    fn tap_failing_test_carries_a_yaml_diagnostic() {
        let r = Report::sample();
        let mut buf = Vec::new();
        TapReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("  ---"));
        assert!(s.contains("  message: 'expected error but tool returned ok'"));
        assert!(s.contains("    diff: |-"));
        assert!(s.contains("  ..."));
    }

    #[test]
    fn tap_renders_skip_with_directive() {
        let mut r = Report::sample();
        r.tests[1].status = TestStatus::Skip;
        let mut buf = Vec::new();
        TapReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("ok 2 - list_issues respects pagination # SKIP"));
    }

    #[test]
    fn tap_name_is_stable() {
        assert_eq!(TapReporter::default().name(), "tap");
    }

    #[test]
    fn tap_sanitizes_hash_in_description() {
        let mut r = Report::sample();
        r.tests[0].name = "handles # in name".into();
        let mut buf = Vec::new();
        TapReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // The `#` is replaced so it cannot open a spurious directive.
        assert!(s.contains("ok 1 - handles   in name"));
        assert!(!s.contains("ok 1 - handles # in name"));
    }

    #[test]
    fn tap_redacts_token_shaped_test_name() {
        let mut r = Report::sample();
        r.tests[0].name = "ghp_abcdefghijklmnop1234".into();
        let reporter = TapReporter::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]"));
    }

    #[test]
    fn tap_escapes_single_quote_in_message() {
        let mut r = Report::sample();
        r.tests[2].failure = Some(TestFailure {
            message: "can't connect".into(),
            diff: None,
            network_diagnostic: None,
        });
        let mut buf = Vec::new();
        TapReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("  message: 'can''t connect'"));
    }
}
