//! Widget that renders a `vt100::Parser`'s screen into a Ratatui buffer.
//!
//! Renders the visible terminal content, including scrollback history when
//! the scroll offset is non-zero. The `screen.cell(row, col)` method respects
//! the scrollback offset set on the parser.

use std::cell::Ref;

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    widgets::Widget,
};

#[derive(Debug, Clone)]
pub enum LinkKind {
    Url(String),
    File { path: std::path::PathBuf, line: Option<usize>, column: Option<usize> },
    /// A diagnostic match (error/warning/note) detected in a terminal row.
    /// Used for row-level background severity indicators. The link spans the
    /// whole row so individual `File` links within it take click priority.
    Diagnostic {
        path: Option<std::path::PathBuf>,
        line: Option<usize>,
        column: Option<usize>,
        severity: crate::issue_registry::Severity,
        message: String,
    },
    /// A search match highlighted by the terminal search UI.
    Search,
    /// The currently active/currently-selected search match. Rendered with a
    /// stronger highlight so it's easy to locate.
    SearchCurrent,
}

#[derive(Debug, Clone)]
pub struct Link {
    pub kind: LinkKind,
    pub row: u16,
    pub start_col: u16,
    pub end_col: u16,
    pub text: String,
}

pub struct TerminalWidget<'a> {
    pub parser: Ref<'a, vt100::Parser>,
    pub links: Vec<Link>,
    /// When true, Url/File links are rendered with underlines and colour. Set
    /// to true when Ctrl is held so users can Ctrl+Click to open links.
    pub show_links: bool,
}

impl<'a> Widget for TerminalWidget<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        if area.height == 0 || area.width == 0 {
            return;
        }

        let screen = self.parser.screen();
        // The app now explicitly splits the layout and passes a reduced `area`
        // (terminal area excluding the status bar). Draw into the full provided
        // area rather than reserving an extra row.
        let draw_rows = area.height;
        let cols = area.width;

        if draw_rows == 0 || cols == 0 {
            return;
        }

        // screen.cell(row, col) respects scrollback offset
        for row in 0..draw_rows {
            for col in 0..cols {
                let x = area.left() + col;
                let y = area.top() + row;

                if let Some(cell_ref) = screen.cell(row, col) {
                    let contents = cell_ref.contents();
                    let ch = if contents.is_empty() {
                        ' '
                    } else {
                        contents.chars().next().unwrap_or(' ')
                    };
                    let mut style = build_style(cell_ref);

                    // Highlight links: underline + light blue fg for Url/File;
                    // severity-tinted background for Diagnostic rows.
                    // Two-pass: Diagnostic bg first (lower priority), then
                    // Url/File style on top (higher priority / break).
                    for link in &self.links {
                        if link.row == row
                            && col >= link.start_col
                            && col < link.end_col
                            && let LinkKind::Diagnostic { severity, .. } = &link.kind
                        {
                            use crate::issue_registry::Severity;
                            let bg = match severity {
                                Severity::Error => Color::Rgb(55, 18, 18),
                                Severity::Warning => Color::Rgb(50, 40, 8),
                                _ => Color::Rgb(12, 32, 48),
                            };
                            style = style.bg(bg);
                        }
                    }
                    if self.show_links {
                        for link in &self.links {
                            if link.row == row && col >= link.start_col && col < link.end_col {
                                match &link.kind {
                                    LinkKind::Url(_) | LinkKind::File { .. } => {
                                        style = style
                                            .fg(Color::LightBlue)
                                            .add_modifier(Modifier::UNDERLINED);
                                        break;
                                    }
                                    LinkKind::Diagnostic { .. } => {} // handled above
                                    LinkKind::Search | LinkKind::SearchCurrent => {}
                                }
                            }
                        }
                    }

                    // Third pass: apply non-color search highlight (bg + bold) so it's
                    // visible in high-contrast modes. This runs after Url/File so
                    // Underlined/LightBlue stays visible on top. Current matches get a
                    // stronger amber-like highlight to make them stand out.
                    for link in &self.links {
                        if link.row == row && col >= link.start_col && col < link.end_col {
                            match &link.kind {
                                LinkKind::SearchCurrent => {
                                    style = style.add_modifier(Modifier::BOLD).bg(Color::Rgb(170, 110, 30)).fg(Color::Black);
                                }
                                LinkKind::Search => {
                                    style = style.add_modifier(Modifier::BOLD).bg(Color::Rgb(30, 48, 70));
                                }
                                _ => {}
                            }
                        }
                    }

                    if let Some(buf_cell) = buf.cell_mut((x, y)) {
                        buf_cell.set_char(ch);
                        buf_cell.set_style(style);
                    }
                } else if let Some(buf_cell) = buf.cell_mut((x, y)) {
                    buf_cell.set_char(' ');
                    buf_cell.set_style(Style::default());
                }
            }
        }

        // Render the cursor (only in live view, not when scrolling)
        if screen.scrollback() == 0 {
            let (crow, ccol) = screen.cursor_position();
            if crow < draw_rows && ccol < cols {
                let cx = area.left() + ccol;
                let cy = area.top() + crow;
                if let Some(buf_cell) = buf.cell_mut((cx, cy)) {
                    let existing = buf_cell.style();
                    buf_cell.set_style(existing.add_modifier(Modifier::REVERSED));
                }
            }
        }
    }
}

fn build_style(cell: &vt100::Cell) -> Style {
    let mut style = Style::default()
        .fg(map_color(cell.fgcolor()))
        .bg(map_color(cell.bgcolor()));

    if cell.bold() {
        style = style.add_modifier(Modifier::BOLD);
    }
    if cell.italic() {
        style = style.add_modifier(Modifier::ITALIC);
    }
    if cell.underline() {
        style = style.add_modifier(Modifier::UNDERLINED);
    }
    if cell.inverse() {
        style = style.add_modifier(Modifier::REVERSED);
    }

    style
}

fn map_color(c: vt100::Color) -> Color {
    match c {
        vt100::Color::Default => Color::Reset,
        vt100::Color::Idx(i) => match i {
            0 => Color::Black,
            1 => Color::Red,
            2 => Color::Green,
            3 => Color::Yellow,
            4 => Color::Blue,
            5 => Color::Magenta,
            6 => Color::Cyan,
            7 => Color::Gray,
            8 => Color::DarkGray,
            9 => Color::LightRed,
            10 => Color::LightGreen,
            11 => Color::LightYellow,
            12 => Color::LightBlue,
            13 => Color::LightMagenta,
            14 => Color::LightCyan,
            15 => Color::White,
            n => Color::Indexed(n),
        },
        vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
    }
}

// Link detection helper: extracts Link structs from a parser's visible screen
// relative to the given current working directory. Kept crate-visible for
// tests and cross-module use.
//
// Uses the background SharedFileIndex for fast suffix lookups when available.
static GLOBAL_FILE_INDEX: once_cell::sync::Lazy<std::sync::Mutex<Option<crate::file_index::SharedFileIndex>>> =
    once_cell::sync::Lazy::new(|| std::sync::Mutex::new(None));

// Small resolved-path cache to avoid repeated index/walk searches for the
// same printed token. Stores (timestamp, path). TTL-based eviction applied on access.
static RESOLVE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashMap<String, (std::time::SystemTime, std::path::PathBuf)>>> =
    once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));

const RESOLVE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
const RESOLVE_CAP: usize = 4096;

pub(crate) fn set_global_file_index(idx: crate::file_index::SharedFileIndex) {
    let mut g = GLOBAL_FILE_INDEX.lock().unwrap();
    *g = Some(idx);
}

pub(crate) fn detect_links_from_screen(parser: &vt100::Parser, cwd: &std::path::Path) -> Vec<Link> {
    use std::collections::BTreeMap;

    const TRIM_CHARS: &[char] = &['(', ')', '[', ']', '{', '}', '.', ',', ';', '"', '\'', '<', '>'];

    let mut out: Vec<Link> = Vec::new();
    let screen = parser.screen();
    let (rows_u16, cols_u16) = screen.size();
    let rows = rows_u16 as usize;
    let cols = cols_u16 as usize;


    let mut searcher = crate::file_index::NucleoSearch::new();


        let mut process_chars = |chars: &[char], positions: &[(usize, usize)], cwd: &std::path::Path, out: &mut Vec<Link>| {
        if chars.is_empty() {
            return;
        }
        // Build pairs of (char, pos) and filter out placeholder markers ('\0')
        // which represent wide-character continuation columns. Keep positions so
        // start/end columns reflect actual screen columns.
        let pairs: Vec<(char, (usize, usize))> = chars.iter().cloned().zip(positions.iter().cloned()).collect();
        let filtered_pairs: Vec<(char, (usize, usize))> = pairs.into_iter().filter(|(ch, _)| *ch != '\0').collect();
        if filtered_pairs.is_empty() {
            return;
        }

        // Trim leading/trailing punctuation from the visible characters
        let mut s = 0usize;
        let mut e = filtered_pairs.len();
        while s < e && TRIM_CHARS.contains(&filtered_pairs[s].0) {
            s += 1;
        }
        while s < e && TRIM_CHARS.contains(&filtered_pairs[e - 1].0) {
            e -= 1;
        }
        if s >= e {
            return;
        }

        let core_chars: Vec<char> = filtered_pairs[s..e].iter().map(|(c, _)| *c).collect();
        let core: String = core_chars.iter().collect();
        let core_clean: String = core.chars().filter(|c| !c.is_control()).collect();
        let trimmed_positions: Vec<(usize, usize)> = filtered_pairs[s..e].iter().map(|(_, p)| *p).collect();

        // Helper to split token positions by row into start/end columns.
        let mut by_row: BTreeMap<usize, (usize, usize)> = BTreeMap::new();
        for &(r, c) in &trimmed_positions {
            by_row
                .entry(r)
                .and_modify(|e| {
                    if c < e.0 {
                        e.0 = c;
                    }
                    if c > e.1 {
                        e.1 = c;
                    }
                })
                .or_insert((c, c));
        }

        if core_clean.starts_with("http://") || core_clean.starts_with("https://") {
            for (r, (start_c, end_c)) in by_row {
                out.push(Link {
                    kind: LinkKind::Url(core_clean.clone()),
                    row: r as u16,
                    start_col: start_c as u16,
                    end_col: (end_c + 1) as u16,
                    text: core_clean.clone(),
                });
            }
            return;
        }

        if core_clean.contains('/') || core_clean.contains('\\') || core_clean.starts_with('.') || core_clean.contains('.') {
            let parts: Vec<&str> = core_clean.split(':').collect();
            let mut end_i = parts.len();
            let mut column = None;
            let mut line = None;
            // Discard trailing empty segments (e.g. trailing colon in "path:3:1:").
            while end_i > 0 && parts[end_i - 1].is_empty() {
                end_i -= 1;
            }
            if end_i >= 2 && parts[end_i - 1].chars().all(|c| c.is_ascii_digit()) {
                column = parts[end_i - 1].parse::<usize>().ok();
                end_i -= 1;
            }
            if end_i >= 2 && parts[end_i - 1].chars().all(|c| c.is_ascii_digit()) {
                line = parts[end_i - 1].parse::<usize>().ok();
                end_i -= 1;
            }
            let base = parts[..end_i].join(":");
            let candidate = if std::path::Path::new(&base).is_absolute() {
                std::path::PathBuf::from(&base)
            } else {
                cwd.join(&base)
            };

            // Resolve candidate: if it exists as-is, use it. Otherwise try to
            // find a matching file under `cwd` by suffix (handles cases like
            // "/tmp/whatever/src/lib.rs" mapping to "src/lib.rs" in project).
            let resolved_path: Option<std::path::PathBuf> = if candidate.is_file() {
                // Prefer canonicalized absolute path when possible, but strip the
                // Windows extended path prefix (\\?\) which `canonicalize` may
                // produce to keep equality comparisons consistent with tests.
                std::fs::canonicalize(&candidate).ok().map(|c| {
                    let s = c.to_string_lossy();
                    if let Some(stripped) = s.strip_prefix("\\\\?\\") {
                        std::path::PathBuf::from(stripped)
                    } else {
                        c
                    }
                }).or(Some(candidate.clone()))
            } else {
                let base_path = std::path::Path::new(&base);
                let comps: Vec<std::ffi::OsString> = base_path.iter().map(|s| s.to_os_string()).collect();
                let mut found: Option<std::path::PathBuf> = None;
                // Prefer the longest suffix (most specific) first.
                for suffix_len in (1..=comps.len()).rev() {
                    let start = comps.len().saturating_sub(suffix_len);
                    let mut suffix = std::path::PathBuf::new();
                    for c in &comps[start..] {
                        suffix.push(c);
                    }
                    let mut matches: Vec<std::path::PathBuf> = Vec::new();
                    // Quick local check: if cwd/suffix exists, prefer it (cheap filesystem call).
                    let local_candidate = cwd.join(&suffix);
                    if local_candidate.is_file() {
                        matches.push(local_candidate);
                    }
                    // Check small global resolve cache first to avoid repeated searches for the same token
                    let cache_key = suffix.to_string_lossy().replace('\\', "/").to_lowercase();
                    {
                        let mut cache = RESOLVE_CACHE.lock().unwrap();
                        if let Some((ts, p)) = cache.get(&cache_key) {
                            if ts.elapsed().unwrap_or(std::time::Duration::from_secs(u64::MAX)) < RESOLVE_TTL {
                                matches.push(p.clone());
                            } else {
                                cache.remove(&cache_key);
                            }
                        }
                    }
                    // Use the SharedFileIndex + NucleoSearch when available for fast suffix matching.
                    if matches.is_empty()
                        && let Some(shared_idx) = GLOBAL_FILE_INDEX.lock().unwrap().as_ref() {
                            let arc = shared_idx.load();
                            if let Some(idx) = arc.as_ref() {
                                // Normalize suffix for comparison
                                let suffix_str = suffix.to_string_lossy().replace('\\', "/").to_lowercase();
                                // Ask for up to 64 candidates from the index (bounded work)
                                let results = searcher.search_top(idx, &suffix_str, 64);
                                for entry in results {
                                    let entry_str = entry.path.to_string_lossy().replace('\\', "/").to_lowercase();
                                    if entry_str.ends_with(&suffix_str) {
                                        matches.push(cwd.join(&entry.path));
                                    }
                                }
                            }
                        }

                    // WalkDir fallback removed: rely on SharedFileIndex for suffix resolution.

                    if !matches.is_empty() {
                        // Score: prefer smallest relative depth under cwd (path closest to project root),
                        // then shortest absolute path (fewer components).
                        matches.sort_by(|a, b| {
                            let a_rel = a.strip_prefix(cwd).ok().map(|rp| rp.components().count()).unwrap_or(usize::MAX);
                            let b_rel = b.strip_prefix(cwd).ok().map(|rp| rp.components().count()).unwrap_or(usize::MAX);
                            if a_rel != b_rel { return a_rel.cmp(&b_rel); }
                            let a_abs = a.components().count();
                            let b_abs = b.components().count();
                            if a_abs != b_abs { return a_abs.cmp(&b_abs); }
                            // fallback to lexical order to keep deterministic behavior
                            a.cmp(b)
                        });
                        // Canonicalize chosen match if possible so editor path comparisons line up.
                        let chosen = matches.remove(0);
                        let chosen_canon = std::fs::canonicalize(&chosen).map(|c| {
                            let s = c.to_string_lossy();
                            if let Some(stripped) = s.strip_prefix("\\\\?\\") {
                                std::path::PathBuf::from(stripped)
                            } else {
                                c
                            }
                        }).unwrap_or(chosen);
                        found = Some(chosen_canon.clone());
                        // cache the chosen resolution for this suffix to speed future lookups
                        let mut cache = RESOLVE_CACHE.lock().unwrap();
                        if cache.len() > RESOLVE_CAP {
                            // prune old entries
                            cache.retain(|_, (t, _)| t.elapsed().unwrap_or(std::time::Duration::from_secs(u64::MAX)) < RESOLVE_TTL);
                            if cache.len() > RESOLVE_CAP {
                                // drop half to keep memory bounded
                                let keys: Vec<String> = cache.keys().take(cache.len() / 2).cloned().collect();
                                for k in keys { cache.remove(&k); }
                            }
                        }
                        cache.insert(cache_key.clone(), (std::time::SystemTime::now(), chosen_canon.clone()));
                        break;
                    }
                }
                found
            };

            if let Some(resolved) = resolved_path {
                for (r, (start_c, end_c)) in by_row {
                    out.push(Link {
                        kind: LinkKind::File { path: resolved.clone(), line, column },
                        row: r as u16,
                        start_col: start_c as u16,
                        end_col: (end_c + 1) as u16,
                        text: core_clean.clone(),
                    });
                }
            }
        }
        };

    let mut pending_chars: Vec<char> = Vec::new();
    let mut pending_pos: Vec<(usize, usize)> = Vec::new();

    for r in 0..rows {
        for c in 0..cols {
            let r_u16 = r as u16;
            let c_u16 = c as u16;
            let ch = if let Some(cell_ref) = screen.cell(r_u16, c_u16) {
                // If this column is the second half of a wide char, treat it as a
                // placeholder so the token continues but no visible character is emitted.
                if cell_ref.is_wide_continuation() {
                    '\0'
                } else if cell_ref.has_contents() {
                    cell_ref.contents().chars().next().unwrap_or(' ')
                } else {
                    // truly empty cell -> whitespace (breaks tokens)
                    ' '
                }
            } else {
                ' '
            };

            // Treat placeholder ('\0') as non-whitespace so wide continuations don't break tokens.
            if ch.is_whitespace() {
                if !pending_chars.is_empty() {
                    process_chars(&pending_chars, &pending_pos, cwd, &mut out);
                    pending_chars.clear();
                    pending_pos.clear();
                }
            } else {
                pending_chars.push(ch);
                pending_pos.push((r, c));
            }
        }
    }

    if !pending_chars.is_empty() {
        process_chars(&pending_chars, &pending_pos, cwd, &mut out);
    }

    // Per-row diagnostic detection: scan full row text for GNU-style error/warning
    // patterns and create Diagnostic links spanning the whole row. These are appended
    // AFTER Url/File links so the latter take click priority (smaller span wins).
    {
        use crate::diagnostics_extractor::DiagnosticsExtractor;
        static ROW_EXTRACTOR: once_cell::sync::Lazy<DiagnosticsExtractor> =
            once_cell::sync::Lazy::new(|| {
                DiagnosticsExtractor::new("terminal:visual", "terminal")
            });
        for r in 0..rows {
            let mut row_text = String::with_capacity(cols);
            for c in 0..cols {
                if let Some(cell) = screen.cell(r as u16, c as u16) {
                    let contents = cell.contents();
                    if contents.is_empty() {
                        row_text.push(' ');
                    } else {
                        row_text.push_str(&contents);
                    }
                } else {
                    row_text.push(' ');
                }
            }
            for issue in ROW_EXTRACTOR.extract_from_str(&row_text) {
                out.push(Link {
                    kind: LinkKind::Diagnostic {
                        path: issue.path,
                        line: issue.range.map(|(s, _)| s.line + 1),
                        column: issue.range.map(|(s, _)| s.column + 1),
                        severity: issue.severity,
                        message: issue.message,
                    },
                    row: r as u16,
                    start_col: 0,
                    end_col: cols as u16,
                    text: row_text.trim_end().to_string(),
                });
            }
        }
    }

    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;
    use vt100::Parser;

    #[test]
    fn detect_url() {
        let mut parser = Parser::new(10, 80, 100);
        parser.process(b"http://example.com\n");
        let cwd = std::path::Path::new(".");
        let links = detect_links_from_screen(&parser, cwd);
        assert_eq!(links.len(), 1);
        match &links[0].kind {
            LinkKind::Url(u) => assert_eq!(u, "http://example.com"),
            _ => panic!("expected url link"),
        }
    }

    #[test]
    fn detect_wrapped_url_across_two_lines() {
        // Use a width that ensures the URL is visible (vt100 behavior varies by width).
        let mut parser = Parser::new(2, 18, 100);
        parser.process(b"http://example.com\n");
        let cwd = std::path::Path::new(".");
        let links = detect_links_from_screen(&parser, cwd);
        assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::Url(u) if u == "http://example.com")));
    }

    #[test]
    fn detect_wrapped_file_with_line_col() {
        // Create a deep path so it wraps across the visible columns and ensure
        // the detector still recognizes the file:line:col pattern.
        let dir = tempdir().unwrap();
        let subdir = dir.path().join("a").join("b").join("c");
        std::fs::create_dir_all(&subdir).unwrap();
        let pfile = subdir.join("long_filename_example.rs");
        std::fs::write(&pfile, "fn main() {}\n").unwrap();

        // Build the printed text and choose a width that ensures a two-line wrap.
        // Use 3 rows so the trailing newline goes to row 2, avoiding a scroll that
        // would push the first wrapped row into scrollback.
        let text = format!("{}:12:3\n", pfile.display());
        let visible_len = text.chars().count();
        let cols = (visible_len / 2) + 1; // force wrap into two rows
        let mut parser = Parser::new(3, cols.try_into().unwrap(), 100);
        parser.process(text.as_bytes());

        let links = detect_links_from_screen(&parser, dir.path());
        assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::File { path, line, column } if path == &pfile && line == &Some(12) && column == &Some(3))));
    }

    #[test]
    fn detect_url_with_ansi_wrapped() {
        // Colorized URL that wraps across two rows; detector should ignore ANSI and find URL.
        // Use 3 rows so the trailing newline goes to row 2, preventing a scroll that would
        // push the first wrapped row into scrollback.
        let url = "http://wrapped.example.com";
        let visible_len = url.chars().count();
        let cols = (visible_len / 2) + 1; // force wrap into two rows
        let mut parser = Parser::new(3, cols.try_into().unwrap(), 100);
        parser.process(format!("\x1b[31m{}\x1b[0m\n", url).as_bytes());

        let links = detect_links_from_screen(&parser, std::path::Path::new("."));
        assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::Url(u) if u == url)));
    }

    #[test]
    fn detect_file_resolve_non_exact() {
        // Simulate a terminal line that contains an absolute path from elsewhere,
        // but the same filename exists under the project's cwd. Detector should
        // resolve to the local file when possible.
        let dir = tempdir().unwrap();
        let project = dir.path().join("project");
        let src = project.join("src");
        std::fs::create_dir_all(&src).unwrap();
        let local = src.join("lib.rs");
        std::fs::write(&local, "fn main() {}\n").unwrap();

        // Construct a fake absolute path outside the cwd that ends with src/lib.rs
        let fake = std::path::Path::new("/tmp/other").join("project").join("src").join("lib.rs");
        let text = format!("{}:12:3\n", fake.display());

        let mut parser = Parser::new(10, 80, 100);
        parser.process(text.as_bytes());

        let links = detect_links_from_screen(&parser, project.as_path());
        assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::File { path, line, column } if path == &local && line == &Some(12) && column == &Some(3))));
    }

    #[test]
    fn detect_file_with_line_col() {
        let dir = tempdir().unwrap();
        let pfile = dir.path().join("foo.rs");
        std::fs::write(&pfile, "fn main() {}\n").unwrap();
        let mut parser = Parser::new(10, 80, 100);
        parser.process(b"foo.rs:12:3\n");
        let links = detect_links_from_screen(&parser, dir.path());
        assert_eq!(links.len(), 1);
        match &links[0].kind {
            LinkKind::File { path, line, column } => {
                assert_eq!(path, &pfile);
                assert_eq!(line, &Some(12));
                assert_eq!(column, &Some(3));
            }
            _ => panic!("expected file link"),
        }
    }

    #[test]
    fn skip_directory() {
        let dir = tempdir().unwrap();
        std::fs::create_dir(dir.path().join("somedir")).unwrap();
        let mut parser = Parser::new(10, 80, 100);
        parser.process(b"./somedir\n");
        let links = detect_links_from_screen(&parser, dir.path());
        assert!(links.is_empty());
    }

    #[test]
    fn detect_skip_directory_wrapped() {
        let dir = tempdir().unwrap();
        let deep = dir.path().join("some").join("very").join("long").join("directory");
        std::fs::create_dir_all(&deep).unwrap();
        let mut parser = Parser::new(2, 12, 100);
        parser.process(format!("{}\n", deep.display()).as_bytes());
        let links = detect_links_from_screen(&parser, dir.path());
        assert!(links.is_empty());
    }

    // ── Diagnostic detection tests ──────────────────────────────────────────

    #[test]
    fn detect_diagnostic_error_row() {
        // A GNU-style error line should produce a Diagnostic link spanning the row.
        let mut parser = Parser::new(10, 80, 100);
        parser.process(b"src/main.rs:42:10: error: type mismatch\n");
        let links = detect_links_from_screen(&parser, std::path::Path::new("."));
        let diag = links.iter().find(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
        assert!(diag.is_some(), "expected a Diagnostic link for error row");
        if let LinkKind::Diagnostic { severity, message, .. } = &diag.unwrap().kind {
            assert_eq!(*severity, crate::issue_registry::Severity::Error);
            assert!(message.contains("type mismatch"), "msg: {message}");
        }
    }

    #[test]
    fn detect_diagnostic_warning_row() {
        let mut parser = Parser::new(10, 80, 100);
        parser.process(b"lib/foo.rs:10:5: warning: unused variable\n");
        let links = detect_links_from_screen(&parser, std::path::Path::new("."));
        let diag = links.iter().find(|l| matches!(
            &l.kind,
            LinkKind::Diagnostic { severity, .. } if *severity == crate::issue_registry::Severity::Warning
        ));
        assert!(diag.is_some(), "expected a Warning Diagnostic link");
    }

    #[test]
    fn detect_diagnostic_does_not_fire_on_plain_output() {
        let mut parser = Parser::new(10, 80, 100);
        parser.process(b"   Compiling mylib v0.1.0\n");
        let links = detect_links_from_screen(&parser, std::path::Path::new("."));
        let has_diag = links.iter().any(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
        assert!(!has_diag, "plain compile lines should not produce Diagnostic links");
    }

    #[test]
    fn diagnostic_and_file_links_coexist_on_same_row() {
        // A diagnostic row should have BOTH a File link (for the path token, clickable)
        // and a Diagnostic link (for the row background indicator).
        let dir = tempdir().unwrap();
        let pfile = dir.path().join("foo.rs");
        std::fs::write(&pfile, "fn main() {}\n").unwrap();
        let text = format!("{}:3:1: error: undeclared variable\n", pfile.display());
        let mut parser = Parser::new(10, 120, 100);
        parser.process(text.as_bytes());
        let links = detect_links_from_screen(&parser, dir.path());
        let has_file = links.iter().any(|l| matches!(&l.kind, LinkKind::File { .. }));
        let has_diag = links.iter().any(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
        assert!(has_file, "expected a File link for the path token");
        assert!(has_diag, "expected a Diagnostic link for the row background");
    }
}
