use std::collections::HashMap;
use std::io::{self, Write};

use serde::Serialize;

use crate::detection::finding::{Finding, FindingCategory, Severity};

// ─── Summary ──────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ScanSummary {
    pub total_findings: usize,
    pub by_severity: HashMap<String, usize>,
    pub by_category: HashMap<String, usize>,
    /// Probability estimate that the code is AI-generated (0.0–1.0).
    pub ai_probability: f32,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ExplainabilityContribution {
    pub category: String,
    pub finding_count: usize,
    pub weighted_confidence: f32,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ExplainabilityEvidence {
    pub finding_id: String,
    pub category: String,
    pub severity: String,
    pub confidence_score: f32,
    pub confidence_contribution: f32,
    pub file_path: String,
    pub line: Option<usize>,
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ExplainabilityReport {
    pub mixed_mode: bool,
    pub category_contributions: Vec<ExplainabilityContribution>,
    pub evidence: Vec<ExplainabilityEvidence>,
}

pub fn build_explainability(findings: &[Finding], mixed_mode: bool) -> ExplainabilityReport {
    let mut by_category: HashMap<String, (usize, f32)> = HashMap::new();
    let mut evidence = Vec::with_capacity(findings.len());

    for f in findings {
        let category = category_label(f.category);
        let severity = severity_label(f.severity);
        let weight = match f.severity {
            Severity::High => 1.0_f32,
            Severity::Medium => 0.6,
            Severity::Low => 0.2,
        };
        let confidence_contribution = weight * f.confidence_score;

        let entry = by_category.entry(category.clone()).or_insert((0, 0.0));
        entry.0 += 1;
        entry.1 += confidence_contribution;

        evidence.push(ExplainabilityEvidence {
            finding_id: f.id.clone(),
            category,
            severity,
            confidence_score: f.confidence_score,
            confidence_contribution,
            file_path: f.file_path.to_string_lossy().into_owned(),
            line: f.line_range.map(|r| r.start),
            reason: f.description.clone(),
        });
    }

    evidence.sort_by(|a, b| {
        b.confidence_contribution
            .total_cmp(&a.confidence_contribution)
            .then_with(|| a.file_path.cmp(&b.file_path))
            .then_with(|| a.finding_id.cmp(&b.finding_id))
    });

    let mut category_contributions = by_category
        .into_iter()
        .map(
            |(category, (finding_count, weighted_confidence))| ExplainabilityContribution {
                category,
                finding_count,
                weighted_confidence,
            },
        )
        .collect::<Vec<_>>();

    category_contributions.sort_by(|a, b| {
        b.weighted_confidence
            .total_cmp(&a.weighted_confidence)
            .then_with(|| a.category.cmp(&b.category))
    });

    ExplainabilityReport {
        mixed_mode,
        category_contributions,
        evidence,
    }
}

#[expect(
    clippy::cast_precision_loss,
    reason = "bounded finding count: no meaningful precision loss"
)]
pub fn build_summary(findings: &[Finding]) -> ScanSummary {
    let mut by_severity: HashMap<String, usize> = HashMap::new();
    let mut by_category: HashMap<String, usize> = HashMap::new();
    let mut weighted_score = 0.0_f32;

    for f in findings {
        *by_severity.entry(severity_label(f.severity)).or_insert(0) += 1;
        *by_category.entry(category_label(f.category)).or_insert(0) += 1;

        let weight = match f.severity {
            Severity::High => 1.0_f32,
            Severity::Medium => 0.6,
            Severity::Low => 0.2,
        };
        weighted_score += weight * f.confidence_score;
    }

    let ai_probability = if findings.is_empty() {
        0.0_f32
    } else {
        let raw = weighted_score / findings.len() as f32;
        1.0 / (1.0 + (-8.0 * (raw - 0.5)).exp())
    };

    ScanSummary {
        total_findings: findings.len(),
        by_severity,
        by_category,
        ai_probability: ai_probability.clamp(0.0, 1.0),
    }
}

/// ANSI colour helpers — all no-ops when `use_color` is false.
struct Ansi {
    use_color: bool,
}

impl Ansi {
    const RESET: &'static str = "\x1b[0m";
    const BOLD: &'static str = "\x1b[1m";
    const DIM: &'static str = "\x1b[2m";
    const RED: &'static str = "\x1b[31m";
    const YELLOW: &'static str = "\x1b[33m";
    const CYAN: &'static str = "\x1b[36m";
    const GREEN: &'static str = "\x1b[32m";

    fn wrap<'a>(&self, codes: &'static str, s: &'a str) -> std::borrow::Cow<'a, str> {
        if self.use_color {
            format!("{codes}{s}{reset}", reset = Self::RESET).into()
        } else {
            s.into()
        }
    }

    fn bold<'a>(&self, s: &'a str) -> std::borrow::Cow<'a, str> {
        self.wrap(Self::BOLD, s)
    }

    fn dim<'a>(&self, s: &'a str) -> std::borrow::Cow<'a, str> {
        self.wrap(Self::DIM, s)
    }

    fn severity_badge(&self, s: Severity) -> String {
        let label = match s {
            Severity::High => "HIGH",
            Severity::Medium => " MED",
            Severity::Low => " LOW",
        };
        if self.use_color {
            let color = match s {
                Severity::High => Self::RED,
                Severity::Medium => Self::YELLOW,
                Severity::Low => Self::CYAN,
            };
            format!("{}{}{}{}", Self::BOLD, color, label, Self::RESET)
        } else {
            label.to_owned()
        }
    }

    fn ai_prob_color(&self, prob: f32) -> &'static str {
        if !self.use_color {
            return "";
        }
        if prob >= 0.75 {
            Self::RED
        } else if prob >= 0.50 {
            Self::YELLOW
        } else {
            Self::GREEN
        }
    }
}

///
/// Pass `use_color = true` when the sink is an interactive terminal.
/// Findings are grouped by file and sorted high→low within each group.
pub fn write_text_report(
    out: &mut impl Write,
    findings: &[Finding],
    summary: &ScanSummary,
    use_color: bool,
    explainability: Option<&ExplainabilityReport>,
) -> io::Result<()> {
    let a = Ansi { use_color };

    if findings.is_empty() {
        let pct = summary.ai_probability * 100.0;
        let color = a.ai_prob_color(summary.ai_probability);
        if use_color {
            writeln!(
                out,
                "{}No findings.{} AI likelihood {}{}{:.0}%{}",
                Ansi::BOLD,
                Ansi::RESET,
                color,
                Ansi::BOLD,
                pct,
                Ansi::RESET
            )?;
        } else {
            writeln!(out, "No findings. AI likelihood {pct:.0}%")?;
        }
        return Ok(());
    }

    write_text_findings(out, findings, &a)?;
    write_text_summary(out, summary, &a)?;
    if let Some(explainability) = explainability {
        write_text_explainability(out, explainability, &a)?;
    }
    writeln!(out, "{}", a.dim(&"─".repeat(52)))?;

    Ok(())
}

fn write_text_explainability(
    out: &mut impl Write,
    explainability: &ExplainabilityReport,
    a: &Ansi,
) -> io::Result<()> {
    writeln!(out, "{}", a.bold("Explainability"))?;
    if explainability.mixed_mode {
        writeln!(out, " {} mixed-content aggregation enabled", a.dim("•"))?;
    }
    writeln!(out, " {} category contributions:", a.dim("•"))?;
    for c in &explainability.category_contributions {
        writeln!(
            out,
            "   - {}: {} finding(s), weighted {:.2}",
            c.category, c.finding_count, c.weighted_confidence
        )?;
    }

    writeln!(out, " {} top evidence:", a.dim("•"))?;
    for e in explainability.evidence.iter().take(5) {
        if let Some(line) = e.line {
            writeln!(
                out,
                "   - [{}] {}:{} ({:.2}) {}",
                e.severity, e.file_path, line, e.confidence_contribution, e.reason
            )?;
        } else {
            writeln!(
                out,
                "   - [{}] {} ({:.2}) {}",
                e.severity, e.file_path, e.confidence_contribution, e.reason
            )?;
        }
    }
    Ok(())
}

fn write_text_findings(out: &mut impl Write, findings: &[Finding], a: &Ansi) -> io::Result<()> {
    let mut by_file: std::collections::BTreeMap<String, Vec<&Finding>> =
        std::collections::BTreeMap::new();
    for f in findings {
        by_file
            .entry(f.file_path.to_string_lossy().into_owned())
            .or_default()
            .push(f);
    }
    for (file, mut group) in by_file {
        let display_file = if file == "." {
            "(repo root)".to_owned()
        } else {
            file.clone()
        };
        writeln!(out, "{}", a.bold(&display_file))?;
        group.sort_by(|x, y| y.severity.cmp(&x.severity));
        for f in group {
            let badge = a.severity_badge(f.severity);
            let cat = category_label(f.category);
            writeln!(out, " [{badge}] {cat} — {desc}", desc = f.description)?;
            if let Some(range) = f.line_range {
                writeln!(out, " {}", a.dim(&format!("at {file}:{}", range.start)))?;
            }
            if let Some(ref suggestion) = f.suggestion {
                writeln!(out, " {} {suggestion}", a.dim("→"))?;
            }
        }
        writeln!(out)?;
    }
    Ok(())
}

fn write_text_summary(out: &mut impl Write, summary: &ScanSummary, a: &Ansi) -> io::Result<()> {
    let high = summary.by_severity.get("HIGH").copied().unwrap_or(0);
    let med = summary.by_severity.get("MED").copied().unwrap_or(0);
    let low = summary.by_severity.get("LOW").copied().unwrap_or(0);
    let pct = summary.ai_probability * 100.0;
    let pct_color = a.ai_prob_color(summary.ai_probability);

    writeln!(out, "{}", a.dim(&"─".repeat(52)))?;

    if a.use_color {
        write!(
            out,
            " {}{} findings{} {} ",
            Ansi::BOLD,
            summary.total_findings,
            Ansi::RESET,
            a.dim("·"),
        )?;
        if high > 0 {
            write!(
                out,
                "{}{}HIGH {high}{} ",
                Ansi::BOLD,
                Ansi::RED,
                Ansi::RESET
            )?;
        }
        if med > 0 {
            write!(
                out,
                "{}{}MED {med}{} ",
                Ansi::BOLD,
                Ansi::YELLOW,
                Ansi::RESET
            )?;
        }
        if low > 0 {
            write!(out, "{}{}LOW {low}{} ", Ansi::BOLD, Ansi::CYAN, Ansi::RESET)?;
        }
        writeln!(
            out,
            "{} AI likelihood {}{}{pct:.0}%{}",
            a.dim("·"),
            pct_color,
            Ansi::BOLD,
            Ansi::RESET
        )?;
    } else {
        write!(out, " {} findings · ", summary.total_findings)?;
        if high > 0 {
            write!(out, "HIGH {high} ")?;
        }
        if med > 0 {
            write!(out, "MED {med} ")?;
        }
        if low > 0 {
            write!(out, "LOW {low} ")?;
        }
        writeln!(out, "· AI likelihood {pct:.0}%")?;
    }
    Ok(())
}

pub fn write_json_report(
    out: &mut impl Write,
    findings: &[Finding],
    summary: &ScanSummary,
    explainability: Option<&ExplainabilityReport>,
) -> io::Result<()> {
    #[derive(Serialize)]
    struct JsonReport<'a> {
        summary: &'a ScanSummary,
        findings: &'a [Finding],
        #[serde(skip_serializing_if = "Option::is_none")]
        explainability: Option<&'a ExplainabilityReport>,
    }

    let report = JsonReport {
        summary,
        findings,
        explainability,
    };
    let json = serde_json::to_string_pretty(&report).map_err(io::Error::other)?;
    writeln!(out, "{json}")
}

///
/// Each finding becomes an `::error` annotation that GitHub surfaces inline
/// `::error file={path},line={line},title={id}::{description}`
///
/// Findings with no line range omit the `line=` attribute so that GitHub
pub fn write_github_actions_report(
    out: &mut impl Write,
    findings: &[Finding],
    summary: &ScanSummary,
) -> io::Result<()> {
    for f in findings {
        let path = f.file_path.to_string_lossy();
        let title = format!("papertowel[{}]: {}", category_label(f.category), f.id);
        // Escape the message: `::` in the text would prematurely close the
        // command; newlines and percent signs also need escaping.
        let message = escape_gha_data(&f.description);

        if let Some(range) = f.line_range {
            writeln!(
                out,
                "::error file={path},line={line},title={title}::{message}",
                line = range.start,
            )?;
        } else {
            writeln!(out, "::error file={path},title={title}::{message}")?;
        }
    }

    // Emit a summary notice after all annotations.
    let ai_pct = summary.ai_probability * 100.0;
    writeln!(
        out,
        "::notice title=papertowel summary::{total} finding(s) \u{2014} AI probability {ai_pct:.0}%",
        total = summary.total_findings,
    )?;

    Ok(())
}

/// command (`::command key=value::data`).
fn escape_gha_data(s: &str) -> String {
    // GitHub Actions command data escaping: percent, carriage-return, newline.
    s.replace('%', "%25")
        .replace('\r', "%0D")
        .replace('\n', "%0A")
}

// ─── SARIF 2.1.0 ─────────────────────────────────────────────────────────────

///
/// by VS Code (SARIF Viewer), GitHub Code Scanning, and other SARIF-aware
pub fn write_sarif_report(
    out: &mut impl Write,
    findings: &[Finding],
    summary: &ScanSummary,
) -> io::Result<()> {
    let rules = build_sarif_rules(findings);
    let results = build_sarif_results(findings, &rules);

    let tool = serde_json::json!({
        "driver": {
            "name": "papertowel",
            "version": env!("CARGO_PKG_VERSION"),
            "informationUri": "https://github.com/greysquirr3l/papertowel",
            "rules": rules.iter().map(|(_, rule)| rule.clone()).collect::<Vec<_>>(),
        }
    });

    let run = serde_json::json!({
        "tool": tool,
        "results": results,
        "properties": {
            "papertowel": {
                "totalFindings": summary.total_findings,
                "aiProbability": summary.ai_probability,
                "bySeverity": summary.by_severity,
                "byCategory": summary.by_category,
            }
        }
    });

    let sarif = serde_json::json!({
        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
        "version": "2.1.0",
        "runs": [run],
    });

    let json = serde_json::to_string_pretty(&sarif).map_err(io::Error::other)?;
    writeln!(out, "{json}")
}

/// Build a deduplicated rule table keyed by `(category, id)`.
fn build_sarif_rules(findings: &[Finding]) -> Vec<(String, serde_json::Value)> {
    let mut seen = std::collections::HashSet::new();
    let mut rules = Vec::new();

    for f in findings {
        let rule_id = sarif_rule_id(f);
        if seen.insert(rule_id.clone()) {
            let mut rule = serde_json::json!({
                "id": rule_id,
                "shortDescription": { "text": format!("{} detector", category_label(f.category)) },
            });
            if let Some(ref suggestion) = f.suggestion {
                rule.as_object_mut().and_then(|m| {
                    m.insert("helpUri".to_owned(), serde_json::Value::Null);
                    m.insert("help".to_owned(), serde_json::json!({ "text": suggestion }))
                });
            }
            rules.push((rule_id, rule));
        }
    }
    rules
}

fn build_sarif_results(
    findings: &[Finding],
    rules: &[(String, serde_json::Value)],
) -> Vec<serde_json::Value> {
    findings
        .iter()
        .map(|f| {
            let rule_id = sarif_rule_id(f);
            let rule_index = rules.iter().position(|(id, _)| *id == rule_id).unwrap_or(0);

            let mut location = serde_json::json!({
                "physicalLocation": {
                    "artifactLocation": {
                        "uri": f.file_path.to_string_lossy(),
                        "uriBaseId": "%SRCROOT%",
                    }
                }
            });

            if let Some(range) = f.line_range {
                location
                    .as_object_mut()
                    .and_then(|m| m.get_mut("physicalLocation"))
                    .and_then(|pl| pl.as_object_mut())
                    .and_then(|pl| {
                        pl.insert(
                            "region".to_owned(),
                            serde_json::json!({
                                "startLine": range.start,
                                "endLine": range.end,
                            }),
                        )
                    });
            }

            let mut result = serde_json::json!({
                "ruleId": rule_id,
                "ruleIndex": rule_index,
                "level": sarif_level(f.severity),
                "message": { "text": f.description },
                "locations": [location],
                "properties": {
                    "confidenceScore": f.confidence_score,
                    "autoFixable": f.auto_fixable,
                }
            });

            if let Some(ref suggestion) = f.suggestion {
                result.as_object_mut().and_then(|m| {
                    m.insert(
                        "fixes".to_owned(),
                        serde_json::json!([{
                            "description": { "text": suggestion },
                        }]),
                    )
                });
            }

            result
        })
        .collect()
}

fn sarif_rule_id(f: &Finding) -> String {
    format!("papertowel/{}/{}", category_label(f.category), f.id)
}

const fn sarif_level(s: Severity) -> &'static str {
    match s {
        Severity::High => "error",
        Severity::Medium => "warning",
        Severity::Low => "note",
    }
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

fn severity_label(s: Severity) -> String {
    match s {
        Severity::High => "HIGH".to_owned(),
        Severity::Medium => "MED".to_owned(),
        Severity::Low => "LOW".to_owned(),
    }
}

fn category_label(c: FindingCategory) -> String {
    match c {
        FindingCategory::Lexical => "lexical".to_owned(),
        FindingCategory::Comment => "comment".to_owned(),
        FindingCategory::Structure => "structure".to_owned(),
        FindingCategory::Readme => "readme".to_owned(),
        FindingCategory::Metadata => "metadata".to_owned(),
        FindingCategory::Workflow => "workflow".to_owned(),
        FindingCategory::Maintenance => "maintenance".to_owned(),
        FindingCategory::Promotion => "promotion".to_owned(),
        FindingCategory::NameCredibility => "name_credibility".to_owned(),
        FindingCategory::IdiomMismatch => "idiom_mismatch".to_owned(),
        FindingCategory::TestPattern => "test_pattern".to_owned(),
        FindingCategory::PromptLeakage => "prompt_leakage".to_owned(),
        FindingCategory::CommitPattern => "commit_pattern".to_owned(),
        FindingCategory::Architecture => "architecture".to_owned(),
        FindingCategory::Security => "security".to_owned(),
    }
}

// ─── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[expect(clippy::expect_used, reason = "test assertions")]
mod tests {
    use std::path::PathBuf;

    use super::{
        Ansi, build_summary, category_label, severity_label, write_github_actions_report,
        write_json_report, write_sarif_report, write_text_report,
    };
    use crate::detection::finding::{Finding, FindingCategory, Severity};

    fn make_finding(sev: Severity, path: &str) -> Finding {
        Finding::new(
            "test.id",
            FindingCategory::Lexical,
            sev,
            0.8,
            PathBuf::from(path),
            "slop word detected",
        )
        .expect("valid finding")
    }

    #[test]
    fn empty_findings_produces_no_finding_output() {
        let summary = build_summary(&[]);
        let mut out = Vec::new();
        write_text_report(&mut out, &[], &summary, false, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(text.contains("No findings."));
    }

    #[test]
    fn empty_findings_with_color_uses_ansi_path() {
        // Covers lines 136-137: writeln! inside if use_color {... } when findings empty.
        let summary = build_summary(&[]);
        let mut out = Vec::new();
        write_text_report(&mut out, &[], &summary, true, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(text.contains("No findings."));
    }

    #[test]
    fn text_report_groups_by_file() {
        let findings = vec![
            make_finding(Severity::High, "src/main.rs"),
            make_finding(Severity::Low, "src/lib.rs"),
            make_finding(Severity::Medium, "src/main.rs"),
        ];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_text_report(&mut out, &findings, &summary, false, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(text.contains("src/main.rs"));
        assert!(text.contains("src/lib.rs"));
        assert!(text.contains("[HIGH]"));
    }

    #[test]
    fn json_report_is_valid_json() {
        let findings = vec![make_finding(Severity::Medium, "src/lib.rs")];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_json_report(&mut out, &findings, &summary, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
        assert!(parsed.get("findings").is_some());
        assert!(parsed.get("summary").is_some());
    }

    #[test]
    fn build_summary_counts_correctly() {
        let findings = vec![
            make_finding(Severity::High, "a.rs"),
            make_finding(Severity::High, "b.rs"),
            make_finding(Severity::Medium, "c.rs"),
        ];
        let summary = build_summary(&findings);
        assert_eq!(summary.total_findings, 3);
        assert_eq!(summary.by_severity.get("HIGH"), Some(&2));
        assert_eq!(summary.by_severity.get("MED"), Some(&1));
    }

    #[test]
    fn ai_probability_is_higher_for_more_findings() {
        let few = vec![make_finding(Severity::Low, "a.rs")];
        let many: Vec<_> = (0..10)
            .map(|i| make_finding(Severity::High, &format!("{i}.rs")))
            .collect();
        let p_few = build_summary(&few).ai_probability;
        let p_many = build_summary(&many).ai_probability;
        assert!(
            p_many > p_few,
            "more high-severity findings → higher probability"
        );
    }

    #[test]
    fn github_actions_report_emits_error_annotations() {
        let findings = vec![make_finding(Severity::High, "src/main.rs")];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_github_actions_report(&mut out, &findings, &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(
            text.contains("::error file=src/main.rs"),
            "expected::error annotation"
        );
        assert!(
            text.contains("::notice title=papertowel summary::"),
            "expected summary notice"
        );
    }

    #[test]
    fn gha_report_escapes_percent_in_description() {
        let mut f = make_finding(Severity::Medium, "src/lib.rs");
        f.description = "100% AI-generated".to_owned();
        let summary = build_summary(&[f.clone()]);
        let mut out = Vec::new();
        write_github_actions_report(&mut out, &[f], &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(
            text.contains("100%25 AI-generated"),
            "percent must be escaped"
        );
    }

    #[test]
    fn gha_report_escapes_cr_and_lf() {
        let mut f = make_finding(Severity::High, "src/lib.rs");
        f.description = "line one\r\nline two".to_owned();
        let summary = build_summary(&[f.clone()]);
        let mut out = Vec::new();
        write_github_actions_report(&mut out, &[f], &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(text.contains("%0D%0A"), "CR+LF must be escaped");
    }

    #[test]
    fn text_report_with_color_enabled_runs_without_error() {
        let f = make_finding(Severity::High, ".");
        let summary = build_summary(std::slice::from_ref(&f));
        let mut out = Vec::new();
        write_text_report(&mut out, &[f], &summary, true, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(!text.is_empty());
    }

    #[test]
    fn text_report_repo_root_displays_as_friendly_label() {
        let f = make_finding(Severity::Low, ".");
        let summary = build_summary(std::slice::from_ref(&f));
        let mut out = Vec::new();
        write_text_report(&mut out, &[f], &summary, false, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(
            text.contains("(repo root)"),
            "path '.' should be displayed as (repo root)"
        );
    }

    #[test]
    fn category_label_covers_all_variants() {
        use crate::detection::finding::FindingCategory;
        assert_eq!(
            category_label(FindingCategory::CommitPattern),
            "commit_pattern"
        );
        assert_eq!(
            category_label(FindingCategory::PromptLeakage),
            "prompt_leakage"
        );
        assert_eq!(category_label(FindingCategory::TestPattern), "test_pattern");
        assert_eq!(
            category_label(FindingCategory::IdiomMismatch),
            "idiom_mismatch"
        );
        assert_eq!(
            category_label(FindingCategory::NameCredibility),
            "name_credibility"
        );
        assert_eq!(category_label(FindingCategory::Promotion), "promotion");
        assert_eq!(category_label(FindingCategory::Maintenance), "maintenance");
        assert_eq!(category_label(FindingCategory::Workflow), "workflow");
        assert_eq!(severity_label(Severity::High), "HIGH");
        assert_eq!(severity_label(Severity::Medium), "MED");
        assert_eq!(severity_label(Severity::Low), "LOW");
    }

    #[test]
    fn text_report_with_medium_and_low_color_covers_badge_paths() {
        // Covers Severity::Medium (line 97) and Severity::Low (line 98) color paths.
        let med = make_finding(Severity::Medium, "src/lib.rs");
        let low = make_finding(Severity::Low, "src/util.rs");
        let findings = vec![med, low];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_text_report(&mut out, &findings, &summary, true, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(!text.is_empty());
    }

    #[test]
    fn text_report_with_line_range_and_suggestion_covers_location_paths() {
        // Covers lines 181-184 (line_range) and 188 (suggestion).
        let mut f = make_finding(Severity::High, "src/main.rs");
        f.line_range = Some(crate::detection::finding::LineRange::new(10, 20).expect("range"));
        f.suggestion = Some("remove this".to_owned());
        let findings = vec![f];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_text_report(&mut out, &findings, &summary, false, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(text.contains("at src/main.rs:10"), "should show file:line");
        assert!(text.contains("remove this"), "should show suggestion");
    }

    #[test]
    fn gha_report_with_line_range_emits_line_annotation() {
        // Covers lines 307-308: GHA report with a finding that has a line_range.
        let mut f = make_finding(Severity::High, "src/main.rs");
        f.line_range = Some(crate::detection::finding::LineRange::new(5, 5).expect("range"));
        let findings = vec![f];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_github_actions_report(&mut out, &findings, &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(
            text.contains("line=5"),
            "GHA annotation should include line number"
        );
    }

    #[test]
    fn text_summary_with_color_and_med_low_findings() {
        // Covers lines 224-225 (MED color) and 233-234 (LOW color) in write_text_summary.
        // Also covers ai_prob_color YELLOW (line 113) and GREEN (line 115) paths.
        let high = make_finding(Severity::High, ".");
        let med = make_finding(Severity::Medium, ".");
        let low = make_finding(Severity::Low, ".");
        let findings = vec![high, med, low];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_text_report(&mut out, &findings, &summary, true, None).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        assert!(!text.is_empty());
    }

    #[test]
    fn ai_prob_color_yellow_and_green_paths() {
        // Covers line 113 (YELLOW: prob >= 0.50 && prob < 0.75) and line 115 (GREEN).
        let a = Ansi { use_color: true };
        // YELLOW path: prob in [0.50, 0.75)
        assert_eq!(a.ai_prob_color(0.60), Ansi::YELLOW);
        // GREEN path: prob < 0.50
        assert_eq!(a.ai_prob_color(0.30), Ansi::GREEN);
    }

    #[test]
    fn category_label_covers_comment_structure_readme_metadata() {
        // Covers lines 350-353: Comment, Structure, Readme, Metadata variants.
        assert_eq!(category_label(FindingCategory::Comment), "comment");
        assert_eq!(category_label(FindingCategory::Structure), "structure");
        assert_eq!(category_label(FindingCategory::Readme), "readme");
        assert_eq!(category_label(FindingCategory::Metadata), "metadata");
    }

    #[test]
    fn sarif_report_is_valid_sarif_json() {
        let findings = vec![make_finding(Severity::High, "src/main.rs")];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_sarif_report(&mut out, &findings, &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
        assert_eq!(parsed.pointer("/version").expect("version"), "2.1.0");
        assert!(
            parsed
                .pointer("/$schema")
                .and_then(serde_json::Value::as_str)
                .is_some_and(|s| s.contains("sarif"))
        );
        assert!(
            parsed
                .pointer("/runs")
                .is_some_and(serde_json::Value::is_array)
        );
    }

    #[test]
    fn sarif_report_contains_results_and_rules() {
        let findings = vec![
            make_finding(Severity::High, "src/main.rs"),
            make_finding(Severity::Low, "src/lib.rs"),
        ];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_sarif_report(&mut out, &findings, &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
        let results = parsed
            .pointer("/runs/0/results")
            .and_then(serde_json::Value::as_array)
            .expect("results array");
        assert_eq!(results.len(), 2);
        let rules = parsed
            .pointer("/runs/0/tool/driver/rules")
            .and_then(serde_json::Value::as_array)
            .expect("rules array");
        assert!(!rules.is_empty());
    }

    #[test]
    fn sarif_severity_maps_correctly() {
        let findings = vec![
            make_finding(Severity::High, "a.rs"),
            make_finding(Severity::Medium, "b.rs"),
            make_finding(Severity::Low, "c.rs"),
        ];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_sarif_report(&mut out, &findings, &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
        assert_eq!(
            parsed.pointer("/runs/0/results/0/level").expect("level 0"),
            "error"
        );
        assert_eq!(
            parsed.pointer("/runs/0/results/1/level").expect("level 1"),
            "warning"
        );
        assert_eq!(
            parsed.pointer("/runs/0/results/2/level").expect("level 2"),
            "note"
        );
    }

    #[test]
    fn sarif_includes_line_range_when_present() {
        let mut f = make_finding(Severity::High, "src/main.rs");
        f.line_range = Some(crate::detection::finding::LineRange::new(10, 20).expect("range"));
        let summary = build_summary(std::slice::from_ref(&f));
        let mut out = Vec::new();
        write_sarif_report(&mut out, &[f], &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
        let region_path = "/runs/0/results/0/locations/0/physicalLocation/region";
        assert_eq!(
            parsed
                .pointer(&format!("{region_path}/startLine"))
                .expect("startLine"),
            10
        );
        assert_eq!(
            parsed
                .pointer(&format!("{region_path}/endLine"))
                .expect("endLine"),
            20
        );
    }

    #[test]
    fn sarif_empty_findings_produces_empty_results() {
        let summary = build_summary(&[]);
        let mut out = Vec::new();
        write_sarif_report(&mut out, &[], &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
        let results = parsed
            .pointer("/runs/0/results")
            .and_then(serde_json::Value::as_array)
            .expect("results");
        assert!(results.is_empty());
    }

    #[test]
    fn sarif_includes_suggestion_as_fix() {
        let mut f = make_finding(Severity::Medium, "src/lib.rs");
        f.suggestion = Some("use a different word".to_owned());
        let summary = build_summary(std::slice::from_ref(&f));
        let mut out = Vec::new();
        write_sarif_report(&mut out, &[f], &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
        assert!(
            parsed
                .pointer("/runs/0/results/0/fixes")
                .is_some_and(serde_json::Value::is_array)
        );
        assert!(
            parsed
                .pointer("/runs/0/results/0/fixes/0/description/text")
                .and_then(serde_json::Value::as_str)
                .is_some_and(|s| s.contains("different word"))
        );
    }

    #[test]
    fn sarif_includes_papertowel_summary_properties() {
        let findings = vec![make_finding(Severity::High, "a.rs")];
        let summary = build_summary(&findings);
        let mut out = Vec::new();
        write_sarif_report(&mut out, &findings, &summary).expect("write");
        let text = String::from_utf8(out).expect("utf8");
        let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
        let base = "/runs/0/properties/papertowel";
        assert_eq!(
            parsed
                .pointer(&format!("{base}/totalFindings"))
                .expect("totalFindings"),
            1
        );
        assert!(
            parsed
                .pointer(&format!("{base}/aiProbability"))
                .is_some_and(serde_json::Value::is_number)
        );
    }
}
