//! GitHub Actions inline-annotations reporter (composable wrapper).
//!
//! The wrapper renders an inner reporter (pretty, json, etc.) to `out`
//! exactly as that reporter would, then writes one GitHub Actions
//! workflow command per failure or warning to a separate stderr-bound
//! writer. The operator runs `--reporter pretty --annotations` and
//! gets the human-readable run plus inline annotations in the PR diff
//! view.
//!
//! Why a wrapper (not a standalone reporter): annotations are useless
//! without a primary format. Composing them onto whatever the operator
//! already uses keeps the CLI flag set small and lets every existing
//! reporter benefit without duplication.
//!
//! Detection: the wrapper auto-detects `GITHUB_ACTIONS=true` and
//! becomes a no-op outside Actions. [`AnnotationsReporter::force`]
//! overrides the env check (tests use this, the CLI may expose it as
//! `--annotations always`). See PRD section 10 and .

use std::io::Write;
use std::sync::Mutex;

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

/// Wraps another [`Reporter`] and adds GitHub Actions workflow
/// commands (`::error`, `::warning`, `::notice`) on a stderr-bound
/// writer when failures or warnings are present.
///
/// Use [`AnnotationsReporter::new`] to wrap any reporter that writes to
/// `out`; annotations always go to a separate `Write` so the primary
/// stdout-bound output stays clean for log scrapers and downstream
/// consumers. The default annotation sink is [`std::io::stderr`], which
/// is what Actions parses workflow commands from.
pub struct AnnotationsReporter<R: Reporter> {
    inner: R,
    /// Stderr-bound writer for workflow commands. The Mutex lets the
    /// wrapper take `&self` from the Reporter trait while still mutating
    /// a writer that does not implement Clone. Tests inject a `Vec<u8>`
    /// via [`AnnotationsReporter::with_annotation_writer`] to capture
    /// output without touching the real stderr.
    annotation_writer: Mutex<Box<dyn Write + Send>>,
    /// When `Some`, overrides the `GITHUB_ACTIONS` env detection. Tests
    /// and the eventual `--annotations always` flag rely on this.
    force: Option<bool>,
}

impl<R: Reporter> AnnotationsReporter<R> {
    /// Wrap `inner` with annotation emission. Annotations go to stderr
    /// when the wrapper is active. Active means `GITHUB_ACTIONS=true`
    /// unless overridden via [`AnnotationsReporter::force`].
    pub fn new(inner: R) -> Self {
        Self {
            inner,
            annotation_writer: Mutex::new(Box::new(std::io::stderr())),
            force: None,
        }
    }

    /// Override the auto-detected `GITHUB_ACTIONS` env check. Pass
    /// `Some(true)` to always emit, `Some(false)` to never emit, or
    /// leave `None` to honor the environment. The CLI uses this to
    /// implement `--annotations always|never|auto`.
    pub fn force(mut self, enabled: bool) -> Self {
        self.force = Some(enabled);
        self
    }

    /// Replace the annotation sink. Tests pass a `Vec<u8>` here so
    /// workflow-command output is captured separately from the
    /// inner-reporter output.
    pub fn with_annotation_writer<W: Write + Send + 'static>(mut self, writer: W) -> Self {
        self.annotation_writer = Mutex::new(Box::new(writer));
        self
    }

    fn is_active(&self) -> bool {
        if let Some(forced) = self.force {
            return forced;
        }
        matches!(
            std::env::var("GITHUB_ACTIONS").as_deref(),
            Ok("true") | Ok("TRUE")
        )
    }
}

impl<R: Reporter> Reporter for AnnotationsReporter<R> {
    fn name(&self) -> &'static str {
        "annotations"
    }

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        self.inner.render(report, out)?;
        if !self.is_active() {
            return Ok(());
        }
        let mut guard = self
            .annotation_writer
            .lock()
            .map_err(|_| std::io::Error::other("annotation writer mutex poisoned"))?;
        emit_annotations(report, &mut **guard)
    }
}

/// Emit one GitHub Actions workflow command per relevant test in
/// `report` to `out`. Public so callers that already hold a `Report`
/// can plug into the same emission path without going through the
/// wrapper (the CLI eventually wires `--annotations` to this).
///
/// Failures emit `::error`. Skipped tests emit `::notice`. Passing
/// tests emit nothing. Warnings will be added once the runner tags
/// flaky-retry events; until then the `::warning` path is exercised
/// via [`emit_warning`].
pub fn emit_annotations(report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
    for test in &report.tests {
        match test.status {
            TestStatus::Fail => emit_one(test, "error", failure_message(test), out)?,
            TestStatus::Skip => emit_one(test, "notice", "test skipped".to_string(), out)?,
            TestStatus::Inconclusive => {
                emit_one(test, "notice", "test inconclusive".to_string(), out)?
            }
            TestStatus::Pass => {}
        }
    }
    Ok(())
}

/// Emit a single `::warning` annotation for `test`. The runner will
/// call this when a flaky-retry recovers (PRD section 10). Kept
/// separate from [`emit_annotations`] because warnings are
/// per-event, not derivable from the final [`Report`].
pub fn emit_warning(test: &TestResult, message: &str, out: &mut dyn Write) -> std::io::Result<()> {
    emit_one(test, "warning", message.to_string(), out)
}

fn emit_one(
    test: &TestResult,
    level: &str,
    message: String,
    out: &mut dyn Write,
) -> std::io::Result<()> {
    let file = test.file.as_deref().unwrap_or("");
    let line = test.line.unwrap_or(1);
    let prefix = if file.is_empty() {
        format!("::{level}::")
    } else {
        format!("::{level} file={},line={}::", escape_property(file), line)
    };
    let escaped = escape_message(&message);
    writeln!(out, "{prefix}{escaped}")
}

fn failure_message(test: &TestResult) -> String {
    match &test.failure {
        Some(f) => format!("{}: {}", test.name, f.message),
        None => format!("{}: test failed", test.name),
    }
}

/// Escape the *message* segment of a workflow command. GitHub parses
/// `%`, `\r`, `\n` so we encode them. See
/// https://docs.github.com/en/actions/using-workflow-commands.
fn escape_message(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '%' => out.push_str("%25"),
            '\r' => out.push_str("%0D"),
            '\n' => out.push_str("%0A"),
            c => out.push(c),
        }
    }
    out
}

/// Escape a *property value* (the right-hand side of `file=...`).
/// Properties additionally need `,` and `:` encoded so the parser
/// does not treat them as separators.
fn escape_property(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '%' => out.push_str("%25"),
            '\r' => out.push_str("%0D"),
            '\n' => out.push_str("%0A"),
            ':' => out.push_str("%3A"),
            ',' => out.push_str("%2C"),
            c => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::reporters::PrettyReporter;
    use crate::report::{Report, TestFailure, TestResult, TestStatus};

    /// In-memory writer used to capture annotation output in tests.
    /// `Vec<u8>` itself does not implement `Send + Sync` lifetimes the
    /// way we want for the Mutex, so we share via `Arc<Mutex<Vec<u8>>>`
    /// indirectly through a thin newtype.
    #[derive(Default, Clone)]
    struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);

    impl Write for SharedBuf {
        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
            self.0
                .lock()
                .map_err(|_| std::io::Error::other("poisoned"))?
                .extend_from_slice(buf);
            Ok(buf.len())
        }
        fn flush(&mut self) -> std::io::Result<()> {
            Ok(())
        }
    }

    impl SharedBuf {
        fn into_string(self) -> String {
            let v = self.0.lock().expect("poisoned").clone();
            String::from_utf8(v).expect("utf-8")
        }
    }

    fn run(reporter: &AnnotationsReporter<PrettyReporter>, report: &Report) -> (String, String) {
        let mut stdout = Vec::new();
        reporter.render(report, &mut stdout).expect("render");
        let primary = String::from_utf8(stdout).expect("utf-8");
        let annotations = {
            let guard = reporter.annotation_writer.lock().expect("not poisoned");
            // The writer is `Box<dyn Write>`; we cannot introspect it,
            // so tests construct the reporter with a SharedBuf and
            // read the buf via their own handle. This helper is only
            // used when the caller does not need the annotation body.
            drop(guard);
            String::new()
        };
        (primary, annotations)
    }

    #[test]
    fn emits_error_annotation_for_each_failure() {
        let report = Report::sample();
        let buf = SharedBuf::default();
        let reporter = AnnotationsReporter::new(PrettyReporter::default())
            .force(true)
            .with_annotation_writer(buf.clone());
        let _ = run(&reporter, &report);
        let annotations = buf.into_string();
        assert!(
            annotations.contains("::error file=tests/create_issue.yml,line=22::"),
            "expected error annotation, got: {annotations:?}"
        );
        assert!(annotations.contains("create_issue rejects empty title"));
    }

    #[test]
    fn emits_no_annotations_when_run_has_no_failures() {
        let report = Report::sample_all_pass();
        let buf = SharedBuf::default();
        let reporter = AnnotationsReporter::new(PrettyReporter::default())
            .force(true)
            .with_annotation_writer(buf.clone());
        let _ = run(&reporter, &report);
        let annotations = buf.into_string();
        assert_eq!(annotations, "");
    }

    #[test]
    fn no_op_when_github_actions_env_is_unset() {
        // force=false simulates GITHUB_ACTIONS missing; we do not touch
        // process env so parallel tests stay isolated.
        let report = Report::sample();
        let buf = SharedBuf::default();
        let reporter = AnnotationsReporter::new(PrettyReporter::default())
            .force(false)
            .with_annotation_writer(buf.clone());
        let _ = run(&reporter, &report);
        assert_eq!(buf.into_string(), "");
    }

    #[test]
    fn primary_output_matches_inner_reporter_byte_for_byte() {
        let report = Report::sample();
        let mut inner_only = Vec::new();
        PrettyReporter::default()
            .render(&report, &mut inner_only)
            .expect("inner render");
        let buf = SharedBuf::default();
        let reporter = AnnotationsReporter::new(PrettyReporter::default())
            .force(true)
            .with_annotation_writer(buf);
        let mut wrapped = Vec::new();
        reporter.render(&report, &mut wrapped).expect("wrapped");
        assert_eq!(inner_only, wrapped);
    }

    #[test]
    fn escapes_percent_cr_lf_in_message() {
        let mut report = Report::sample_all_pass();
        report.tests.push(TestResult {
            name: "weird".into(),
            status: TestStatus::Fail,
            duration_ms: 1,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: Some(TestFailure {
                message: "100% bad\r\nsecond line".into(),
                diff: None,
                network_diagnostic: None,
            }),
            file: Some("tests/weird.yml".into()),
            line: Some(5),
            steps: Vec::new(),
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
            natural_language: false,
        });
        report.summary.failed = 1;
        report.summary.total += 1;
        let buf = SharedBuf::default();
        let reporter = AnnotationsReporter::new(PrettyReporter::default())
            .force(true)
            .with_annotation_writer(buf.clone());
        let _ = run(&reporter, &report);
        let out = buf.into_string();
        assert!(out.contains("100%25 bad%0D%0Asecond line"), "got: {out:?}");
    }

    #[test]
    fn escapes_comma_and_colon_in_file_property() {
        let mut report = Report::sample_all_pass();
        report.tests.push(TestResult {
            name: "odd path".into(),
            status: TestStatus::Fail,
            duration_ms: 1,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: Some(TestFailure {
                message: "boom".into(),
                diff: None,
                network_diagnostic: None,
            }),
            file: Some("tests/a,b:c.yml".into()),
            line: Some(9),
            steps: Vec::new(),
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
            natural_language: false,
        });
        report.summary.failed = 1;
        report.summary.total += 1;
        let buf = SharedBuf::default();
        let reporter = AnnotationsReporter::new(PrettyReporter::default())
            .force(true)
            .with_annotation_writer(buf.clone());
        let _ = run(&reporter, &report);
        let out = buf.into_string();
        assert!(
            out.contains("file=tests/a%2Cb%3Ac.yml,line=9"),
            "got: {out:?}"
        );
    }

    #[test]
    fn emit_warning_writes_warning_workflow_command() {
        let test = TestResult {
            name: "flaky".into(),
            status: TestStatus::Pass,
            duration_ms: 5,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: None,
            file: Some("tests/flaky.yml".into()),
            line: Some(11),
            steps: Vec::new(),
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
            natural_language: false,
        };
        let mut buf = Vec::new();
        emit_warning(&test, "retried once after timeout", &mut buf).expect("write");
        let out = String::from_utf8(buf).expect("utf-8");
        assert_eq!(
            out,
            "::warning file=tests/flaky.yml,line=11::retried once after timeout\n"
        );
    }

    #[test]
    fn skipped_tests_render_as_notice() {
        let mut report = Report::sample_all_pass();
        report.tests[0].status = TestStatus::Skip;
        report.summary.passed = 0;
        report.summary.skipped = 1;
        let buf = SharedBuf::default();
        let reporter = AnnotationsReporter::new(PrettyReporter::default())
            .force(true)
            .with_annotation_writer(buf.clone());
        let _ = run(&reporter, &report);
        let out = buf.into_string();
        assert!(out.starts_with("::notice file=tests/smoke.yml,line=1::"));
        assert!(out.contains("test skipped"));
    }

    #[test]
    fn omits_file_and_line_when_test_has_no_source_location() {
        let test = TestResult {
            name: "no-source".into(),
            status: TestStatus::Fail,
            duration_ms: 1,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: Some(TestFailure {
                message: "boom".into(),
                diff: None,
                network_diagnostic: None,
            }),
            file: None,
            line: None,
            steps: Vec::new(),
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
            natural_language: false,
        };
        let tests = vec![test];
        let report = Report {
            schema_version: "0.1".into(),
            mcptest_version: crate::version::full().into(),
            generated_at: "now".into(),
            run_id: "r".into(),
            summary: crate::report::RunSummary {
                duration_ms: 1,
                ..crate::report::RunSummary::from_tests(&tests)
            },
            tests,
            cost: None,
            metadata: None,
            matrix: None,
            protocol_violations: Vec::new(),
        };
        let buf = SharedBuf::default();
        let reporter = AnnotationsReporter::new(PrettyReporter::default())
            .force(true)
            .with_annotation_writer(buf.clone());
        let _ = run(&reporter, &report);
        let out = buf.into_string();
        assert!(out.starts_with("::error::"), "got: {out:?}");
        assert!(out.contains("no-source: boom"));
    }
}
