//! GitHub-flavored Markdown reporter.
//!
//! Designed for GitHub PR comments, GitHub Actions job summaries, and
//! gists. The output shape:
//!
//! 1. An `## mcptest run <id>` heading and a single-row pass-rate
//!    summary table with counts (passed / failed / cached / total)
//!    plus a percentage row.
//! 2. A per-failed-test collapsible `<details><summary>` block whose
//!    body is a triple-backtick fenced code block holding the diff.
//! 3. A compact passed-tests table with one row per test (name +
//!    duration) so reviewers can spot slow-but-green outliers.
//! 4. A "Slowest tests" callout, when there is more than one test.
//!
//! Size cap: GitHub PR comments hard-cap at 65 KB. When we cross it
//! we keep the summary tables intact and truncate the failure list,
//! ending with `...N more failures, view full report at <link>` so
//! readers know to look elsewhere.
//!
//! Compatibility: GFM tables (pipe syntax), GFM fenced code blocks
//! (triple-backtick), and HTML `<details>` blocks. No `<summary type=...>`
//! and no HTML tables, both of which render badly in mobile views.

use std::fmt::Write as _;
use std::io::Write;
use std::sync::Arc;

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

/// GitHub PR comment body limit. The official ceiling is 65,536 bytes
/// (64 KiB plus the 1 KiB header allowance) and rendered output has
/// to fit inside it. We aim a hair below so a CI runner appending a
/// run-id footer stays inside the limit.
const SIZE_CAP_BYTES: usize = 65_000;

/// Anchor text shown at the bottom of a truncated report. The hosted
/// URL is supplied at runtime; with no link we fall back to a plain
/// `view full report` marker so the contract test still passes.
const FALLBACK_FULL_REPORT_LINK: &str = "view full report";

/// GitHub-flavored Markdown reporter (see module docs).
///
/// Accepts an optional [`Redactor`] via [`MarkdownReporter::with_redactor`].
/// When set, user-supplied strings (test name, failure message, diff
/// body) are run through the redactor before Markdown escaping. Prefer
/// [`Report::redacted`] at the dispatch site when redacting many
/// reporters at once.
#[derive(Default)]
pub struct MarkdownReporter {
    redactor: Option<Arc<Redactor>>,
    full_report_link: Option<String>,
    coverage: Option<CoverageReport>,
}

impl MarkdownReporter {
    /// 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
    }

    /// Attach a hyperlink shown in the truncation marker so reviewers
    /// who hit the 65 KB cap know where the full report lives.
    pub fn with_full_report_link(mut self, link: impl Into<String>) -> Self {
        self.full_report_link = Some(link.into());
        self
    }

    /// Attach a coverage report so the PR comment includes a per-tool
    /// breakdown table.
    pub fn with_coverage(mut self, coverage: CoverageReport) -> Self {
        self.coverage = Some(coverage);
        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),
        }
    }
}

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

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        // Build into a String first so we can enforce the 65 KB cap
        // before any bytes hit the wire.
        let body = self.render_to_string(report);
        out.write_all(body.as_bytes())
    }
}

impl MarkdownReporter {
    fn render_to_string(&self, report: &Report) -> String {
        let mut out = String::new();
        self.render_heading(&mut out, report);
        self.render_summary_table(&mut out, report);
        let failures: Vec<&TestResult> = report
            .tests
            .iter()
            .filter(|t| matches!(t.status, TestStatus::Fail))
            .collect();
        let passes: Vec<&TestResult> = report
            .tests
            .iter()
            .filter(|t| matches!(t.status, TestStatus::Pass))
            .collect();
        self.render_failures(&mut out, &failures);
        self.render_passed_summary(&mut out, &passes);
        self.render_slowest(&mut out, report);
        self.render_metric_rollups(&mut out, report);
        if let Some(c) = self.coverage.as_ref() {
            out.push_str(&c.render_markdown());
        }
        if out.len() > SIZE_CAP_BYTES {
            out = self.render_truncated(report, &failures, &passes);
        }
        out
    }

    fn render_heading(&self, out: &mut String, report: &Report) {
        let _ = writeln!(out, "## mcptest run {}", md_escape(&report.run_id));
        let _ = writeln!(out);
    }

    fn render_summary_table(&self, out: &mut String, report: &Report) {
        // GFM table: count row, then pass-rate row. Passed / failed /
        // cached / total + a percentage so a reviewer sees the verdict
        // before deciding to expand any details blocks.
        let s = &report.summary;
        let pct = if s.total == 0 {
            0.0
        } else {
            (s.passed as f64 / s.total as f64) * 100.0
        };
        let _ = writeln!(out, "| Passed | Failed | Cached | Total | Pass rate |");
        let _ = writeln!(out, "| --- | --- | --- | --- | --- |");
        let _ = writeln!(
            out,
            "| {} | {} | {} | {} | {:.1}% |",
            s.passed, s.failed, s.cache_hits, s.total, pct
        );
        let _ = writeln!(out);
        let _ = writeln!(
            out,
            "Run duration: {} ms ({} skipped)",
            s.duration_ms, s.skipped
        );
        let _ = writeln!(out);
    }

    fn render_failures(&self, out: &mut String, failures: &[&TestResult]) {
        if failures.is_empty() {
            return;
        }
        let _ = writeln!(out, "### Failures");
        let _ = writeln!(out);
        for test in failures {
            self.render_one_failure(out, test);
        }
    }

    fn render_one_failure(&self, out: &mut String, test: &TestResult) {
        let Some(failure) = &test.failure else {
            return;
        };
        let name_red = self.redact(&test.name);
        let msg_red = self.redact(&failure.message);
        let _ = writeln!(
            out,
            "<details><summary>{} ({}): {}</summary>",
            md_escape(&name_red),
            test.duration_ms,
            md_escape(&msg_red),
        );
        let _ = writeln!(out);
        if let Some(diff) = &failure.diff {
            let diff_red = self.redact(diff);
            let _ = writeln!(out, "```");
            for line in diff_red.lines() {
                let _ = writeln!(out, "{line}");
            }
            let _ = writeln!(out, "```");
        }
        let _ = writeln!(out);
        let _ = writeln!(out, "</details>");
        let _ = writeln!(out);
    }

    fn render_passed_summary(&self, out: &mut String, passes: &[&TestResult]) {
        if passes.is_empty() {
            return;
        }
        let _ = writeln!(out, "### Passed");
        let _ = writeln!(out);
        let _ = writeln!(out, "| Test | Duration |");
        let _ = writeln!(out, "| --- | --- |");
        for test in passes {
            let name_red = self.redact(&test.name);
            let cached = if test.cache_hit { " (cached)" } else { "" };
            let _ = writeln!(
                out,
                "| {} | {} ms{} |",
                md_escape(&name_red),
                test.duration_ms,
                cached,
            );
        }
        let _ = writeln!(out);
    }

    /// Suite-level rollup of named metrics: one row per metric with its mean
    /// across the tests that reported it and the count. Omitted when no test
    /// reported a derived metric.
    fn render_metric_rollups(&self, out: &mut String, report: &Report) {
        let rollups = report.metric_rollups();
        if rollups.is_empty() {
            return;
        }
        let _ = writeln!(out, "### Metric rollups");
        let _ = writeln!(out);
        let _ = writeln!(out, "| Metric | Mean | Tests |");
        let _ = writeln!(out, "| --- | --- | --- |");
        for r in rollups {
            let _ = writeln!(
                out,
                "| {} | {:.2} | {} |",
                md_escape(&r.name),
                r.mean,
                r.count
            );
        }
        let _ = writeln!(out);
    }

    fn render_slowest(&self, out: &mut String, report: &Report) {
        let slowest = report.slowest(5);
        if slowest.len() <= 1 {
            return;
        }
        let _ = writeln!(out, "### Slowest tests");
        let _ = writeln!(out);
        for (i, test) in slowest.iter().enumerate() {
            let name = self.redact(&test.name);
            let _ = writeln!(
                out,
                "{}. {} ({} ms)",
                i + 1,
                md_escape(&name),
                test.duration_ms
            );
        }
        let _ = writeln!(out);
    }

    /// When the un-truncated body would exceed 65 KB, drop failure
    /// detail blocks until the total fits, then append a one-line
    /// "...N more failures, view full report at <link>" footer.
    fn render_truncated(
        &self,
        report: &Report,
        failures: &[&TestResult],
        passes: &[&TestResult],
    ) -> String {
        let mut out = String::new();
        self.render_heading(&mut out, report);
        self.render_summary_table(&mut out, report);
        // Reserve room for the truncation marker (a generous 256 B
        // ceiling). We add failures one at a time until adding the
        // next one would push us over the cap.
        let reservation = 256usize;
        let cap = SIZE_CAP_BYTES.saturating_sub(reservation);
        let mut shown = 0usize;
        if !failures.is_empty() {
            let _ = writeln!(&mut out, "### Failures");
            let _ = writeln!(&mut out);
        }
        for test in failures {
            let mut tentative = String::new();
            self.render_one_failure(&mut tentative, test);
            if out.len() + tentative.len() > cap {
                break;
            }
            out.push_str(&tentative);
            shown += 1;
        }
        let remaining = failures.len().saturating_sub(shown);
        if remaining > 0 {
            let link = self
                .full_report_link
                .as_deref()
                .unwrap_or(FALLBACK_FULL_REPORT_LINK);
            let _ = writeln!(
                &mut out,
                "...{remaining} more failures, view full report at {link}"
            );
            let _ = writeln!(&mut out);
        }
        // Passed summary and slowest sections are intentionally dropped
        // when truncated; they would push us back over the cap.
        // Mention the count instead so the reader knows they exist.
        if !passes.is_empty() {
            let _ = writeln!(&mut out, "### Passed");
            let _ = writeln!(&mut out);
            let _ = writeln!(
                &mut out,
                "{} passing tests omitted from truncated view.",
                passes.len()
            );
        }
        out
    }
}

/// Escape the Markdown special characters that would break inline
/// formatting if a test name contains them. We escape the conservative
/// set called out by GitHub's GFM spec: `|`, `<`, `>`, `*`, `_`.
fn md_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '|' | '<' | '>' | '*' | '_' => {
                out.push('\\');
                out.push(ch);
            }
            _ => out.push(ch),
        }
    }
    out
}

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

    #[test]
    fn markdown_reporter_emits_heading_and_summary_table() {
        let r = Report::sample();
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("## mcptest run "));
        // Required pass / fail / cached / total + pass-rate row.
        assert!(s.contains("| Passed | Failed | Cached | Total | Pass rate |"));
        assert!(s.contains("| 2 | 1 | 1 | 3 | 66.7% |"));
    }

    #[test]
    fn markdown_reporter_wraps_failure_in_collapsible_block() {
        let r = Report::sample();
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("<details><summary>"));
        assert!(s.contains("expected error but tool returned ok"));
        assert!(s.contains("```"));
        assert!(s.contains("expected: error code -32602"));
        assert!(s.contains("</details>"));
    }

    #[test]
    fn markdown_reporter_escapes_special_characters() {
        let mut r = Report::sample();
        r.tests[0].name = "pipe|and<tag>and*star_underscore".into();
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("pipe\\|and\\<tag\\>and\\*star\\_underscore"));
    }

    #[test]
    fn markdown_reporter_handles_zero_failure_report() {
        let r = Report::sample_all_pass();
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // 1 passed, 0 failed, 0 cached, 1 total => 100.0%
        assert!(s.contains("| 1 | 0 | 0 | 1 | 100.0% |"));
        assert!(!s.contains("<details>"));
    }

    #[test]
    fn markdown_reporter_name_is_stable() {
        assert_eq!(MarkdownReporter::default().name(), "md");
    }

    #[test]
    fn markdown_reporter_emits_passed_summary_table_with_duration_column() {
        let r = Report::sample();
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // Distinct from the top summary table. Passed-only section
        // shows test name + duration per row.
        assert!(s.contains("### Passed"));
        assert!(s.contains("| Test | Duration |"));
        assert!(s.contains("| --- | --- |"));
        assert!(s.contains("| 80 ms |"));
        assert!(s.contains("| 1 ms (cached) |"));
    }

    #[test]
    fn markdown_reporter_emits_slowest_section() {
        let r = Report::sample();
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("### Slowest tests"));
    }

    #[test]
    fn markdown_reporter_redacts_token_shaped_test_name() {
        let mut r = Report::sample();
        r.tests[0].name = "ghp_abcdefghijklmnop1234".into();
        let reporter = MarkdownReporter::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 markdown_reporter_truncates_oversized_report_with_marker() {
        // Synthetic 200-failure report exceeds the 65 KB cap; the
        // reporter must drop trailing failure blocks and emit a
        // "...N more failures" marker.
        let mut r = Report::sample();
        let big_diff = "z".repeat(1024); // ~1 KB per failure block
        r.tests.clear();
        for i in 0..200 {
            r.tests.push(TestResult {
                name: format!("failing test {i}"),
                status: TestStatus::Fail,
                duration_ms: 5,
                cache_hit: false,
                started_at: None,
                ended_at: None,
                failure: Some(TestFailure {
                    message: format!("assertion {i} failed"),
                    diff: Some(big_diff.clone()),
                    network_diagnostic: None,
                }),
                file: Some(format!("tests/case_{i}.yml")),
                line: Some(1),
                steps: Vec::new(),
                consensus: None,
                rule_id: None,
                severity: None,
                score: None,
                derived_metrics: Vec::new(),
                natural_language: false,
            });
        }
        r.summary.total = 200;
        r.summary.failed = 200;
        r.summary.passed = 0;
        r.summary.cache_hits = 0;
        let reporter =
            MarkdownReporter::default().with_full_report_link("https://example.com/run/abc");
        let mut buf = Vec::new();
        reporter.render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(
            s.len() <= SIZE_CAP_BYTES,
            "rendered body {} exceeded cap {}",
            s.len(),
            SIZE_CAP_BYTES
        );
        assert!(s.contains("more failures"));
        assert!(s.contains("https://example.com/run/abc"));
    }

    #[test]
    fn markdown_reporter_includes_coverage_table_when_attached() {
        let r = Report::sample();
        let mut buf = Vec::new();
        MarkdownReporter::default()
            .with_coverage(CoverageReport::sample())
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("## Coverage"));
        assert!(s.contains("| Tool |"));
        assert!(s.contains("`create_issue`"));
    }

    #[test]
    fn markdown_reporter_renders_metric_rollups_when_present() {
        let mut r = Report::sample();
        r.tests[0].derived_metrics = vec![crate::report::DerivedMetricReport {
            name: "factuality".into(),
            value: 0.82,
            passed: None,
        }];
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("### Metric rollups"));
        assert!(s.contains("| factuality | 0.82 | 1 |"));
    }

    #[test]
    fn markdown_reporter_omits_rollups_when_absent() {
        let r = Report::sample();
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("Metric rollups"));
    }

    #[test]
    fn markdown_reporter_omits_coverage_when_unset() {
        let r = Report::sample();
        let mut buf = Vec::new();
        MarkdownReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("## Coverage"));
    }
}
