use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;

// pylint format: "path/file.py:line:col: type/code (symbol) message"
// types: C=convention, R=refactor, W=warning, E=error, F=fatal
static DIAG_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"(?m)^([^:]+\.py):(\d+):(\d+):\s+([CRWEF]\d{4}):\s+(.+?)(?:\s+\(([^)]+)\))?\s*$")
        .unwrap()
});
// "Your code has been rated at X.XX/10" score line
static SCORE_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?m)^Your code has been rated at[^\n]*\n?").unwrap());
// "------- Module name" section headers
static MODULE_HEADER_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?m)^\*{4,}[^\n]*Module[^\n]*\n?").unwrap());

// ── compress pylint output ────────────────────────────────────────────────────

pub fn compress_pylint(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    // Preserve score line — it's one useful signal
    let score = SCORE_RE
        .find(&cleaned)
        .map(|m| m.as_str().trim().to_string());
    let s = MODULE_HEADER_RE.replace_all(&cleaned, "");

    let mut by_file: std::collections::HashMap<&str, Vec<(u32, &str, &str, &str)>> =
        std::collections::HashMap::new();
    let mut error_count = 0usize;
    let mut warning_count = 0usize;
    let mut convention_count = 0usize;

    for caps in DIAG_RE.captures_iter(&s) {
        let file = caps.get(1).map(|m| m.as_str()).unwrap_or("");
        let line: u32 = caps
            .get(2)
            .and_then(|m| m.as_str().parse().ok())
            .unwrap_or(0);
        let code = caps.get(4).map(|m| m.as_str()).unwrap_or("");
        let msg = caps.get(5).map(|m| m.as_str()).unwrap_or("").trim();
        let symbol = caps.get(6).map(|m| m.as_str()).unwrap_or("");
        match code.chars().next() {
            Some('E') | Some('F') => error_count += 1,
            Some('W') => warning_count += 1,
            _ => convention_count += 1,
        }
        by_file
            .entry(file)
            .or_default()
            .push((line, code, msg, symbol));
    }

    if by_file.is_empty() {
        // Fall back to stripping noise and collapsing blanks
        let result = SCORE_RE.replace_all(&s, "");
        let result = compactor::collapse_blanks(&result);
        if let Some(sc) = score {
            return format!("{}\n{sc}", result.trim());
        }
        return result;
    }

    let mut out_lines: Vec<String> = Vec::new();
    let mut files: Vec<&&str> = by_file.keys().collect();
    files.sort();

    for file in files {
        let diags = &by_file[file];
        let mut sorted = diags.clone();
        sorted.sort_by_key(|(l, _, _, _)| *l);
        out_lines.push(file.to_string());
        for (i, (line, code, msg, symbol)) in sorted.iter().enumerate() {
            if i >= 10 {
                out_lines.push(format!(
                    "  … {} more issues in this file",
                    sorted.len() - 10
                ));
                break;
            }
            if symbol.is_empty() {
                out_lines.push(format!("  {line}  {code}  {msg}"));
            } else {
                out_lines.push(format!("  {line}  {code}  {msg}  ({symbol})"));
            }
        }
    }

    // Summary
    let total = error_count + warning_count + convention_count;
    out_lines.push(format!(
        "\nTotal: {}  [{} errors, {} warnings, {} convention]",
        total, error_count, warning_count, convention_count
    ));
    if let Some(sc) = score {
        out_lines.push(sc);
    }
    out_lines.join("\n")
}

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

    #[test]
    fn strips_score_from_body_but_keeps_at_end() {
        let raw = "************* Module mymodule\nmymodule.py:10:0: W0611: Unused import os (unused-import)\n\n-------------------------------------------------------------------\nYour code has been rated at 8.50/10 (previous run: 7.00/10, +1.50)\n";
        let out = compress_pylint(raw);
        // Score should appear (at end) but module header should be stripped
        assert!(out.contains("8.50/10"), "{out}");
        assert!(!out.contains("Module mymodule"), "{out}");
        assert!(out.contains("W0611"), "{out}");
    }

    #[test]
    fn groups_by_file_with_summary() {
        let raw = "app.py:5:0: E0001: invalid syntax (syntax-error)\napp.py:12:4: W0611: Unused import sys (unused-import)\nutils.py:3:0: C0114: Missing module docstring (missing-module-docstring)\n";
        let out = compress_pylint(raw);
        assert!(out.contains("app.py"), "{out}");
        assert!(out.contains("utils.py"), "{out}");
        assert!(out.contains("Total:"), "{out}");
    }

    #[test]
    fn caps_per_file_at_10() {
        let lines: Vec<String> = (1..=15)
            .map(|i| format!("big.py:{i}:0: W0611: Unused import x{i} (unused-import)"))
            .collect();
        let out = compress_pylint(&lines.join("\n"));
        assert!(out.contains("more issues"), "{out}");
    }

    #[test]
    fn handles_no_diagnostics() {
        let raw = "--------------------------------------------------------------------\nYour code has been rated at 10.00/10\n";
        let out = compress_pylint(raw);
        assert!(out.contains("10.00/10"), "{out}");
    }
}
