//! Diagnostics extraction from parsed task output.
//!
//! [`DiagnosticsExtractor`] consumes [`StyledLine`] values produced by the
//! VT100 parser and emits [`NewIssue`] entries that are fed back to
//! [`crate::issue_registry::IssueRegistry`] via `Operation::AddIssue`.
//!
//! # Scope (MVP)
//!
//! Matches two categories of output:
//!
//! 1. **GNU-style diagnostic lines** (Rust, C, Go, most compilers):
//!    ```text
//!    path/to/file.rs:42:10: error: something bad happened
//!    path/to/file.rs:42: warning: something is suspicious
//!    ```
//!
//! 2. **TODO / FIXME / HACK / XXX comments** in any output line:
//!    ```text
//!    // TODO: refactor this function
//!    FIXME: this is broken
//!    ```
//!
//! Unknown or malformed lines are silently ignored — the extractor never
//! panics on bad input.
//!
//! # Usage
//!
//! One `DiagnosticsExtractor` is created per task execution (called from
//! [`crate::task_executor::TaskExecutor::spawn`]).  It is shared across the
//! stdout and stderr streams via [`std::sync::Arc`].
//!
//! ```ignore
//! let extractor = Arc::new(DiagnosticsExtractor::new("task:build:crate:a", "build"));
//! for line in styled_lines {
//!     for issue in extractor.extract_from_line(&line) {
//!         let _ = op_tx.send(vec![Operation::AddIssue { issue }]);
//!     }
//! }
//! ```

use std::path::PathBuf;

use regex::Regex;

use crate::issue_registry::{NewIssue, Severity};
use crate::vt_parser::StyledLine;

// ---------------------------------------------------------------------------
// DiagnosticsExtractor
// ---------------------------------------------------------------------------

/// Stateless, `Send + Sync` diagnostics extractor.
///
/// Each task execution gets one instance (shared between stdout and stderr
/// streams via `Arc`).  All extracted issues receive the same `marker` and
/// `source` so they can be cleared together when the task reruns.
pub struct DiagnosticsExtractor {
    /// Issue registry marker — `"task:{queue}:{target}"`.
    marker: String,
    /// Human-readable source tag for the registry (e.g. `"build"`, `"test"`).
    source: String,
    /// Matches `path:line:col: severity: message` (three-component prefix).
    gnu_3: Regex,
    /// Matches `path:line: severity: message` (two-component prefix, no column).
    gnu_2: Regex,
    /// Matches TODO/FIXME/HACK/XXX annotations anywhere in a line.
    todo_pat: Regex,
    /// Matches a rustc/cargo severity header: `error[E0425]: message` or
    /// `warning: message`.  Group 1 = severity keyword, group 2 = message.
    rustc_header: Regex,
    /// Matches a rustc/cargo source location line: ` --> path:line:col`.
    /// Group 1 = path, group 2 = line, group 3 = col.
    rustc_arrow: Regex,
}

impl DiagnosticsExtractor {
    /// Construct a new extractor.
    ///
    /// * `marker` — issue registry marker, usually `"task:{queue}:{target}"`.
    /// * `source` — human-readable tag for the registry (e.g. `"build"`).
    pub fn new(marker: impl Into<String>, source: impl Into<String>) -> Self {
        // GNU-style: `path:line:col: severity: message`
        // Severity keywords: error, warning, note, info, hint.
        // We capture group 1=path, 2=line, 3=col, 4=severity, 5=message.
        // The path group handles Windows drive letters (C:\...) by allowing an
        // optional `X:\` prefix before the non-colon path characters.
        let gnu_3 = Regex::new(
            r"(?i)^((?:[A-Za-z]:\\[^:\n]*|[^:\n]+)):(\d+):(\d+):\s*(error|warning|note|info|hint):\s+(.+)$",
        )
        .expect("gnu_3 regex is valid");

        // GNU-style without column: `path:line: severity: message`
        let gnu_2 = Regex::new(
            r"(?i)^((?:[A-Za-z]:\\[^:\n]*|[^:\n]+)):(\d+):\s*(error|warning|note|info|hint):\s+(.+)$",
        )
        .expect("gnu_2 regex is valid");

        // TODO/FIXME/HACK/XXX — word-boundary anchored; captures the tag and the message.
        let todo_pat = Regex::new(r"(?i)\b(TODO|FIXME|HACK|XXX)\b[:\s]+(.+)$")
            .expect("todo_pat regex is valid");

        // Rustc/cargo severity header: `error[E0425]: msg` or `warning: msg`.
        // Group 1 = severity keyword, group 2 = optional `[Exxxx]` code (ignored),
        // group 3 = message.
        let rustc_header = Regex::new(
            r"(?i)^(error|warning|note|info|hint)(\[.*?\])?:\s+(.+)$",
        )
        .expect("rustc_header regex is valid");

        // Rustc/cargo source location: ` --> path:line:col`
        // Leading whitespace is required (distinguishes from other text).
        // Windows path handled same as gnu_3: optional drive prefix.
        let rustc_arrow = Regex::new(
            r"^\s+-->\s+((?:[A-Za-z]:\\[^:\n]*|[^:\n]+)):(\d+):(\d+)\s*$",
        )
        .expect("rustc_arrow regex is valid");

        Self {
            marker: marker.into(),
            source: source.into(),
            gnu_3,
            gnu_2,
            todo_pat,
            rustc_header,
            rustc_arrow,
        }
    }

    /// Extract zero or more [`NewIssue`]s from a single styled output line.
    ///
    /// The plain text content of the line is used; ANSI styling is ignored.
    /// Returns an empty `Vec` if the line matches no known pattern.
    pub fn extract_from_line(&self, line: &StyledLine) -> Vec<NewIssue> {
        self.extract_from_str(&line.text)
    }

    /// Extract zero or more [`NewIssue`]s from a plain text line.
    ///
    /// Equivalent to [`Self::extract_from_line`] but accepts a `&str` directly,
    /// useful when the caller already has plain text (e.g. from a terminal PTY
    /// output buffer where ANSI codes have already been stripped by the VT100
    /// parser).
    pub fn extract_from_str(&self, text: &str) -> Vec<NewIssue> {
        let text = text.trim_end();
        if text.is_empty() {
            return Vec::new();
        }

        // Try the three-component GNU pattern first (more specific).
        if let Some(caps) = self.gnu_3.captures(text) {
            let path = PathBuf::from(&caps[1]);
            let lineno: usize = caps[2].parse().unwrap_or(0);
            let col: usize = caps[3].parse().unwrap_or(0);
            let severity = parse_severity(&caps[4]);
            let message = caps[5].trim().to_string();

            return vec![self.make_issue(
                severity,
                message,
                Some(path),
                Some(lineno),
                Some(col),
            )];
        }

        // Fall back to the two-component GNU pattern (no column).
        if let Some(caps) = self.gnu_2.captures(text) {
            let path = PathBuf::from(&caps[1]);
            let lineno: usize = caps[2].parse().unwrap_or(0);
            let severity = parse_severity(&caps[3]);
            let message = caps[4].trim().to_string();

            return vec![self.make_issue(
                severity,
                message,
                Some(path),
                Some(lineno),
                None,
            )];
        }

        // Check for TODO/FIXME/HACK/XXX anywhere in the line.
        if let Some(caps) = self.todo_pat.captures(text) {
            let message = format!("{}: {}", &caps[1].to_uppercase(), caps[2].trim());
            return vec![self.make_issue(Severity::Todo, message, None, None, None)];
        }

        Vec::new()
    }

    // --- rustc / cargo multi-line helpers ----------------------------------

    /// Try to match a rustc/cargo severity header line such as
    /// `error[E0425]: cannot find value \`x\`` or `warning: unused variable`.
    ///
    /// Returns `(severity, message)` if matched, `None` otherwise.
    pub fn try_rustc_header(&self, text: &str) -> Option<(Severity, String)> {
        let text = text.trim_end();
        self.rustc_header.captures(text).map(|caps| {
            let severity = parse_severity(&caps[1]);
            let message = caps[3].trim().to_string();
            (severity, message)
        })
    }

    /// Try to match a rustc/cargo source-location line such as
    /// ` --> src/main.rs:5:10`.
    ///
    /// Returns `(path, line, col)` if matched, `None` otherwise.
    pub fn try_rustc_arrow(&self, text: &str) -> Option<(PathBuf, usize, usize)> {
        let text = text.trim_end();
        self.rustc_arrow.captures(text).map(|caps| {
            let path = PathBuf::from(&caps[1]);
            let line: usize = caps[2].parse().unwrap_or(0);
            let col: usize = caps[3].parse().unwrap_or(0);
            (path, line, col)
        })
    }

    // --- helpers -----------------------------------------------------------

    /// Build a [`NewIssue`] from individual components.
    ///
    /// `line` and `column` are 1-based; they are converted to 0-based
    /// [`Position`] internally.
    pub fn make_issue(
        &self,
        severity: Severity,
        message: String,
        path: Option<PathBuf>,
        line: Option<usize>,
        column: Option<usize>,
    ) -> NewIssue {
        use crate::editor::position::Position;

        let range = match (line, column) {
            (Some(l), Some(c)) => {
                let pos = Position { line: l.saturating_sub(1), column: c.saturating_sub(1) };
                Some((pos, pos))
            }
            (Some(l), None) => {
                let pos = Position { line: l.saturating_sub(1), column: 0 };
                Some((pos, pos))
            }
            _ => None,
        };

        NewIssue {
            marker: Some(self.marker.clone()),
            source: self.source.clone(),
            path,
            range,
            message,
            severity,
        }
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Map a matched severity keyword to [`Severity`].
fn parse_severity(s: &str) -> Severity {
    match s.to_ascii_lowercase().as_str() {
        "error" => Severity::Error,
        "warning" => Severity::Warning,
        _ => Severity::Info,
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn extractor() -> DiagnosticsExtractor {
        DiagnosticsExtractor::new("task:build:crate_a", "build")
    }

    fn line(text: &str) -> StyledLine {
        StyledLine { text: text.to_string(), spans: vec![] }
    }

    fn single(text: &str) -> Option<NewIssue> {
        let results = extractor().extract_from_line(&line(text));
        assert!(
            results.len() <= 1,
            "expected at most 1 result, got {}: {results:?}",
            results.len()
        );
        results.into_iter().next()
    }

    // 1. GNU 3-component: file:line:col: error: message
    #[test]
    fn gnu_error_with_column() {
        let issue = single("src/main.rs:42:10: error: type mismatch").unwrap();
        assert_eq!(issue.severity, Severity::Error);
        assert_eq!(issue.message, "type mismatch");
        assert_eq!(issue.path, Some(PathBuf::from("src/main.rs")));
        // Range: line 41 (0-indexed), col 9 (0-indexed)
        let (start, _) = issue.range.unwrap();
        assert_eq!(start.line, 41);
        assert_eq!(start.column, 9);
    }

    // 2. GNU 3-component: warning
    #[test]
    fn gnu_warning_with_column() {
        let issue = single("lib/foo.rs:10:5: warning: unused variable").unwrap();
        assert_eq!(issue.severity, Severity::Warning);
        assert_eq!(issue.message, "unused variable");
        assert_eq!(issue.path, Some(PathBuf::from("lib/foo.rs")));
    }

    // 3. GNU 2-component: file:line: error: message (no column)
    #[test]
    fn gnu_error_without_column() {
        let issue = single("build/Makefile:7: error: missing separator").unwrap();
        assert_eq!(issue.severity, Severity::Error);
        assert_eq!(issue.message, "missing separator");
        assert_eq!(issue.path, Some(PathBuf::from("build/Makefile")));
        let (start, _) = issue.range.unwrap();
        assert_eq!(start.line, 6); // 0-indexed
        assert_eq!(start.column, 0);
    }

    // 4. TODO comment → Severity::Todo
    #[test]
    fn todo_comment() {
        let issue = single("  // TODO: refactor this function").unwrap();
        assert_eq!(issue.severity, Severity::Todo);
        assert!(issue.message.contains("refactor this function"), "msg: {}", issue.message);
        assert!(issue.path.is_none());
    }

    // 5. FIXME → Severity::Todo
    #[test]
    fn fixme_comment() {
        let issue = single("FIXME: this is broken").unwrap();
        assert_eq!(issue.severity, Severity::Todo);
    }

    // 6. HACK → Severity::Todo
    #[test]
    fn hack_comment() {
        let issue = single("  HACK: workaround for upstream bug").unwrap();
        assert_eq!(issue.severity, Severity::Todo);
    }

    // 7. XXX → Severity::Todo
    #[test]
    fn xxx_comment() {
        let issue = single("XXX: needs review").unwrap();
        assert_eq!(issue.severity, Severity::Todo);
    }

    // 8. Marker and source are set correctly on all issue types.
    #[test]
    fn marker_and_source_are_set() {
        let ext = DiagnosticsExtractor::new("task:lint:mylib", "lint");
        let results = ext.extract_from_line(&line("src/lib.rs:1:1: error: oops"));
        let issue = &results[0];
        assert_eq!(issue.marker, Some("task:lint:mylib".into()));
        assert_eq!(issue.source, "lint");
    }

    // 9. Malformed / plain line → no issues.
    #[test]
    fn plain_line_produces_no_issues() {
        assert!(single("   Compiling mylib v0.1.0").is_none());
    }

    // 10. Empty line → no issues.
    #[test]
    fn empty_line_produces_no_issues() {
        assert!(single("").is_none());
    }

    // 11. Note / info keywords → Severity::Info.
    #[test]
    fn note_keyword_maps_to_info() {
        let issue = single("src/main.rs:5:3: note: consider using a semicolon").unwrap();
        assert_eq!(issue.severity, Severity::Info);
    }

    // 12. Multiple tasks produce diagnostics associated to correct marker.
    #[test]
    fn different_markers_are_independent() {
        let ext_a = DiagnosticsExtractor::new("task:build:crate_a", "build");
        let ext_b = DiagnosticsExtractor::new("task:build:crate_b", "build");
        let results_a = ext_a.extract_from_line(&line("a.rs:1:1: error: a broke"));
        let results_b = ext_b.extract_from_line(&line("b.rs:1:1: error: b broke"));
        assert_eq!(results_a[0].marker, Some("task:build:crate_a".into()));
        assert_eq!(results_b[0].marker, Some("task:build:crate_b".into()));
    }

    // 13. Case-insensitive severity matching.
    #[test]
    fn case_insensitive_severity() {
        let issue = single("src/main.rs:1:1: ERROR: uppercase error").unwrap();
        assert_eq!(issue.severity, Severity::Error);
        let issue2 = single("src/main.rs:1:1: Warning: mixed case").unwrap();
        assert_eq!(issue2.severity, Severity::Warning);
    }

    // 14. Path with spaces is not matched (safety: avoid false positives on
    //     lines like `Running: some task`).
    #[test]
    fn path_with_colon_only_no_line_number_not_matched() {
        // "Running: some task" has no digit after the first colon → no match.
        assert!(single("Running: building the project").is_none());
    }

    // 15. extract_from_str produces the same result as extract_from_line.
    #[test]
    fn extract_from_str_matches_extract_from_line() {
        let ext = extractor();
        let text = "src/main.rs:10:5: warning: dead code";
        let from_line = ext.extract_from_line(&line(text));
        let from_str = ext.extract_from_str(text);
        assert_eq!(from_line.len(), from_str.len());
        assert_eq!(from_line[0].severity, from_str[0].severity);
        assert_eq!(from_line[0].message, from_str[0].message);
    }

    // 16. extract_from_str works on ANSI-stripped terminal output.
    #[test]
    fn extract_from_str_ansi_stripped() {
        // Simulate what the terminal extractor receives after strip_ansi.
        let ext = extractor();
        // "src/main.rs:1:1: error: oh no" preceded by cleared color code.
        let text = "src/main.rs:1:1: error: oh no";
        let issue = ext.extract_from_str(text).into_iter().next().unwrap();
        assert_eq!(issue.severity, Severity::Error);
        assert_eq!(issue.message, "oh no");
    }
}
