//! Issue / TODO View — scrollable list of all IDE issues with inline detail.
//!
//! ## Modes
//!
//! * **List** — shows all issues from `AppState.issue_registry` (errors first,
//!   then warnings, info, todos, newest-first within each level).
//!   Filter by typing; `1`–`4` to pin a severity; `h` to toggle dismissed.
//!   Navigate with ↑↓/PgUp/PgDn; Enter opens detail.
//!   `r` resolves the selected issue. `d` dismisses it. Esc → CloseModal.
//!
//! * **Detail** — shows the full issue message + file / location info.
//!   ↑↓/PgUp/PgDn scroll long messages. Enter jumps to the file if one is
//!   attached. `r` / `d` resolve / dismiss. Esc → back to List.
//!
//! Layout (List mode):
//! ```text
//!   ┌─ Issues ────────────────────────────── 3E  2W  1I ─────────────────┐
//!   │ Filter: ________________   [All] [E] [W] [I] [T]  [h: hidden]      │
//!   │ ✗ build   src/main.rs:42:8  error[E0308]: mismatched types          │
//!   │ ⚠ lint    src/lib.rs:8:3   warning: unused variable `x`             │
//!   │ ℹ lsp     src/lib.rs:15:1  unused import: std::fmt                  │
//!   └─────────────────────────────────────────────────────────────────────┘
//!     [Enter: detail/jump]  [r: resolve]  [d: dismiss]  [h: toggle dismissed]
//! ```

use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};

use crate::prelude::*;
use input::{Key, KeyEvent};
use operation::{Event, IssueViewOp, Operation};
use settings::Settings;
use views::View;
use widgets::focus::FocusRing;
use widgets::focusable::FocusOp;
use crate::issue_registry::{IssueId, Severity};

const FOCUS_FILTER: &str = "filter";
const FOCUS_LIST: &str = "list";
#[allow(dead_code)] // used in tests
const FOCUS_DETAIL: &str = "detail";

// ---------------------------------------------------------------------------
// ViewMode
// ---------------------------------------------------------------------------

#[derive(Debug, Clone)]
enum ViewMode {
    List,
    #[allow(dead_code)] // variant constructed in open_detail (test helper)
    Detail { id: IssueId, scroll: usize },
}

// ---------------------------------------------------------------------------
// IssueView
// ---------------------------------------------------------------------------

#[derive(Debug)]
pub struct IssueView {
    filter: widgets::input_field::InputField,
    focus: FocusRing,
    cursor: usize,
    mode: ViewMode,
    severity_filter: Option<Severity>,
    show_dismissed: bool,
}

impl Default for IssueView {
    fn default() -> Self {
        Self::new()
    }
}

impl IssueView {
    pub fn new() -> Self {
        Self {
            filter: widgets::input_field::InputField::new("Filter"),
            focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_LIST]),
            cursor: 0,
            mode: ViewMode::List,
            severity_filter: None,
            show_dismissed: false,
        }
    }

    fn move_up(&mut self, n: usize) {
        self.cursor = self.cursor.saturating_sub(n);
    }

    fn move_down(&mut self, n: usize, list_len: usize) {
        if list_len == 0 { return; }
        self.cursor = (self.cursor + n).min(list_len - 1);
    }

    pub fn clamp_cursor(&mut self, list_len: usize) {
        if list_len == 0 { self.cursor = 0; }
        else { self.cursor = self.cursor.min(list_len - 1); }
    }

    fn back_to_list(&mut self) {
        self.mode = ViewMode::List;
        self.focus = FocusRing::new(vec![FOCUS_FILTER, FOCUS_LIST]);
        self.focus.set_focus(FOCUS_LIST);
    }

    /// Returns `true` when the view is showing an issue detail pane.
    pub fn is_detail(&self) -> bool {
        matches!(self.mode, ViewMode::Detail { .. })
    }

    #[allow(dead_code)] // used in tests
    fn open_detail(&mut self, id: IssueId) {
        self.mode = ViewMode::Detail { id, scroll: 0 };
        self.focus = FocusRing::new(vec![FOCUS_DETAIL]);
        self.focus.set_focus(FOCUS_DETAIL);
    }

    /// Return the `IssueId` of the currently highlighted row.
    pub fn selected_issue_id(
        &self,
        issue_registry: &crate::issue_registry::IssueRegistry,
    ) -> Option<IssueId> {
        let entries = self.filtered_sorted(issue_registry);
        if entries.is_empty() { return None; }
        let idx = self.cursor.min(entries.len() - 1);
        entries.get(idx).map(|i| i.id)
    }

    /// Build the filtered, sorted list of issues for the current view state.
    fn filtered_sorted<'r>(
        &self,
        reg: &'r crate::issue_registry::IssueRegistry,
    ) -> Vec<&'r crate::issue_registry::Issue> {
        let q = self.filter.text().to_lowercase();
        let mut entries: Vec<&crate::issue_registry::Issue> = reg
            .list_all()
            .into_iter()
            .filter(|i| {
                if !self.show_dismissed && (i.dismissed || i.resolved) {
                    return false;
                }
                if self.severity_filter.is_some_and(|sev| i.severity != sev) {
                    return false;
                }
                if q.is_empty() { return true; }
                i.message.to_lowercase().contains(&q)
                    || i.source.to_lowercase().contains(&q)
                    || i.path.as_ref().is_some_and(|p| {
                        p.to_string_lossy().to_lowercase().contains(&q)
                    })
            })
            .collect();

        entries.sort_by(|a, b| {
            b.severity.cmp(&a.severity)
                .then(b.created_at.cmp(&a.created_at))
        });
        entries
    }

    /// Operations to resolve the currently selected issue.
    pub fn ops_resolve_selected(
        &self,
        issue_registry: &crate::issue_registry::IssueRegistry,
    ) -> Vec<Operation> {
        let Some(id) = self.selected_issue_id(issue_registry) else { return vec![]; };
        let Some(issue) = issue_registry.get(id) else { return vec![]; };
        if issue.marker.is_none() {
            vec![Operation::PersistentIssueLocal(
                operation::PersistentIssueOp::Resolve { id },
            )]
        } else {
            vec![Operation::ResolveIssue { id }]
        }
    }

    /// Operations to dismiss the currently selected issue.
    pub fn ops_dismiss_selected(
        &self,
        issue_registry: &crate::issue_registry::IssueRegistry,
    ) -> Vec<Operation> {
        let Some(id) = self.selected_issue_id(issue_registry) else { return vec![]; };
        let Some(issue) = issue_registry.get(id) else { return vec![]; };
        if issue.marker.is_none() {
            vec![Operation::PersistentIssueLocal(
                operation::PersistentIssueOp::Dismiss { id },
            )]
        } else {
            vec![Operation::DismissIssue { id }]
        }
    }

    /// Operations to jump to the selected issue's file location.
    pub fn ops_goto_selected(
        &self,
        issue_registry: &crate::issue_registry::IssueRegistry,
    ) -> Vec<Operation> {
        // In detail mode, use the detail issue id; otherwise the list cursor.
        let id = match &self.mode {
            ViewMode::Detail { id, .. } => *id,
            ViewMode::List => match self.selected_issue_id(issue_registry) {
                Some(id) => id,
                None => return vec![],
            },
        };
        let Some(issue) = issue_registry.get(id) else { return vec![]; };
        let Some(ref path) = issue.path else { return vec![]; };
        let mut ops = vec![
            Operation::Close,
            Operation::OpenFile { path: path.clone() },
        ];
        if let Some((start, _)) = issue.range {
            ops.push(Operation::MoveCursor {
                path: Some(path.clone()),
                cursor: start,
            });
        }
        ops
    }
}

// ---------------------------------------------------------------------------
// View trait
// ---------------------------------------------------------------------------

impl View for IssueView {
    const KIND: views::ViewKind = views::ViewKind::Modal;

    fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}

    fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
        // Detail mode key handling.
        if let ViewMode::Detail { .. } = &self.mode {
            return match key.key {
                Key::Char('r') => vec![Operation::IssueViewLocal(IssueViewOp::ResolveSelected)],
                Key::Char('d') => vec![Operation::IssueViewLocal(IssueViewOp::DismissSelected)],
                Key::Enter => vec![Operation::IssueViewLocal(IssueViewOp::OpenSelected)],
                Key::ArrowUp | Key::Char('k') => vec![Operation::NavigateUp],
                Key::ArrowDown | Key::Char('j') => vec![Operation::NavigateDown],
                Key::PageUp  => vec![Operation::NavigatePageUp],
                Key::PageDown => vec![Operation::NavigatePageDown],
                Key::Home => vec![Operation::NavigateHome],
                Key::End  => vec![Operation::NavigateEnd],
                _ => vec![],
            };
        }

        // List mode key handling.
        if key.key == Key::Tab    { return vec![Operation::Focus(FocusOp::Next)]; }
        if key.key == Key::BackTab { return vec![Operation::Focus(FocusOp::Prev)]; }

        // Severity filters.
        match key.key {
            Key::Char('0') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(None))],
            Key::Char('1') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Error)))],
            Key::Char('2') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Warning)))],
            Key::Char('3') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Info)))],
            Key::Char('4') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Todo)))],
            Key::Char('h') => return vec![Operation::IssueViewLocal(IssueViewOp::ToggleShowDismissed)],
            _ => {}
        }

        match self.focus.current() {
            FOCUS_FILTER => {
                if let Some(field_op) = widgets::input_field::key_to_op(key) {
                    return vec![Operation::IssueViewLocal(IssueViewOp::FilterInput(field_op))];
                }
                match key.key {
                    Key::ArrowUp   => vec![Operation::NavigateUp],
                    Key::ArrowDown => vec![Operation::NavigateDown],
                    Key::PageUp    => vec![Operation::NavigatePageUp],
                    Key::PageDown  => vec![Operation::NavigatePageDown],
                    _ => vec![],
                }
            }
            FOCUS_LIST => {
                match key.key {
                    Key::ArrowUp   => vec![Operation::NavigateUp],
                    Key::ArrowDown => vec![Operation::NavigateDown],
                    Key::PageUp    => vec![Operation::NavigatePageUp],
                    Key::PageDown  => vec![Operation::NavigatePageDown],
                    Key::Home => vec![Operation::NavigateHome],
                    Key::End  => vec![Operation::NavigateEnd],
                    Key::Enter => vec![Operation::IssueViewLocal(IssueViewOp::OpenSelected)],
                    Key::Char('r') | Key::Char('R') => {
                        vec![Operation::IssueViewLocal(IssueViewOp::ResolveSelected)]
                    }
                    Key::Char('d') | Key::Char('D') => {
                        vec![Operation::IssueViewLocal(IssueViewOp::DismissSelected)]
                    }
                    _ => {
                        if let Some(field_op) = widgets::input_field::key_to_op(key) {
                            return vec![Operation::IssueViewLocal(IssueViewOp::FilterInput(field_op))];
                        }
                        vec![]
                    }
                }
            }
            _ => vec![],
        }
    }

    fn handle_mouse(&self, _mouse: input::MouseEvent) -> Vec<Operation> {
        vec![]
    }

    fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
        match op {
            Operation::Focus(focus_op) => {
                match focus_op {
                    FocusOp::Next => self.focus.focus_next(),
                    FocusOp::Prev => self.focus.focus_prev(),
                    _ => {}
                }
                Some(Event::applied("issue_view", op.clone()))
            }

            Operation::IssueViewLocal(local_op) => {
                match local_op {
                    IssueViewOp::FilterInput(field_op) => {
                        self.filter.apply(field_op);
                        self.cursor = 0;
                    }
                    IssueViewOp::OpenSelected => {
                        // Actual navigation / detail-open done in app.rs which has
                        // access to issue_registry.
                    }
                    IssueViewOp::CloseDetail => {
                        self.back_to_list();
                    }
                    IssueViewOp::ResolveSelected | IssueViewOp::DismissSelected => {
                        // Actual resolve/dismiss ops emitted by app.rs.
                    }
                    IssueViewOp::ToggleShowDismissed => {
                        self.show_dismissed = !self.show_dismissed;
                        self.cursor = 0;
                    }
                    IssueViewOp::SetSeverityFilter(sev) => {
                        self.severity_filter = *sev;
                        self.cursor = 0;
                    }
                    IssueViewOp::RegistryChanged => {
                        // Cursor clamping done in app.rs after this.
                    }
                }
                Some(Event::applied("issue_view", op.clone()))
            }

            Operation::IssueAdded { .. }
            | Operation::IssueRemoved { .. }
            | Operation::IssueUpdated { .. } => {
                Some(Event::applied("issue_view", op.clone()))
            }

            Operation::NavigateUp => {
                match &mut self.mode {
                    ViewMode::Detail { scroll, .. } => {
                        *scroll = scroll.saturating_sub(1);
                    }
                    ViewMode::List => self.move_up(1),
                }
                Some(Event::applied("issue_view", op.clone()))
            }
            Operation::NavigateDown => {
                match &mut self.mode {
                    ViewMode::Detail { scroll, .. } => {
                        *scroll = scroll.saturating_add(1);
                    }
                    ViewMode::List => self.move_down(1, usize::MAX),
                }
                Some(Event::applied("issue_view", op.clone()))
            }
            Operation::NavigatePageUp => {
                match &mut self.mode {
                    ViewMode::Detail { scroll, .. } => {
                        *scroll = scroll.saturating_sub(20);
                    }
                    ViewMode::List => self.move_up(10),
                }
                Some(Event::applied("issue_view", op.clone()))
            }
            Operation::NavigatePageDown => {
                match &mut self.mode {
                    ViewMode::Detail { scroll, .. } => {
                        *scroll = scroll.saturating_add(20);
                    }
                    ViewMode::List => self.move_down(10, usize::MAX),
                }
                Some(Event::applied("issue_view", op.clone()))
            }
            Operation::NavigateHome => {
                match &mut self.mode {
                    ViewMode::Detail { scroll, .. } => { *scroll = 0; }
                    ViewMode::List => { self.cursor = 0; }
                }
                Some(Event::applied("issue_view", op.clone()))
            }
            Operation::NavigateEnd => {
                // List end clamped in render; detail end clamped in render_detail.
                Some(Event::applied("issue_view", op.clone()))
            }

            _ => None,
        }
    }

    fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
        // Stub — real rendering done via render_with_registry from app.rs.
        let _ = (frame, area, theme);
    }

    fn status_bar(
        &self,
        state: &crate::app_state::AppState,
        bar: &mut crate::widgets::status_bar::StatusBarBuilder,
    ) {
        let reg = &state.issue_registry;
        let errors   = reg.list_by_severity(Severity::Error).len();
        let warnings = reg.list_by_severity(Severity::Warning).len();
        let info     = reg.list_by_severity(Severity::Info).len();

        match &self.mode {
            ViewMode::List => {
                if errors + warnings + info > 0 {
                    bar.label(format!("{}E  {}W  {}I  ↑↓ navigate · r resolve · d dismiss · h toggle dismissed · Esc close", errors, warnings, info));
                } else {
                    bar.label("Issues  ↑↓ navigate · r resolve · d dismiss · Esc close");
                }
            }
            ViewMode::Detail { .. } => {
                bar.label("Issue detail  ↑↓ scroll · Enter jump to file · r resolve · Esc back");
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Rendering — called from app.rs with registry access
// ---------------------------------------------------------------------------

impl IssueView {
    /// Full render that has access to the issue registry (called from app.rs).
    pub fn render_with_registry(
        &self,
        frame: &mut Frame,
        area: Rect,
        theme: &crate::theme::Theme,
        reg: &crate::issue_registry::IssueRegistry,
    ) {
        match &self.mode {
            ViewMode::List => self.render_list(frame, area, theme, reg),
            ViewMode::Detail { id, scroll } => self.render_detail(frame, area, theme, reg, *id, *scroll),
        }
    }

    fn render_list(
        &self,
        frame: &mut Frame,
        area: Rect,
        theme: &crate::theme::Theme,
        reg: &crate::issue_registry::IssueRegistry,
    ) {
        let bg     = theme.bg();
        let fg     = theme.fg();
        let dim    = theme.fg_dim();
        let accent = theme.accent();
        let sel_bg = theme.selection_bg();

        let outer_block = Block::default()
            .borders(Borders::ALL)
            .title(Span::styled(" Issues ", Style::default().fg(accent).add_modifier(Modifier::BOLD)))
            .style(Style::default().fg(fg).bg(bg));
        let inner = outer_block.inner(area);
        frame.render_widget(outer_block, area);

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(1), Constraint::Min(0)])
            .split(inner);

        let filter_area = chunks[0];
        let list_area   = chunks[1];

        // --- Filter row ---
        let filter_focused = self.focus.current() == FOCUS_FILTER;
        let filter_style = if filter_focused {
            Style::default().fg(accent).bg(bg)
        } else {
            Style::default().fg(dim).bg(bg)
        };

        let sev_options: &[(&str, Option<Severity>)] = &[
            ("All", None),
            ("E", Some(Severity::Error)),
            ("W", Some(Severity::Warning)),
            ("I", Some(Severity::Info)),
            ("T", Some(Severity::Todo)),
        ];
        let mut filter_spans: Vec<Span> = vec![
            Span::styled(
                format!("{} {}", if filter_focused { ">" } else { " " }, self.filter.text()),
                filter_style,
            ),
            Span::raw("  "),
        ];
        for (lbl, sev) in sev_options {
            let active = &self.severity_filter == sev;
            let style = if active {
                Style::default().fg(bg).bg(accent)
            } else {
                Style::default().fg(dim).bg(bg)
            };
            filter_spans.push(Span::styled(format!("[{}]", lbl), style));
            filter_spans.push(Span::raw(" "));
        }
        let hidden_style = if self.show_dismissed {
            Style::default().fg(accent).bg(bg)
        } else {
            Style::default().fg(dim).bg(bg)
        };
        filter_spans.push(Span::styled("[h:dismissed]", hidden_style));

        frame.render_widget(
            Paragraph::new(Line::from(filter_spans)).style(Style::default().bg(bg)),
            filter_area,
        );

        // --- Issue list ---
        let entries = self.filtered_sorted(reg);
        let list_len = entries.len();
        let cursor   = if list_len == 0 { 0 } else { self.cursor.min(list_len - 1) };

        let visible_rows = list_area.height as usize;
        let scroll_offset = if cursor >= visible_rows { cursor + 1 - visible_rows } else { 0 };
        let list_focused = self.focus.current() == FOCUS_LIST;

        let items: Vec<ListItem> = entries
            .iter()
            .enumerate()
            .skip(scroll_offset)
            .take(visible_rows)
            .map(|(idx, issue)| {
                let is_sel = list_focused && idx == cursor;
                let row_bg = if is_sel { sel_bg } else { bg };
                let row_fg = if is_sel { accent } else { fg };

                let (ind, ind_style) = severity_indicator(issue.severity, theme);
                let source_pad = format!("{:<8}", &issue.source[..issue.source.len().min(8)]);

                let loc = issue.path.as_ref().map(|p| {
                    let file = p.file_name().and_then(|n| n.to_str()).unwrap_or("?");
                    if let Some((start, _)) = issue.range {
                        format!("{}:{}:{}", file, start.line + 1, start.column)
                    } else {
                        file.to_string()
                    }
                }).unwrap_or_else(|| "(no file)".to_string());

                let loc_pad = format!("{:<24}", truncate_str(&loc, 24));
                let msg = truncate_str(&issue.message, 60);

                let msg_style = if issue.dismissed || issue.resolved {
                    Style::default().fg(dim).bg(row_bg).add_modifier(Modifier::DIM)
                } else {
                    Style::default().fg(row_fg).bg(row_bg)
                };

                let line = Line::from(vec![
                    Span::styled(ind, ind_style.bg(row_bg)),
                    Span::styled(" ", Style::default().bg(row_bg)),
                    Span::styled(source_pad, Style::default().fg(dim).bg(row_bg)),
                    Span::styled(" ", Style::default().bg(row_bg)),
                    Span::styled(loc_pad, Style::default().fg(dim).bg(row_bg)),
                    Span::styled(" ", Style::default().bg(row_bg)),
                    Span::styled(msg, msg_style),
                ]);
                ListItem::new(line)
            })
            .collect();

        frame.render_widget(
            List::new(items).style(Style::default().fg(fg).bg(bg)),
            list_area,
        );

        if list_len == 0 {
            let msg = if self.filter.text().is_empty() && self.severity_filter.is_none() {
                "No issues."
            } else {
                "No matching issues."
            };
            frame.render_widget(
                Paragraph::new(msg).style(Style::default().fg(dim).bg(bg)),
                list_area,
            );
        }
    }

    fn render_detail(
        &self,
        frame: &mut Frame,
        area: Rect,
        theme: &crate::theme::Theme,
        reg: &crate::issue_registry::IssueRegistry,
        id: IssueId,
        scroll: usize,
    ) {
        let bg  = theme.bg();
        let fg  = theme.fg();
        let dim = theme.fg_dim();

        let Some(issue) = reg.get(id) else {
            frame.render_widget(
                Paragraph::new("Issue no longer exists.  Press Esc to go back.")
                    .style(Style::default().fg(dim).bg(bg)),
                area,
            );
            return;
        };

        let (sev_str, sev_style) = match issue.severity {
            Severity::Error   => ("✗ Error",   Style::default().fg(theme.level_error())),
            Severity::Warning => ("⚠ Warning", Style::default().fg(theme.level_warn())),
            Severity::Info    => ("ℹ Info",    Style::default().fg(fg)),
            Severity::Todo    => ("☑ Todo",    Style::default().fg(dim)),
        };

        let block_title = format!(" ← Issues — {} ", sev_str);
        let visible_rows = area.height.saturating_sub(2) as usize;

        let loc_line = issue.path.as_ref().map(|p| {
            let path_str = p.to_string_lossy();
            if let Some((start, _)) = issue.range {
                format!("{}:{}:{}", path_str, start.line + 1, start.column)
            } else {
                path_str.into_owned()
            }
        }).unwrap_or_else(|| "(no file)".to_string());

        let status_str = if issue.dismissed { "dismissed" }
            else if issue.resolved { "resolved" }
            else { "active" };

        let mut detail_lines: Vec<Line> = vec![
            Line::from(vec![
                Span::styled("File:     ", Style::default().fg(dim).bg(bg)),
                Span::styled(loc_line, Style::default().fg(fg).bg(bg)),
            ]),
            Line::from(vec![
                Span::styled("Source:   ", Style::default().fg(dim).bg(bg)),
                Span::styled(issue.source.clone(), Style::default().fg(fg).bg(bg)),
            ]),
            Line::from(vec![
                Span::styled("Severity: ", Style::default().fg(dim).bg(bg)),
                Span::styled(sev_str, sev_style.bg(bg)),
            ]),
            Line::from(vec![
                Span::styled("Status:   ", Style::default().fg(dim).bg(bg)),
                Span::styled(status_str, Style::default().fg(fg).bg(bg)),
            ]),
            Line::from(""),
        ];
        let wrap_width = area.width.saturating_sub(4) as usize;
        for para in issue.message.split('\n') {
            for wrapped_line in word_wrap(para, wrap_width.max(1)) {
                detail_lines.push(Line::from(Span::raw(wrapped_line)));
            }
        }

        let max_scroll = detail_lines.len().saturating_sub(visible_rows);
        let scroll = scroll.min(max_scroll);

        let rendered: Vec<Line> = detail_lines
            .iter()
            .skip(scroll)
            .take(visible_rows)
            .cloned()
            .collect();

        let block = Block::default()
            .borders(Borders::ALL)
            .title(Span::styled(block_title, sev_style.add_modifier(Modifier::BOLD)))
            .style(Style::default().fg(fg).bg(bg));

        frame.render_widget(
            Paragraph::new(rendered)
                .block(block)
                .wrap(Wrap { trim: false })
                .style(Style::default().fg(fg).bg(bg)),
            area,
        );

        if detail_lines.len() > visible_rows {
            let hint = format!(
                " lines {}-{}/{} ",
                scroll + 1,
                (scroll + visible_rows).min(detail_lines.len()),
                detail_lines.len()
            );
            let hw = hint.len() as u16;
            if area.width > hw + 2 {
                let hint_rect = Rect {
                    x: area.x + area.width - hw - 1,
                    y: area.y + area.height - 1,
                    width: hw,
                    height: 1,
                };
                frame.render_widget(
                    Paragraph::new(Span::styled(hint, Style::default().fg(dim).bg(bg))),
                    hint_rect,
                );
            }
        }
    }
}

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

fn severity_indicator(sev: Severity, theme: &crate::theme::Theme) -> (&'static str, Style) {
    match sev {
        Severity::Error   => ("✗", Style::default().fg(theme.level_error())),
        Severity::Warning => ("⚠", Style::default().fg(theme.level_warn())),
        Severity::Info    => ("ℹ", Style::default().fg(theme.fg())),
        Severity::Todo    => ("☑", Style::default().fg(theme.fg_dim())),
    }
}

fn truncate_str(s: &str, max_chars: usize) -> String {
    let chars: Vec<char> = s.chars().collect();
    if chars.len() <= max_chars {
        s.to_string()
    } else {
        let truncated: String = chars[..max_chars.saturating_sub(1)].iter().collect();
        format!("{}…", truncated)
    }
}

fn word_wrap(text: &str, max_width: usize) -> Vec<String> {
    if text.trim().is_empty() { return vec![String::new()]; }
    let mut lines = Vec::new();
    let mut current = String::new();
    for word in text.split_whitespace() {
        if current.is_empty() {
            current.push_str(word);
        } else if current.len() + 1 + word.len() <= max_width {
            current.push(' ');
            current.push_str(word);
        } else {
            lines.push(current);
            current = word.to_string();
        }
    }
    if !current.is_empty() {
        lines.push(current);
    }
    if lines.is_empty() { lines.push(String::new()); }
    lines
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::issue_registry::{IssueRegistry, NewIssue, Severity};
    use input::Modifiers;

    fn make_issue(msg: &str, sev: Severity) -> NewIssue {
        NewIssue {
            marker: None,
            source: "test".into(),
            path: None,
            range: None,
            message: msg.into(),
            severity: sev,
        }
    }

    fn dummy_settings() -> Settings {
        let dir = tempfile::tempdir().expect("tempdir");
        Settings::new(dir.path().join("config.yaml").as_path())
            .expect("Settings::new in test")
    }

    fn dummy_key(key: Key) -> KeyEvent {
        KeyEvent { key, modifiers: Modifiers::empty() }
    }

    #[test]
    fn navigate_down_up() {
        let mut view = IssueView::new();
        view.focus.set_focus(FOCUS_LIST);

        let mut reg = IssueRegistry::new();
        reg.add_issue(make_issue("a", Severity::Error));
        reg.add_issue(make_issue("b", Severity::Warning));
        reg.add_issue(make_issue("c", Severity::Info));

        let ops = view.handle_key(dummy_key(Key::ArrowDown));
        assert!(ops.iter().any(|o| matches!(o, Operation::NavigateDown)));
        view.handle_operation(&Operation::NavigateDown, &dummy_settings());
        assert_eq!(view.cursor, 1);

        view.handle_operation(&Operation::NavigateUp, &dummy_settings());
        assert_eq!(view.cursor, 0);
    }

    #[test]
    fn clamp_cursor_on_empty_registry() {
        let mut view = IssueView::new();
        view.cursor = 5;

        let reg = IssueRegistry::new();
        view.clamp_cursor(view.filtered_sorted(&reg).len());
        assert_eq!(view.cursor, 0);
    }

    #[test]
    fn severity_filter_ops() {
        let mut view = IssueView::new();
        let ops = view.handle_key(dummy_key(Key::Char('1')));
        assert!(ops.iter().any(|o| matches!(
            o,
            Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Error)))
        )));
        view.handle_operation(
            &Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Error))),
            &dummy_settings(),
        );
        assert_eq!(view.severity_filter, Some(Severity::Error));

        let ops = view.handle_key(dummy_key(Key::Char('0')));
        assert!(ops.iter().any(|o| matches!(
            o,
            Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(None))
        )));
    }

    #[test]
    fn toggle_show_dismissed() {
        let mut view = IssueView::new();
        assert!(!view.show_dismissed);
        let ops = view.handle_key(dummy_key(Key::Char('h')));
        assert!(ops.iter().any(|o| matches!(
            o,
            Operation::IssueViewLocal(IssueViewOp::ToggleShowDismissed)
        )));
        view.handle_operation(
            &Operation::IssueViewLocal(IssueViewOp::ToggleShowDismissed),
            &dummy_settings(),
        );
        assert!(view.show_dismissed);
    }

    #[test]
    fn resolve_selected_emits_op() {
        let mut view = IssueView::new();
        view.focus.set_focus(FOCUS_LIST);
        let ops = view.handle_key(dummy_key(Key::Char('r')));
        assert!(ops.iter().any(|o| matches!(
            o,
            Operation::IssueViewLocal(IssueViewOp::ResolveSelected)
        )));
    }

    #[test]
    fn dismiss_selected_emits_op() {
        let mut view = IssueView::new();
        view.focus.set_focus(FOCUS_LIST);
        let ops = view.handle_key(dummy_key(Key::Char('d')));
        assert!(ops.iter().any(|o| matches!(
            o,
            Operation::IssueViewLocal(IssueViewOp::DismissSelected)
        )));
    }

    #[test]
    fn esc_in_list_yields_no_ops() {
        // Escape is intercepted by the global app.close_modal command; handle_key never sees it.
        let view = IssueView::new();
        let ops = view.handle_key(dummy_key(Key::Escape));
        assert!(ops.is_empty());
    }

    #[test]
    fn esc_in_detail_yields_no_ops() {
        // Escape is intercepted by the global app.close_modal command; handle_key never sees it.
        let mut view = IssueView::new();
        let mut reg = IssueRegistry::new();
        let id = reg.add_issue(make_issue("x", Severity::Error));
        view.mode = ViewMode::Detail { id, scroll: 0 };
        view.focus.set_focus(FOCUS_DETAIL);

        let ops = view.handle_key(dummy_key(Key::Escape));
        assert!(ops.is_empty());
    }

    #[test]
    fn selected_issue_id_returns_error_first() {
        let mut view = IssueView::new();
        let mut reg = IssueRegistry::new();
        let id1 = reg.add_issue(make_issue("error msg", Severity::Error));
        reg.add_issue(make_issue("warn msg", Severity::Warning));

        view.cursor = 0;
        assert_eq!(view.selected_issue_id(&reg), Some(id1));
    }

    #[test]
    fn ops_goto_selected_no_path_returns_empty() {
        let mut view = IssueView::new();
        let mut reg = IssueRegistry::new();
        reg.add_issue(make_issue("msg", Severity::Error));
        view.cursor = 0;
        let ops = view.ops_goto_selected(&reg);
        assert!(ops.is_empty());
    }

    #[test]
    fn filtered_sorted_hides_dismissed_by_default() {
        let view = IssueView::new();
        let mut reg = IssueRegistry::new();
        let id = reg.add_issue(make_issue("dismissed issue", Severity::Error));
        reg.dismiss_issue(id);
        reg.add_issue(make_issue("active", Severity::Warning));

        let entries = view.filtered_sorted(&reg);
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].message, "active");
    }

    #[test]
    fn open_detail_switches_mode() {
        let mut view = IssueView::new();
        let mut reg = IssueRegistry::new();
        let id = reg.add_issue(make_issue("err", Severity::Error));
        view.open_detail(id);
        assert!(matches!(view.mode, ViewMode::Detail { id: detail_id, .. } if detail_id == id));
    }

    #[test]
    fn close_detail_returns_to_list() {
        let mut view = IssueView::new();
        let mut reg = IssueRegistry::new();
        let id = reg.add_issue(make_issue("err", Severity::Error));
        view.open_detail(id);
        view.handle_operation(
            &Operation::IssueViewLocal(IssueViewOp::CloseDetail),
            &dummy_settings(),
        );
        assert!(matches!(view.mode, ViewMode::List));
    }
}

