//! Diff View
//!
//! Displays the diff for a selected change from the log view.

mod input;
mod render;

use crate::model::{CompareInfo, DiffContent, DiffDisplayFormat, DiffMode};

/// Action returned by DiffView key handling
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffAction {
    /// No action needed
    None,
    /// Return to log view
    Back,
    /// Open blame/annotation for current file
    OpenBlame {
        /// File path to annotate
        file_path: String,
    },
    /// Show an info notification (e.g., feature unavailable in current mode)
    ShowNotification(String),
    /// Copy diff to clipboard (full = jj show, !full = jj diff)
    CopyToClipboard { full: bool },
    /// Export diff to .patch file
    ExportToFile,
    /// Cycle display format (color-words → stat → git → color-words)
    CycleFormat,
}

/// Diff view state
#[derive(Debug)]
pub struct DiffView {
    /// Change ID being displayed
    pub revision: String,
    /// Parsed diff content
    pub content: DiffContent,
    /// Scroll offset (line index)
    pub scroll_offset: usize,
    /// Positions of file headers in the lines array
    pub file_header_positions: Vec<usize>,
    /// File names (extracted from headers)
    pub file_names: Vec<String>,
    /// Current file index (for context bar)
    pub current_file_index: usize,
    /// Last known visible height (updated during render)
    visible_height: usize,
    /// Compare info (set when in compare/interdiff mode, None for normal single-revision diff)
    pub compare_info: Option<CompareInfo>,
    /// Display mode (Single/Compare/Interdiff) — determines executor routing
    pub mode: DiffMode,
    /// Current display format
    pub display_format: DiffDisplayFormat,
    /// When true, header expands to show the full description even if it
    /// shrinks the diff area. Default false keeps diff visibility prioritized.
    pub description_expanded: bool,
    /// Agent Trace AI overlay: one mark per `content.lines` entry (Phase 3).
    /// Empty = no overlay (no gutter is rendered). Cleared by `set_content`
    /// and recomputed by App when applicable (Single + ColorWords).
    pub ai_line_marks: Vec<bool>,
}

impl Default for DiffView {
    fn default() -> Self {
        Self::empty()
    }
}

impl DiffView {
    /// Default visible height for scroll calculations when not specified
    const DEFAULT_VISIBLE_HEIGHT: usize = 20;

    /// Create a new empty DiffView
    pub fn empty() -> Self {
        Self {
            revision: String::new(),
            content: DiffContent::default(),
            scroll_offset: 0,
            file_header_positions: Vec::new(),
            file_names: Vec::new(),
            current_file_index: 0,
            visible_height: Self::DEFAULT_VISIBLE_HEIGHT,
            compare_info: None,
            mode: DiffMode::Single,
            display_format: DiffDisplayFormat::default(),
            description_expanded: false,
            ai_line_marks: Vec::new(),
        }
    }

    /// Toggle whether the header expands to show the full description.
    pub fn toggle_description_expanded(&mut self) {
        self.description_expanded = !self.description_expanded;
    }

    /// Effective header height for the current mode and viewport.
    ///
    /// Mirrors the layout logic in `render::render_normal` /
    /// `render::render_compare` so that callers (notably `App` storing
    /// `visible_height` for scroll bounds) and the renderer always agree on
    /// how many rows the diff content actually gets.
    pub fn header_height(&self, area_height: u16) -> u16 {
        if self.compare_info.is_some() {
            // Compare/Interdiff: fixed 5 rows (border + from + to + summary + border)
            return 5_u16.min(area_height.saturating_sub(2));
        }

        const MIN_DIFF_HEIGHT: u16 = 8;
        const MIN_DIFF_HEIGHT_EXPANDED: u16 = 3;
        let desired = 1 + 2 + self.description_line_count() as u16;
        let max_header = if self.description_expanded {
            area_height.saturating_sub(1 + MIN_DIFF_HEIGHT_EXPANDED)
        } else {
            let reserved = 1 + MIN_DIFF_HEIGHT;
            let half = (area_height / 2).max(4);
            area_height.saturating_sub(reserved).min(half)
        };
        desired
            .min(max_header)
            .max(4)
            .min(area_height.saturating_sub(2))
    }

    /// Effective diff content height inside `render_diff_content`.
    ///
    /// `area_height` is the height of the rect passed to `render()`.
    pub fn diff_content_height(&self, area_height: u16) -> u16 {
        area_height
            .saturating_sub(self.header_height(area_height))
            .saturating_sub(1) // context bar
            .saturating_sub(1) // content block's bottom border
    }

    /// Update `visible_height` and re-clamp `scroll_offset` so it never
    /// exceeds the new `max_scroll_offset()`. Call this right before render
    /// (with the freshly-computed content height) and after any state change
    /// that can shrink the diff area — e.g. toggling description expansion.
    pub fn set_visible_height_and_clamp(&mut self, visible_height: usize) {
        self.visible_height = visible_height;
        let max = self.max_scroll_offset();
        if self.scroll_offset > max {
            self.scroll_offset = max;
        }
    }

    /// Create a new DiffView with content
    pub fn new(revision: String, content: DiffContent) -> Self {
        let mut view = Self::empty();
        view.set_content(revision, content);
        view
    }

    /// Create a new DiffView in compare mode (two-revision diff)
    pub fn new_compare(content: DiffContent, compare_info: CompareInfo) -> Self {
        let mut view = Self::empty();
        let revision = compare_info.from.commit_id.to_string();
        view.set_content(revision, content);
        view.compare_info = Some(compare_info);
        view.mode = DiffMode::Compare;
        view
    }

    /// Create a new DiffView in interdiff mode (patch comparison)
    pub fn new_interdiff(content: DiffContent, compare_info: CompareInfo) -> Self {
        let mut view = Self::empty();
        let revision = compare_info.from.commit_id.to_string();
        view.set_content(revision, content);
        view.compare_info = Some(compare_info);
        view.mode = DiffMode::Interdiff;
        view
    }

    /// Create a new DiffView in stack mode (multi-revision `jj show <revset>`)
    ///
    /// `revset` is stored in `revision` and reused for refresh / format cycling.
    pub fn new_stack(revset: String, content: DiffContent) -> Self {
        let mut view = Self::empty();
        view.set_content(revset, content);
        view.mode = DiffMode::Stack;
        view
    }

    /// Set the content to display
    pub fn set_content(&mut self, revision: String, content: DiffContent) {
        use crate::model::DiffLineKind;

        // Extract file header positions and names
        let (positions, names): (Vec<_>, Vec<_>) = content
            .lines
            .iter()
            .enumerate()
            .filter(|(_, line)| line.kind == DiffLineKind::FileHeader)
            .map(|(i, line)| (i, line.content.clone()))
            .unzip();

        self.file_header_positions = positions;
        self.file_names = names;
        self.revision = revision;
        self.content = content;
        self.scroll_offset = 0;
        self.current_file_index = 0;
        // Marks index into content.lines — always invalidated by new content
        self.ai_line_marks.clear();
    }

    /// Set the Agent Trace AI overlay marks (must align with content.lines)
    pub fn set_ai_line_marks(&mut self, marks: Vec<bool>) {
        self.ai_line_marks = marks;
    }

    /// Whether the AI overlay gutter should be rendered
    pub fn has_ai_overlay(&self) -> bool {
        self.ai_line_marks.iter().any(|&m| m)
    }

    /// Clear the view (test-only helper)
    #[cfg(test)]
    pub fn clear(&mut self) {
        self.revision.clear();
        self.content = DiffContent::default();
        self.scroll_offset = 0;
        self.file_header_positions.clear();
        self.file_names.clear();
        self.current_file_index = 0;
        self.visible_height = Self::DEFAULT_VISIBLE_HEIGHT;
        self.mode = DiffMode::Single;
        self.display_format = DiffDisplayFormat::default();
        self.description_expanded = false;
    }

    /// Cycle to the next display format
    pub fn cycle_format(&mut self) -> DiffDisplayFormat {
        self.display_format = self.display_format.next();
        self.display_format
    }

    /// Get current file name for context bar
    pub fn current_file_name(&self) -> Option<&str> {
        self.file_names
            .get(self.current_file_index)
            .map(|s| s.as_str())
    }

    /// Get total file count
    pub fn file_count(&self) -> usize {
        self.file_names.len()
    }

    /// Number of changes in stack mode (count of ChangeHeader lines)
    pub fn change_count(&self) -> usize {
        use crate::model::DiffLineKind;
        self.content
            .lines
            .iter()
            .filter(|l| l.kind == DiffLineKind::ChangeHeader)
            .count()
    }

    /// Count description lines for header height calculation
    pub fn description_line_count(&self) -> usize {
        if self.content.description.is_empty() {
            1 // "(no description)" placeholder
        } else {
            self.content.description.lines().count().max(1)
        }
    }

    /// Check if there are any changes to display
    pub fn has_changes(&self) -> bool {
        self.content.has_changes()
    }

    /// Total number of diff lines
    pub fn total_lines(&self) -> usize {
        self.content.lines.len()
    }

    /// Get current context string for status bar
    pub fn current_context(&self) -> String {
        if self.file_count() > 0 {
            let file_name = self.current_file_name().unwrap_or("(unknown)");
            format!(
                "{} [{}/{}]",
                file_name,
                self.current_file_index + 1,
                self.file_count()
            )
        } else {
            "(no files)".to_string()
        }
    }

    // =========================================================================
    // Navigation
    // =========================================================================

    /// Scroll up by one line
    pub fn scroll_up(&mut self) {
        self.scroll_offset = self.scroll_offset.saturating_sub(1);
        self.update_current_file_index();
    }

    /// Scroll down by one line
    pub fn scroll_down(&mut self) {
        let max_offset = self.max_scroll_offset();
        if self.scroll_offset < max_offset {
            self.scroll_offset += 1;
        }
        self.update_current_file_index();
    }

    /// Calculate maximum scroll offset based on visible height
    fn max_scroll_offset(&self) -> usize {
        // If visible_height is 0, don't allow scrolling
        if self.visible_height == 0 {
            return 0;
        }
        let total = self.total_lines();
        total.saturating_sub(self.visible_height)
    }

    /// Scroll up by half page
    pub fn scroll_half_page_up(&mut self, visible_height: usize) {
        self.visible_height = visible_height;
        let half = visible_height / 2;
        self.scroll_offset = self.scroll_offset.saturating_sub(half);
        self.update_current_file_index();
    }

    /// Scroll down by half page
    pub fn scroll_half_page_down(&mut self, visible_height: usize) {
        self.visible_height = visible_height;
        let half = visible_height / 2;
        let max_offset = self.max_scroll_offset();
        self.scroll_offset = (self.scroll_offset + half).min(max_offset);
        self.update_current_file_index();
    }

    /// Jump to the top
    pub fn jump_to_top(&mut self) {
        self.scroll_offset = 0;
        self.current_file_index = 0;
    }

    /// Jump to the bottom
    pub fn jump_to_bottom(&mut self, visible_height: usize) {
        self.visible_height = visible_height;
        self.scroll_offset = self.max_scroll_offset();
        self.update_current_file_index();
    }

    /// Jump to the next file (wraps to the first).
    ///
    /// Stepping is by **file index** and must NOT re-derive the index from
    /// `scroll_offset`. When the whole diff fits on screen,
    /// `max_scroll_offset()` is 0, so the pre-render clamp
    /// (`set_visible_height_and_clamp`) pins `scroll_offset` back to 0 after we
    /// jump. Deriving the index from that pinned scroll would snap it back to
    /// file 0, making `]`/`[` dead. `current_file_index` is kept in sync by the
    /// scroll handlers, so we just step it directly here; the `[N/M]` label
    /// (also index-driven) then stays correct even when the scroll can't move.
    pub fn next_file(&mut self) {
        if self.file_header_positions.is_empty() {
            return;
        }
        let last = self.file_header_positions.len() - 1;
        let next = if self.current_file_index >= last {
            0
        } else {
            self.current_file_index + 1
        };
        self.current_file_index = next;
        self.scroll_offset = self.file_header_positions[next];
    }

    /// Jump to the previous file (wraps to the last). Index-based; see
    /// [`Self::next_file`] for why scroll-position scanning was dropped.
    pub fn prev_file(&mut self) {
        if self.file_header_positions.is_empty() {
            return;
        }
        let prev = if self.current_file_index == 0 {
            self.file_header_positions.len() - 1
        } else {
            self.current_file_index - 1
        };
        self.current_file_index = prev;
        self.scroll_offset = self.file_header_positions[prev];
    }

    /// Update current_file_index based on scroll position
    fn update_current_file_index(&mut self) {
        self.current_file_index = self
            .file_header_positions
            .iter()
            .rposition(|&pos| pos <= self.scroll_offset)
            .unwrap_or(0);
    }

    /// Jump to a specific file by path
    ///
    /// If the file is found, scrolls to its header position.
    /// If not found, does nothing.
    ///
    /// This handles renamed files where jj show outputs `prefix{old => new}`
    /// but StatusView passes just the new path `prefix/new`.
    pub fn jump_to_file(&mut self, file_path: &str) {
        // First try exact match
        if let Some(idx) = self.file_names.iter().position(|name| name == file_path)
            && let Some(&pos) = self.file_header_positions.get(idx)
        {
            self.scroll_offset = pos;
            self.current_file_index = idx;
            return;
        }

        // Try matching renamed files: "prefix{old => new}" should match "prefix/new"
        for (idx, name) in self.file_names.iter().enumerate() {
            if let Some(new_path) = crate::model::extract_new_path_from_rename(name)
                && new_path == file_path
                && let Some(&pos) = self.file_header_positions.get(idx)
            {
                self.scroll_offset = pos;
                self.current_file_index = idx;
                return;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{CommitId, DiffContent, DiffLine};
    use crossterm::event::KeyEvent;

    fn create_test_content() -> DiffContent {
        let mut content = DiffContent {
            commit_id: CommitId::new("abc123def456".to_string()),
            author: "Test User <test@example.com>".to_string(),
            timestamp: "2024-01-30 12:00:00".to_string(),
            description: "Test commit".to_string(),
            lines: Vec::new(),
        };

        // Add some test diff lines
        content.lines.push(DiffLine::file_header("src/main.rs"));
        content
            .lines
            .push(DiffLine::context(Some(10), Some(10), "fn main() {"));
        content
            .lines
            .push(DiffLine::deleted(11, "    println!(\"old\");"));
        content
            .lines
            .push(DiffLine::added(11, "    println!(\"new\");"));
        content
            .lines
            .push(DiffLine::context(Some(12), Some(12), "}"));
        content.lines.push(DiffLine::separator());
        content.lines.push(DiffLine::file_header("src/lib.rs"));
        content.lines.push(DiffLine::added(1, "pub fn hello() {}"));

        content
    }

    #[test]
    fn test_diff_view_empty() {
        let view = DiffView::empty();
        assert!(view.revision.is_empty());
        assert!(!view.has_changes());
        assert_eq!(view.file_count(), 0);
    }

    #[test]
    fn test_diff_view_new() {
        let view = DiffView::new("testchange".to_string(), create_test_content());

        assert_eq!(view.revision, "testchange");
        assert!(view.has_changes());
        assert_eq!(view.file_count(), 2);
        assert_eq!(view.file_names, vec!["src/main.rs", "src/lib.rs"]);
        assert_eq!(view.file_header_positions, vec![0, 6]);
    }

    #[test]
    fn test_diff_view_scroll() {
        let mut view = DiffView::new("test".to_string(), create_test_content());
        // Set visible height smaller than total lines to allow scrolling
        view.visible_height = 5;

        assert_eq!(view.scroll_offset, 0);

        view.scroll_down();
        assert_eq!(view.scroll_offset, 1);

        view.scroll_down();
        view.scroll_down();
        assert_eq!(view.scroll_offset, 3);

        view.scroll_up();
        assert_eq!(view.scroll_offset, 2);

        view.jump_to_top();
        assert_eq!(view.scroll_offset, 0);
    }

    #[test]
    fn test_diff_view_scroll_bounds() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        // Scroll up at top should stay at 0
        view.scroll_up();
        assert_eq!(view.scroll_offset, 0);

        // Set a visible height smaller than total lines
        view.visible_height = 5;

        // Scroll to bottom
        for _ in 0..20 {
            view.scroll_down();
        }

        // With 8 total lines and 5 visible, max offset should be 3
        // (so the last 5 lines are visible)
        let expected_max = view.total_lines().saturating_sub(view.visible_height);
        assert_eq!(view.scroll_offset, expected_max);
    }

    #[test]
    fn test_diff_view_file_jump() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        assert_eq!(view.current_file_index, 0);
        assert_eq!(view.scroll_offset, 0);

        // Jump to next file (src/lib.rs at position 6)
        view.next_file();
        assert_eq!(view.current_file_index, 1);
        assert_eq!(view.scroll_offset, 6);

        // Jump to next file wraps to first
        view.next_file();
        assert_eq!(view.current_file_index, 0);
        assert_eq!(view.scroll_offset, 0);

        // Jump to previous file
        view.prev_file();
        assert_eq!(view.current_file_index, 1);
        assert_eq!(view.scroll_offset, 6);
    }

    #[test]
    fn test_diff_view_current_file_name() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        assert_eq!(view.current_file_name(), Some("src/main.rs"));

        view.next_file();
        assert_eq!(view.current_file_name(), Some("src/lib.rs"));
    }

    #[test]
    fn test_diff_view_handle_key_scroll() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        // Use handle_key_with_height to set visible height smaller than total lines
        let action =
            view.handle_key_with_height(KeyEvent::from(crossterm::event::KeyCode::Char('j')), 5);
        assert_eq!(action, DiffAction::None);
        assert_eq!(view.scroll_offset, 1);
    }

    #[test]
    fn file_nav_works_when_diff_fits_on_screen() {
        // Regression (v0.9.1 missed this): when the whole diff fits, the
        // pre-render clamp pins scroll_offset to 0 (max_scroll_offset == 0).
        // File navigation must still cycle by index — `]`/`[` must not be dead.
        use crossterm::event::KeyCode;
        let mut view = DiffView::new("test".to_string(), create_test_content());
        // positions = [0, 6]; 8 lines. visible height 50 → fits → max scroll 0.
        let h = 50;

        // Render clamp runs before each key (as in the real loop).
        view.set_visible_height_and_clamp(h);
        view.handle_key_with_height(KeyEvent::from(KeyCode::Char(']')), h);
        view.set_visible_height_and_clamp(h);
        assert_eq!(view.current_file_index, 1, "] → file 2 even though it fits");

        // The bug: `[` did nothing here because scroll was pinned to 0.
        view.handle_key_with_height(KeyEvent::from(KeyCode::Char('[')), h);
        view.set_visible_height_and_clamp(h);
        assert_eq!(view.current_file_index, 0, "[ → back to file 1");

        // And `]` again still advances.
        view.handle_key_with_height(KeyEvent::from(KeyCode::Char(']')), h);
        view.set_visible_height_and_clamp(h);
        assert_eq!(view.current_file_index, 1);
    }

    #[test]
    fn prev_file_from_inside_file_goes_to_previous_file() {
        // Regression: when the scroll sits partway through a file (not exactly
        // on its header), `[` must move to the PREVIOUS file, not snap to the
        // current file's own header (which looked like "no reaction").
        // positions = [0 (src/main.rs), 6 (src/lib.rs)], 8 lines total.
        let mut view = DiffView::new("test".to_string(), create_test_content());

        // Sit partway through the last file (src/lib.rs header at line 6,
        // scroll at line 7 = inside its body).
        view.scroll_offset = 7;
        view.update_current_file_index();
        assert_eq!(view.current_file_index, 1, "now inside src/lib.rs");

        // `[` → previous file (src/main.rs), not src/lib.rs's own header.
        view.prev_file();
        assert_eq!(view.current_file_index, 0);
        assert_eq!(view.scroll_offset, 0);

        // `]` → back to src/lib.rs.
        view.next_file();
        assert_eq!(view.current_file_index, 1);
        assert_eq!(view.scroll_offset, 6);
    }

    #[test]
    fn test_diff_view_handle_key_back() {
        let mut view = DiffView::empty();

        let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('q')));
        assert_eq!(action, DiffAction::Back);

        let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Esc));
        assert_eq!(action, DiffAction::Back);
    }

    #[test]
    fn test_diff_view_half_page_scroll() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        // With 8 total lines and visible_height 4, max offset is 4
        // Half page is 2, so first scroll goes to 2
        view.scroll_half_page_down(4);
        assert_eq!(view.scroll_offset, 2);

        view.scroll_half_page_up(4);
        assert_eq!(view.scroll_offset, 0);
    }

    #[test]
    fn test_diff_view_clear() {
        let mut view = DiffView::new("test".to_string(), create_test_content());
        // Set visible height smaller than total lines to allow scrolling
        view.visible_height = 5;
        view.scroll_down();

        assert!(view.has_changes());
        assert_eq!(view.scroll_offset, 1);

        view.clear();

        assert!(!view.has_changes());
        assert_eq!(view.scroll_offset, 0);
        assert!(view.revision.is_empty());
    }

    #[test]
    fn test_diff_view_update_current_file_index() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        // At start, should be file 0
        assert_eq!(view.current_file_index, 0);

        // After scrolling past file header of second file
        view.scroll_offset = 7;
        view.update_current_file_index();
        assert_eq!(view.current_file_index, 1);

        // Back before second file
        view.scroll_offset = 3;
        view.update_current_file_index();
        assert_eq!(view.current_file_index, 0);
    }

    #[test]
    fn test_diff_view_current_context() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        assert_eq!(view.current_context(), "src/main.rs [1/2]");

        view.next_file();
        assert_eq!(view.current_context(), "src/lib.rs [2/2]");
    }

    #[test]
    fn test_diff_view_current_context_empty() {
        let view = DiffView::empty();
        assert_eq!(view.current_context(), "(no files)");
    }

    #[test]
    fn test_diff_view_scroll_with_zero_visible_height() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        // When visible_height is 0, scrolling should not be allowed
        view.visible_height = 0;

        // Try to scroll down - should stay at 0
        view.scroll_down();
        assert_eq!(view.scroll_offset, 0);

        // Try half page down with 0 height
        view.scroll_half_page_down(0);
        assert_eq!(view.scroll_offset, 0);
    }

    #[test]
    fn test_diff_view_jump_to_file() {
        let mut view = DiffView::new("test".to_string(), create_test_content());

        // Start at first file
        assert_eq!(view.current_file_index, 0);
        assert_eq!(view.scroll_offset, 0);

        // Jump to second file by path
        view.jump_to_file("src/lib.rs");
        assert_eq!(view.current_file_index, 1);
        assert_eq!(view.scroll_offset, 6); // Second file header is at position 6

        // Jump back to first file
        view.jump_to_file("src/main.rs");
        assert_eq!(view.current_file_index, 0);
        assert_eq!(view.scroll_offset, 0);

        // Jump to non-existent file should do nothing
        view.jump_to_file("non_existent.rs");
        assert_eq!(view.current_file_index, 0);
        assert_eq!(view.scroll_offset, 0);
    }

    #[test]
    fn test_extract_new_path_from_rename() {
        // Standard rename with prefix
        assert_eq!(
            crate::model::extract_new_path_from_rename("src/{old.rs => new.rs}"),
            Some("src/new.rs".to_string())
        );

        // Rename without prefix
        assert_eq!(
            crate::model::extract_new_path_from_rename("{old.rs => new.rs}"),
            Some("new.rs".to_string())
        );

        // Deep path rename
        assert_eq!(
            crate::model::extract_new_path_from_rename("src/components/{Button.tsx => button.tsx}"),
            Some("src/components/button.tsx".to_string())
        );

        // Not a rename pattern
        assert_eq!(
            crate::model::extract_new_path_from_rename("src/main.rs"),
            None
        );
    }

    #[test]
    fn test_compare_mode_blame_returns_notification() {
        use crate::model::{ChangeId, CommitId, CompareInfo, CompareRevisionInfo};

        let compare_info = CompareInfo {
            from: CompareRevisionInfo {
                change_id: ChangeId::new("aaaa1111".to_string()),
                commit_id: CommitId::new("ff001111".to_string()),
                bookmarks: vec![],
                author: "user@test.com".to_string(),
                timestamp: "2024-01-01".to_string(),
                description: "from revision".to_string(),
            },
            to: CompareRevisionInfo {
                change_id: ChangeId::new("bbbb2222".to_string()),
                commit_id: CommitId::new("ff002222".to_string()),
                bookmarks: vec![],
                author: "user@test.com".to_string(),
                timestamp: "2024-01-02".to_string(),
                description: "to revision".to_string(),
            },
        };
        let mut view = DiffView::new_compare(create_test_content(), compare_info);

        // Press 'a' (annotate/blame) in compare mode — should return notification
        let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('a')));
        assert_eq!(
            action,
            DiffAction::ShowNotification(
                "Blame is not available in compare/interdiff/stack mode".to_string()
            )
        );
    }

    #[test]
    fn test_jump_to_file_with_rename() {
        // Create content with a renamed file
        let content = DiffContent {
            commit_id: CommitId::new("test123".to_string()),
            author: "Test".to_string(),
            timestamp: "2024-01-30".to_string(),
            description: "Test".to_string(),
            lines: vec![
                DiffLine::file_header("src/{old.rs => new.rs}"),
                DiffLine::added(1, "content"),
            ],
        };

        let mut view = DiffView::new("test".to_string(), content);
        assert_eq!(view.file_names, vec!["src/{old.rs => new.rs}"]);

        // Jump using the new path (as StatusView would provide)
        view.jump_to_file("src/new.rs");
        assert_eq!(view.current_file_index, 0);
        assert_eq!(view.scroll_offset, 0);
    }

    #[test]
    fn test_yank_key_returns_copy_full() {
        let mut view = DiffView::new("test".to_string(), create_test_content());
        let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('y')));
        assert_eq!(action, DiffAction::CopyToClipboard { full: true });
    }

    #[test]
    fn test_yank_diff_key_returns_copy_diff_only() {
        let mut view = DiffView::new("test".to_string(), create_test_content());
        let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('Y')));
        assert_eq!(action, DiffAction::CopyToClipboard { full: false });
    }

    #[test]
    fn test_write_key_returns_export() {
        let mut view = DiffView::new("test".to_string(), create_test_content());
        let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('w')));
        assert_eq!(action, DiffAction::ExportToFile);
    }

    #[test]
    fn test_format_cycle_key_returns_cycle_format() {
        let mut view = DiffView::new("test".to_string(), create_test_content());
        let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('m')));
        assert_eq!(action, DiffAction::CycleFormat);
    }

    #[test]
    fn test_cycle_format_cycles_through_all() {
        use crate::model::DiffDisplayFormat;

        let mut view = DiffView::new("test".to_string(), create_test_content());
        assert_eq!(view.display_format, DiffDisplayFormat::ColorWords);

        let fmt = view.cycle_format();
        assert_eq!(fmt, DiffDisplayFormat::Stat);
        assert_eq!(view.display_format, DiffDisplayFormat::Stat);

        let fmt = view.cycle_format();
        assert_eq!(fmt, DiffDisplayFormat::Git);

        let fmt = view.cycle_format();
        assert_eq!(fmt, DiffDisplayFormat::ColorWords);
    }

    #[test]
    fn test_clear_resets_format() {
        use crate::model::DiffDisplayFormat;

        let mut view = DiffView::new("test".to_string(), create_test_content());
        view.cycle_format(); // Now Stat
        assert_eq!(view.display_format, DiffDisplayFormat::Stat);

        view.clear();
        assert_eq!(view.display_format, DiffDisplayFormat::ColorWords);
    }

    #[test]
    fn test_format_rollback_pattern() {
        // Verify the old_format / cycle_format / rollback pattern used in App
        use crate::model::DiffDisplayFormat;

        let mut view = DiffView::new("test".to_string(), create_test_content());

        // Simulate: save old, cycle, then rollback on error
        let old_format = view.display_format;
        assert_eq!(old_format, DiffDisplayFormat::ColorWords);

        let new_format = view.cycle_format();
        assert_eq!(new_format, DiffDisplayFormat::Stat);
        assert_eq!(view.display_format, DiffDisplayFormat::Stat);

        // Simulate error → rollback
        view.display_format = old_format;
        assert_eq!(view.display_format, DiffDisplayFormat::ColorWords);
    }
}
