use super::types::{MarkViewedResult, PendingApproveChoice};
use super::*;
use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use lasso::Rodeo;
use serial_test::serial;
use tempfile::tempdir;

use crate::cache::{PrCacheKey, PrData};
use crate::github::comment::ReviewComment;
use crate::github::{ChangedFile, PrCommit, PullRequest};
use crate::loader::DataLoadResult;

struct ScopedCacheHome {
    old: Option<std::ffi::OsString>,
}

impl ScopedCacheHome {
    fn new(dir: &std::path::Path) -> Self {
        let old = std::env::var_os("XDG_CACHE_HOME");
        unsafe { std::env::set_var("XDG_CACHE_HOME", dir) };
        Self { old }
    }
}

impl Drop for ScopedCacheHome {
    fn drop(&mut self) {
        match self.old.take() {
            Some(v) => unsafe { std::env::set_var("XDG_CACHE_HOME", v) },
            None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
        }
    }
}

#[test]
fn test_find_diff_line_index_basic() {
    let patch = r#"@@ -1,3 +1,4 @@
 context line
+added line
 another context
-removed line"#;

    // Line 1 (context) is at diff index 1
    assert_eq!(App::find_diff_line_index(patch, 1), Some(1));
    // Line 2 (added) is at diff index 2
    assert_eq!(App::find_diff_line_index(patch, 2), Some(2));
    // Line 3 (context) is at diff index 3
    assert_eq!(App::find_diff_line_index(patch, 3), Some(3));
    // Line 5 doesn't exist in new file
    assert_eq!(App::find_diff_line_index(patch, 5), None);
}

#[test]
fn test_find_diff_line_index_multi_hunk() {
    let patch = r#"@@ -1,2 +1,2 @@
 line1
+new line2
@@ -10,2 +10,2 @@
 line10
+new line11"#;

    // First hunk: line 1 at index 1, line 2 at index 2
    assert_eq!(App::find_diff_line_index(patch, 1), Some(1));
    assert_eq!(App::find_diff_line_index(patch, 2), Some(2));
    // Second hunk: line 10 at index 4, line 11 at index 5
    assert_eq!(App::find_diff_line_index(patch, 10), Some(4));
    assert_eq!(App::find_diff_line_index(patch, 11), Some(5));
}

#[test]
fn test_has_comment_at_current_line() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.cmt.file_comment_positions = vec![
        CommentPosition {
            diff_line_index: 5,
            comment_index: 0,
        },
        CommentPosition {
            diff_line_index: 10,
            comment_index: 1,
        },
    ];

    app.diff_scroll.selected_line = 5;
    assert!(app.has_comment_at_current_line());

    app.diff_scroll.selected_line = 10;
    assert!(app.has_comment_at_current_line());

    app.diff_scroll.selected_line = 7;
    assert!(!app.has_comment_at_current_line());
}

#[test]
fn test_get_comment_indices_at_current_line() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    // Two comments on line 5, one on line 10
    app.cmt.file_comment_positions = vec![
        CommentPosition {
            diff_line_index: 5,
            comment_index: 0,
        },
        CommentPosition {
            diff_line_index: 5,
            comment_index: 2,
        },
        CommentPosition {
            diff_line_index: 10,
            comment_index: 1,
        },
    ];

    app.diff_scroll.selected_line = 5;
    let indices = app.get_comment_indices_at_current_line();
    assert_eq!(indices, vec![0, 2]);

    app.diff_scroll.selected_line = 10;
    let indices = app.get_comment_indices_at_current_line();
    assert_eq!(indices, vec![1]);

    app.diff_scroll.selected_line = 7;
    let indices = app.get_comment_indices_at_current_line();
    assert!(indices.is_empty());
}

#[test]
fn test_jump_to_next_comment_basic() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.cmt.file_comment_positions = vec![
        CommentPosition {
            diff_line_index: 5,
            comment_index: 0,
        },
        CommentPosition {
            diff_line_index: 10,
            comment_index: 1,
        },
        CommentPosition {
            diff_line_index: 15,
            comment_index: 2,
        },
    ];

    app.diff_scroll.selected_line = 0;
    app.jump_to_next_comment();
    assert_eq!(app.diff_scroll.selected_line, 5);

    app.jump_to_next_comment();
    assert_eq!(app.diff_scroll.selected_line, 10);

    app.jump_to_next_comment();
    assert_eq!(app.diff_scroll.selected_line, 15);
}

#[test]
fn test_jump_to_next_comment_no_wrap() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.cmt.file_comment_positions = vec![CommentPosition {
        diff_line_index: 5,
        comment_index: 0,
    }];

    app.diff_scroll.selected_line = 5;
    app.jump_to_next_comment();
    // Should stay at 5 (no wrap-around)
    assert_eq!(app.diff_scroll.selected_line, 5);
}

#[test]
fn test_jump_to_prev_comment_basic() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.cmt.file_comment_positions = vec![
        CommentPosition {
            diff_line_index: 5,
            comment_index: 0,
        },
        CommentPosition {
            diff_line_index: 10,
            comment_index: 1,
        },
        CommentPosition {
            diff_line_index: 15,
            comment_index: 2,
        },
    ];

    app.diff_scroll.selected_line = 20;
    app.jump_to_prev_comment();
    assert_eq!(app.diff_scroll.selected_line, 15);

    app.jump_to_prev_comment();
    assert_eq!(app.diff_scroll.selected_line, 10);

    app.jump_to_prev_comment();
    assert_eq!(app.diff_scroll.selected_line, 5);
}

#[test]
fn test_jump_to_prev_comment_no_wrap() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.cmt.file_comment_positions = vec![CommentPosition {
        diff_line_index: 5,
        comment_index: 0,
    }];

    app.diff_scroll.selected_line = 5;
    app.jump_to_prev_comment();
    // Should stay at 5 (no wrap-around)
    assert_eq!(app.diff_scroll.selected_line, 5);
}

#[test]
fn test_jump_with_empty_positions() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.cmt.file_comment_positions = vec![];

    app.diff_scroll.selected_line = 10;
    app.jump_to_next_comment();
    assert_eq!(app.diff_scroll.selected_line, 10);

    app.jump_to_prev_comment();
    assert_eq!(app.diff_scroll.selected_line, 10);
}

#[test]
fn test_liststate_autoscroll_with_multiline_items() {
    use ratatui::buffer::Buffer;
    use ratatui::layout::Rect;
    use ratatui::text::Line;
    use ratatui::widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget};

    // 10 multiline items (each 3 lines), area height = 12 (10 inner after borders)
    let items: Vec<ListItem> = (0..10)
        .map(|i| {
            ListItem::new(vec![
                Line::from(format!("Header {}", i)),
                Line::from(format!("  Body {}", i)),
                Line::from(""),
            ])
        })
        .collect();

    let area = Rect::new(0, 0, 40, 12); // 12 total, 10 inner

    // Simulate frame-by-frame scrolling like the real app
    let mut offset = 0usize;
    for selected in 0..10 {
        let list = List::new(items.clone()).block(Block::default().borders(Borders::ALL));
        let mut state = ListState::default()
            .with_offset(offset)
            .with_selected(Some(selected));
        let mut buf = Buffer::empty(area);
        StatefulWidget::render(&list, area, &mut buf, &mut state);
        offset = state.offset();

        // selected should always be in visible range [offset, offset + visible_items)
        // With 10 inner height and 3 lines per item, 3 items fit (9 lines)
        assert!(
            selected >= offset,
            "selected={} should be >= offset={}",
            selected,
            offset
        );
    }

    // After scrolling to last item, offset should be > 0
    assert!(offset > 0, "offset should have scrolled, got {}", offset);
}

#[test]
fn test_back_to_pr_list_clears_view_receivers() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.started_from_pr_list = true;

    // data_receiver is already set by new_loading
    assert!(app.data_receiver.is_some());

    // Set up additional receivers to simulate in-flight requests
    let (_comment_tx, comment_rx) = mpsc::channel(1);
    app.cmt.comment_receiver = Some((1, comment_rx));
    let (_disc_tx, disc_rx) = mpsc::channel(1);
    app.cmt.discussion_comment_receiver = Some((1, disc_rx));
    let (_submit_tx, submit_rx) = mpsc::channel(1);
    app.cmt.comment_submit_receiver = Some((1, submit_rx));
    let (_mark_tx, mark_rx) = mpsc::channel(1);
    app.mark_viewed_receiver = Some((1, mark_rx));
    app.cmt.comment_submitting = true;
    app.cmt.comments_loading = true;
    app.cmt.discussion_comments_loading = true;
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(1);
    app.retry_sender = Some(retry_tx);

    app.back_to_pr_list();

    // data_receiver / retry_sender は永続のため維持
    assert!(app.data_receiver.is_some());
    assert!(app.retry_sender.is_some());
    // view 系 receivers はクリア
    assert!(app.cmt.comment_receiver.is_none());
    assert!(app.cmt.discussion_comment_receiver.is_none());
    assert!(app.cmt.comment_submit_receiver.is_none());
    assert!(app.mark_viewed_receiver.is_none());
    assert!(!app.diff_store.has_highlight_rx());
    assert!(!app.diff_store.has_prefetch_rx());
    // Loading flags should be cleared
    assert!(!app.cmt.comment_submitting);
    assert!(!app.cmt.comments_loading);
    assert!(!app.cmt.discussion_comments_loading);
    // PR number should be None
    assert!(app.pr_number.is_none());
    assert_eq!(app.state, AppState::PullRequestList);
}

#[test]
fn test_back_to_pr_list_from_local_mode_resets_local_state() {
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 0, config);
    app.started_from_pr_list = true;
    app.local_mode = true;
    app.pr_number = Some(0);
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((0, data_rx));
    app.selected_file = 2;

    app.back_to_pr_list();

    // local_mode がリセットされている
    assert!(!app.local_mode);
    assert_eq!(app.state, AppState::PullRequestList);
    assert!(app.pr_number.is_none());
}

#[tokio::test]
async fn test_pr_list_local_toggle_round_trip() {
    // PR一覧 → L(Local) → q(PR一覧) → L(Local) の往復でデータが正常に表示されるか
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(8);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let mut app = App::new_for_test();
    app.started_from_pr_list = true;
    app.state = AppState::PullRequestList;
    app.pr_number = None;
    app.original_pr_number = None;
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((0, data_rx));

    // SessionCache に Local diff データを事前格納
    let local_pr = PullRequest {
        number: 0,
        node_id: None,
        title: "Local HEAD diff".to_string(),
        body: None,
        state: "local".to_string(),
        head: crate::github::Branch {
            ref_name: "HEAD".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "local".to_string(),
            sha: "local".to_string(),
        },
        user: crate::github::User {
            login: "local".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    };
    let local_files = vec![ChangedFile {
        filename: "src/main.rs".to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 0,
        patch: Some("@@ -1,1 +1,2 @@\n line1\n+line2".to_string()),
        viewed: false,
    }];
    app.session_cache.put_pr_data(
        PrCacheKey {
            repo: "test/repo".to_string(),
            pr_number: 0,
        },
        PrData {
            pr: Box::new(local_pr),
            files: local_files,
            pr_updated_at: "2024-01-01T00:00:00Z".to_string(),
        },
    );

    // Step 1: PR一覧 → L (Local モード)
    app.toggle_local_mode();
    assert!(app.local_mode);
    assert_eq!(app.pr_number, Some(0));
    assert_eq!(app.state, AppState::FileList);
    assert!(matches!(app.data_state, DataState::Loaded { .. }));

    // Step 2: q → PR一覧に戻る
    app.back_to_pr_list();
    assert!(!app.local_mode);
    assert_eq!(app.state, AppState::PullRequestList);

    // Step 3: L → 再度 Local モード（1回目で正しく Local に入る）
    app.toggle_local_mode();
    assert!(app.local_mode);
    assert_eq!(app.pr_number, Some(0));
    assert_eq!(app.state, AppState::FileList);
    // SessionCache から即時表示
    assert!(matches!(app.data_state, DataState::Loaded { .. }));
}

#[tokio::test]
async fn test_poll_data_updates_discards_stale_pr_data() {
    let config = Config::default();
    let (mut app, tx) = App::new_loading("owner/repo", 1, config);
    app.started_from_pr_list = true;

    // Simulate switching to PR #2 while PR #1 data is in-flight
    // The data_receiver still carries origin_pr = 1
    app.pr_number = Some(2);

    // Send data for PR #1
    let pr = PullRequest {
        number: 1,
        node_id: None,
        title: "PR 1".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    };
    tx.send(DataLoadResult::Success {
        pr: Box::new(pr),
        files: vec![],
    })
    .await
    .unwrap();

    // Poll should NOT panic and should NOT apply PR #1 data to current UI state
    app.poll_data_updates();

    // data_receiver should be kept alive (persistent channel for future refreshes)
    assert!(app.data_receiver.is_some());
    // data_state should still be Loading (PR #1 data was discarded from UI)
    assert!(matches!(app.data_state, DataState::Loading));
    // But session cache should have the data under PR #1 key
    let cache_key = PrCacheKey {
        repo: "owner/repo".to_string(),
        pr_number: 1,
    };
    assert!(app.session_cache.get_pr_data(&cache_key).is_some());
}

#[tokio::test]
async fn test_poll_comment_updates_discards_stale_pr_comments() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.started_from_pr_list = true;

    // Set up a comment receiver for PR #1
    let (comment_tx, comment_rx) = mpsc::channel(1);
    app.cmt.comment_receiver = Some((1, comment_rx));
    app.cmt.comments_loading = true;

    // Simulate switching to PR #2
    app.pr_number = Some(2);

    // Send comments for PR #1
    comment_tx.send(Ok(vec![])).await.unwrap();

    // Poll should NOT panic and should NOT apply PR #1 comments to UI
    app.poll_comment_updates();

    assert!(app.cmt.comment_receiver.is_none());
    // comments_loading should NOT have been cleared (different PR)
    assert!(app.cmt.comments_loading);
    // Session cache should NOT have comments for PR #1 since pr_data was never stored
    // (comments are only cached for keys that have an existing pr_data entry)
    let cache_key = PrCacheKey {
        repo: "owner/repo".to_string(),
        pr_number: 1,
    };
    assert!(app.session_cache.get_review_comments(&cache_key).is_none());
}

#[tokio::test]
async fn test_handle_data_result_clamps_selected_file_when_files_shrink() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);

    // Simulate initial state with 5 files, selected_file pointing to file index 4
    let make_file = |name: &str| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    };

    let initial_files: Vec<ChangedFile> = (0..5)
        .map(|i| make_file(&format!("file_{}.rs", i)))
        .collect();

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    // Set initial loaded state with 5 files
    app.data_state = DataState::Loaded {
        pr: pr.clone(),
        files: initial_files,
    };
    app.selected_file = 4; // Last file selected

    // Now simulate refresh with only 2 files (file count shrank)
    let fewer_files: Vec<ChangedFile> = (0..2)
        .map(|i| make_file(&format!("file_{}.rs", i)))
        .collect();

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: fewer_files,
        },
    );

    // selected_file should be clamped to 1 (last valid index for 2 files)
    assert_eq!(app.selected_file, 1);
    // Should be able to access the file without panic
    assert!(app.files().get(app.selected_file).is_some());
}

#[tokio::test]
async fn test_handle_data_result_resyncs_diff_state_when_selected_file_changes() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);

    let make_file = |name: &str| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    };

    let initial_files: Vec<ChangedFile> = (0..5)
        .map(|i| make_file(&format!("file_{}.rs", i)))
        .collect();

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    // Set initial loaded state with 5 files
    app.data_state = DataState::Loaded {
        pr: pr.clone(),
        files: initial_files,
    };
    app.selected_file = 4;
    app.diff_scroll.selected_line = 10;
    app.diff_scroll.scroll_offset = 5;

    // Set a stale diff_cache pointing to old file index 4
    app.diff_store.current = Some(DiffCache {
        file_index: 4,
        patch_hash: 0,
        lines: vec![],
        interner: Rodeo::default(),
        highlighted: false,
        markdown_rich: false,
    });

    // Refresh with only 2 files (selected_file will be clamped from 4 to 1)
    let fewer_files: Vec<ChangedFile> = (0..2)
        .map(|i| make_file(&format!("file_{}.rs", i)))
        .collect();

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: fewer_files,
        },
    );

    // selected_file clamped
    assert_eq!(app.selected_file, 1);
    // diff_cache must be rebuilt for the new selected file (ensure_diff_cache rebuilds it)
    assert_eq!(
        app.diff_store.current.as_ref().map(|c| c.file_index),
        Some(1),
        "diff_cache should be rebuilt for the new selected file"
    );
    // selected_line and scroll_offset must be reset
    assert_eq!(
        app.diff_scroll.selected_line, 0,
        "selected_line should be reset to 0"
    );
    assert_eq!(
        app.diff_scroll.scroll_offset, 0,
        "scroll_offset should be reset to 0"
    );
}

#[tokio::test]
async fn test_handle_data_result_resyncs_comment_positions_when_selected_file_changes() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);

    let make_file = |name: &str| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    };

    let initial_files: Vec<ChangedFile> = (0..5)
        .map(|i| make_file(&format!("file_{}.rs", i)))
        .collect();

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    // Set initial loaded state with 5 files, selected_file = 4
    app.data_state = DataState::Loaded {
        pr: pr.clone(),
        files: initial_files,
    };
    app.selected_file = 4;

    // Set up review comments for file_4.rs (the old selected file)
    app.cmt.review_comments = Some(vec![ReviewComment {
        id: 1,
        path: "file_4.rs".to_string(),
        line: Some(1),
        start_line: None,
        body: "comment on old file".to_string(),
        user: crate::github::User {
            login: "reviewer".to_string(),
        },
        created_at: "2024-01-01T00:00:00Z".to_string(),
        in_reply_to_id: None,
    }]);

    // Pre-populate stale comment positions for the old file
    app.cmt.file_comment_positions = vec![CommentPosition {
        diff_line_index: 2,
        comment_index: 0,
    }];
    app.cmt.file_comment_lines.insert(2);
    app.cmt.comment_panel_open = true;
    app.cmt.comment_panel_scroll = 5;

    // Refresh with only 2 files (selected_file will be clamped from 4 to 1)
    let fewer_files: Vec<ChangedFile> = (0..2)
        .map(|i| make_file(&format!("file_{}.rs", i)))
        .collect();

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: fewer_files,
        },
    );

    // selected_file clamped to 1
    assert_eq!(app.selected_file, 1);

    // file_comment_positions should be recalculated for file_1.rs (no matching comments)
    assert!(
        app.cmt.file_comment_positions.is_empty(),
        "file_comment_positions should be recalculated for new file (no comments for file_1.rs)"
    );
    assert!(
        app.cmt.file_comment_lines.is_empty(),
        "file_comment_lines should be recalculated for new file"
    );

    // comment_panel should be closed
    assert!(
        !app.cmt.comment_panel_open,
        "comment_panel_open should be reset when selected_file changes"
    );
    assert_eq!(
        app.cmt.comment_panel_scroll, 0,
        "comment_panel_scroll should be reset when selected_file changes"
    );
}

#[tokio::test]
async fn test_handle_data_result_preserves_diff_state_when_selected_file_unchanged() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);

    let make_file = |name: &str| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    };

    let initial_files: Vec<ChangedFile> = (0..5)
        .map(|i| make_file(&format!("file_{}.rs", i)))
        .collect();

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    // Set initial loaded state
    app.data_state = DataState::Loaded {
        pr: pr.clone(),
        files: initial_files,
    };
    app.selected_file = 1;
    app.diff_scroll.selected_line = 2;
    app.diff_scroll.scroll_offset = 1;

    // Set diff_cache for file index 1
    app.diff_store.current = Some(DiffCache {
        file_index: 1,
        patch_hash: 0,
        lines: vec![],
        interner: Rodeo::default(),
        highlighted: false,
        markdown_rich: false,
    });

    // Refresh with same or more files (selected_file stays at 1)
    let same_files: Vec<ChangedFile> = (0..5)
        .map(|i| make_file(&format!("file_{}.rs", i)))
        .collect();

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: same_files,
        },
    );

    // selected_file unchanged
    assert_eq!(app.selected_file, 1);
    // diff_cache should NOT be invalidated (selected_file didn't change)
    assert!(
        app.diff_store.current.is_some(),
        "diff_cache should be preserved when selected_file is unchanged"
    );
    // selected_line and scroll_offset should be preserved (within valid range for 3-line diff)
    assert_eq!(app.diff_scroll.selected_line, 2);
    assert_eq!(app.diff_scroll.scroll_offset, 1);
}

#[tokio::test]
async fn test_handle_data_result_keeps_selected_file_by_filename() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.set_local_mode(true);
    app.set_local_auto_focus(false);

    let make_file = |name: &str| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    };

    let initial_files: Vec<ChangedFile> = vec![
        make_file("file_a.rs"),
        make_file("file_b.rs"),
        make_file("file_c.rs"),
    ];

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    app.data_state = DataState::Loaded {
        pr: pr.clone(),
        files: initial_files.clone(),
    };
    app.selected_file = 1; // file_b.rs
    app.remember_local_file_signatures(&initial_files);

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: vec![make_file("file_b.rs"), make_file("file_c.rs")],
        },
    );

    assert_eq!(
        app.selected_file, 0,
        "selected file should track file_b.rs by filename, not by index"
    );
}

#[tokio::test]
async fn test_handle_data_result_auto_focus_selects_next_changed_file() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.set_local_mode(true);
    app.set_local_auto_focus(true);
    app.selected_file = 1;

    let make_file = |name: &str, additions: u32| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    };

    let initial_files = vec![
        make_file("file_a.rs", 1),
        make_file("file_b.rs", 1),
        make_file("file_c.rs", 1),
        make_file("file_d.rs", 1),
    ];

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    app.data_state = DataState::Loaded {
        pr: pr.clone(),
        files: initial_files.clone(),
    };
    app.remember_local_file_signatures(&initial_files);

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: vec![
                make_file("file_a.rs", 2), // changed (additions: 1→2)
                make_file("file_b.rs", 1), // unchanged
                make_file("file_c.rs", 1), // unchanged
                make_file("file_d.rs", 2), // changed (additions: 1→2)
            ],
        },
    );

    assert_eq!(
        app.selected_file, 3,
        "auto-focus should prefer the next changed file after current selection"
    );
}

#[tokio::test]
async fn test_handle_data_result_auto_focus_prefers_nearest_changed_file() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.set_local_mode(true);
    app.set_local_auto_focus(true);
    app.selected_file = 3;

    let make_file = |name: &str, additions: u32| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    };

    let initial_files = vec![
        make_file("file_a.rs", 1),
        make_file("file_b.rs", 1),
        make_file("file_c.rs", 1),
        make_file("file_d.rs", 1),
        make_file("file_e.rs", 1),
    ];

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    app.data_state = DataState::Loaded {
        pr: pr.clone(),
        files: initial_files.clone(),
    };
    app.remember_local_file_signatures(&initial_files);

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: vec![
                make_file("file_a.rs", 2), // changed before (additions: 1→2)
                make_file("file_b.rs", 1), // unchanged
                make_file("file_c.rs", 1), // unchanged
                make_file("file_d.rs", 1), // unchanged
                make_file("file_e.rs", 2), // changed after (additions: 1→2)
            ],
        },
    );

    assert_eq!(
        app.selected_file, 4,
        "auto-focus should move to the nearer changed file around current selection"
    );
}

#[tokio::test]
async fn test_handle_data_result_auto_focus_prefers_next_when_distances_are_tie() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.set_local_mode(true);
    app.set_local_auto_focus(true);
    app.selected_file = 2;

    let make_file = |name: &str, additions: u32| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    };

    let initial_files = vec![
        make_file("file_a.rs", 1),
        make_file("file_b.rs", 1),
        make_file("file_c.rs", 1),
        make_file("file_d.rs", 1),
        make_file("file_e.rs", 1),
    ];

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    app.data_state = DataState::Loaded {
        pr: pr.clone(),
        files: initial_files.clone(),
    };
    app.remember_local_file_signatures(&initial_files);

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: vec![
                make_file("file_a.rs", 1), // unchanged (index 0)
                make_file("file_b.rs", 2), // changed (index 1, additions: 1→2)
                make_file("file_c.rs", 1), // unchanged (index 2)
                make_file("file_d.rs", 2), // changed (index 3, additions: 1→2)
                make_file("file_e.rs", 1), // unchanged (index 4)
            ],
        },
    );

    assert_eq!(
        app.selected_file, 3,
        "auto-focus should prefer the next file when before/after distances are equal"
    );
}

#[tokio::test]
async fn test_handle_data_result_auto_focus_transitions_to_split_view_diff() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.set_local_mode(true);
    app.set_local_auto_focus(true);
    app.state = AppState::FileList;

    let make_file = |name: &str, patch: &str| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some(patch.to_string()),
        viewed: false,
    };

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr: pr.clone(),
            files: vec![make_file("initial.rs", "@@ -1,1 +1,1 @@\n-old\n+new")],
        },
    );

    assert_eq!(app.state, AppState::SplitViewDiff);
    assert_eq!(app.selected_file, 0);
    assert_eq!(app.files().len(), 1);
}

#[test]
fn test_toggle_auto_focus() {
    let mut app = App::new_for_test();
    app.local_mode = true;
    assert!(!app.local_auto_focus);

    app.toggle_auto_focus();
    assert!(app.local_auto_focus);
    assert!(app.cmt.submission_result.is_some());
    assert!(app.cmt.submission_result.as_ref().unwrap().1.contains("ON"));

    app.toggle_auto_focus();
    assert!(!app.local_auto_focus);
    assert!(app
        .cmt
        .submission_result
        .as_ref()
        .unwrap()
        .1
        .contains("OFF"));
}

#[test]
fn test_toggle_local_mode_blocks_during_ai_rally() {
    let mut app = App::new_for_test();
    app.state = AppState::AiRally;

    app.toggle_local_mode();
    assert!(!app.local_mode);
    assert!(app
        .cmt
        .submission_result
        .as_ref()
        .unwrap()
        .1
        .contains("Cannot"));
}

// ===================================================================
#[test]
fn test_back_to_pr_list_resets_pr_state() {
    let mut app = App::new_for_test();
    app.started_from_pr_list = true;
    app.pr_number = Some(42);
    app.cmt.review_comments = Some(vec![]);
    app.cmt.discussion_comments = Some(vec![]);
    app.diff_store.current = Some(crate::ui::diff_view::build_plain_diff_cache(
        "@@ -1 +1 @@\n+x",
        4,
    ));
    let mut hc = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+y", 4);
    hc.file_index = 1;
    app.diff_store.store.insert(1, hc);

    app.back_to_pr_list();

    // PR 固有の状態がリセットされること
    assert!(app.pr_number.is_none());
    assert!(matches!(app.data_state, DataState::Loading));
    assert!(app.cmt.review_comments.is_none());
    assert!(app.cmt.discussion_comments.is_none());
    assert!(app.diff_store.current.is_none());
    assert!(app.diff_store.store.is_empty());
    assert_eq!(app.selected_file, 0);
    assert_eq!(app.file_list_scroll_offset, 0);
    assert_eq!(app.diff_scroll.selected_line, 0);
    assert_eq!(app.diff_scroll.scroll_offset, 0);
    assert!(app.file_list_filter.is_none());
}

#[test]
fn test_back_to_pr_list_clears_all_receivers() {
    let mut app = App::new_for_test();
    app.started_from_pr_list = true;

    // レシーバーを設定
    let (_tx1, rx1) =
        mpsc::channel::<Result<Vec<crate::github::comment::ReviewComment>, String>>(1);
    app.cmt.comment_receiver = Some((1, rx1));
    let (_tx2, rx2) = mpsc::channel(1);
    app.diff_store.set_highlight_rx(rx2);
    let (_tx3, rx3) = mpsc::channel(1);
    app.diff_store.set_prefetch_rx(rx3);
    let (_tx4, rx4) =
        mpsc::channel::<Result<Vec<crate::github::comment::DiscussionComment>, String>>(1);
    app.cmt.discussion_comment_receiver = Some((1, rx4));
    let (_tx5, rx5) = mpsc::channel::<crate::loader::CommentSubmitResult>(1);
    app.cmt.comment_submit_receiver = Some((1, rx5));
    let (_tx6, rx6) = mpsc::channel::<MarkViewedResult>(1);
    app.mark_viewed_receiver = Some((1, rx6));
    let (_tx7, rx7) = mpsc::channel::<Vec<crate::loader::SingleFileDiffResult>>(1);
    app.batch_diff_receiver = Some(rx7);
    let (_tx8, rx8) = mpsc::channel::<crate::loader::SingleFileDiffResult>(1);
    app.lazy_diff_receiver = Some(rx8);
    app.lazy_diff_pending_file = Some("file.rs".to_string());
    app.cmt.comment_submitting = true;
    app.cmt.comments_loading = true;
    app.cmt.discussion_comments_loading = true;

    app.back_to_pr_list();

    // 全レシーバーがクリアされること
    assert!(app.cmt.comment_receiver.is_none());
    assert!(!app.diff_store.has_highlight_rx());
    assert!(!app.diff_store.has_prefetch_rx());
    assert!(app.cmt.discussion_comment_receiver.is_none());
    assert!(app.cmt.comment_submit_receiver.is_none());
    assert!(app.mark_viewed_receiver.is_none());
    assert!(app.batch_diff_receiver.is_none());
    assert!(app.lazy_diff_receiver.is_none());
    assert!(app.lazy_diff_pending_file.is_none());
    assert!(!app.cmt.comment_submitting);
    assert!(!app.cmt.comments_loading);
    assert!(!app.cmt.discussion_comments_loading);
}

/// PR(--pr指定)→Local→PR の往復で pr_number が復元されること
#[test]
fn test_toggle_local_mode_pr_to_local_and_back() {
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let mut app = App::new_for_test();
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((42, data_rx));
    app.original_pr_number = Some(42);
    app.pr_number = Some(42);

    // PR → Local: クリーン起動
    app.toggle_local_mode();
    assert!(app.local_mode);
    assert_eq!(app.pr_number, Some(0));
    assert_eq!(app.selected_file, 0); // リセットされる
    assert!(app
        .cmt
        .submission_result
        .as_ref()
        .unwrap()
        .1
        .contains("Local"));

    // Local → PR: original_pr_number で復帰
    app.toggle_local_mode();
    assert!(!app.local_mode);
    assert_eq!(app.pr_number, Some(42));
    assert_eq!(app.state, AppState::FileList);
    assert!(app.cmt.submission_result.as_ref().unwrap().1.contains("PR"));
}

/// PR→Local→PR の2往復でも pr_number が安定すること
#[test]
fn test_toggle_local_mode_roundtrip_preserves_pr_number() {
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let mut app = App::new_for_test();
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((42, data_rx));
    app.original_pr_number = Some(42);
    app.pr_number = Some(42);

    for _ in 0..3 {
        app.toggle_local_mode();
        assert!(app.local_mode);
        assert_eq!(app.pr_number, Some(0));

        app.toggle_local_mode();
        assert!(!app.local_mode);
        assert_eq!(app.pr_number, Some(42));
    }
}

/// PR一覧→Local→PR一覧に戻れること
#[tokio::test]
async fn test_toggle_local_mode_from_pr_list_without_selecting_pr() {
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let mut app = App::new_for_test();
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((0, data_rx));
    app.original_pr_number = None;
    app.started_from_pr_list = false;
    app.pr_number = None;
    app.state = AppState::PullRequestList;

    // PR一覧 → Local
    app.toggle_local_mode();
    assert!(app.local_mode);
    assert_eq!(app.pr_number, Some(0));
    assert_eq!(app.state, AppState::FileList);

    // Local → PR一覧
    app.toggle_local_mode();
    assert!(!app.local_mode);
    assert_eq!(app.state, AppState::PullRequestList);
}

/// PR一覧→PR選択→Local→PR一覧の往復（original_pr_numberなし）
#[tokio::test]
async fn test_toggle_local_mode_roundtrip_from_pr_list() {
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let mut app = App::new_for_test();
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((99, data_rx));
    app.original_pr_number = None;
    app.started_from_pr_list = false;
    app.pr_number = Some(99);

    // PR → Local
    app.toggle_local_mode();
    assert!(app.local_mode);
    assert_eq!(app.pr_number, Some(0));

    // Local → PR一覧（original_pr_numberがないのでPR一覧へ）
    app.toggle_local_mode();
    assert!(!app.local_mode);
    assert_eq!(app.state, AppState::PullRequestList);
}

/// --local起動（detect_repo成功, repo="owner/repo"）→ L → PR一覧に遷移
#[tokio::test]
async fn test_toggle_local_mode_from_local_startup_with_valid_repo() {
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let mut app = App::new_for_test(); // repo = "test/repo"（スラッシュあり）
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((0, data_rx));
    app.original_pr_number = Some(0); // --local は new_loading(repo, 0) で起動
    app.started_from_pr_list = false;
    app.local_mode = true;
    app.pr_number = Some(0);

    // Local → PR: repo が有効なので PR一覧へ遷移できる
    app.toggle_local_mode();
    assert!(!app.local_mode);
    assert_eq!(app.state, AppState::PullRequestList);
    assert!(app.started_from_pr_list);
}

/// --local起動（detect_repo失敗, repo="local"）→ L → 切替不可エラー
#[test]
fn test_toggle_local_mode_from_local_startup_with_dummy_repo() {
    let mut app = App::new_for_test();
    app.repo = "local".to_string(); // detect_repo 失敗時のフォールバック
    app.original_pr_number = Some(0);
    app.started_from_pr_list = false;
    app.local_mode = true;
    app.pr_number = Some(0);

    // Local → PR: repo がダミーなので切替不可
    app.toggle_local_mode();
    assert!(app.local_mode, "should stay in local mode with dummy repo");
    assert!(
        app.cmt
            .submission_result
            .as_ref()
            .unwrap()
            .1
            .contains("No PR"),
        "should show error message"
    );
}

/// --local起動（有効repo）→ L → PR一覧 → L → local の往復
#[tokio::test]
async fn test_toggle_local_mode_roundtrip_from_local_startup() {
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let mut app = App::new_for_test(); // repo = "test/repo"
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((0, data_rx));
    app.original_pr_number = Some(0);
    app.started_from_pr_list = false;
    app.local_mode = true;
    app.pr_number = Some(0);

    // Local → PR一覧
    app.toggle_local_mode();
    assert!(!app.local_mode);
    assert_eq!(app.state, AppState::PullRequestList);

    // PR一覧 → Local
    app.toggle_local_mode();
    assert!(app.local_mode);
    assert_eq!(app.pr_number, Some(0));
    assert_eq!(app.state, AppState::FileList);

    // Local → PR一覧（2回目も安定して動作）
    app.toggle_local_mode();
    assert!(!app.local_mode);
    assert_eq!(app.state, AppState::PullRequestList);
}

#[test]
fn test_retry_load_sends_correct_request_type() {
    let (tx, mut rx) = mpsc::channel::<RefreshRequest>(1);
    let mut app = App::new_for_test();
    app.retry_sender = Some(tx);

    // PR mode
    app.local_mode = false;
    app.pr_number = Some(42);
    app.retry_load();
    let req = rx.try_recv().unwrap();
    assert!(matches!(req, RefreshRequest::PrRefresh { pr_number: 42 }));

    // Local mode
    app.local_mode = true;
    app.data_state = DataState::Loading; // reset from retry_load
    app.retry_load();
    let req = rx.try_recv().unwrap();
    assert!(matches!(req, RefreshRequest::LocalRefresh));
}

#[test]
fn test_is_shift_char_shortcut_accepts_uppercase() {
    let key = KeyEvent {
        code: KeyCode::Char('J'),
        modifiers: KeyModifiers::SHIFT,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    };
    assert!(App::is_shift_char_shortcut(&key, 'j'));
}

#[test]
fn test_is_shift_char_shortcut_rejects_ctrl_or_alt() {
    let ctrl = KeyEvent {
        code: KeyCode::Char('J'),
        modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    };
    let alt = KeyEvent {
        code: KeyCode::Char('K'),
        modifiers: KeyModifiers::SHIFT | KeyModifiers::ALT,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    };

    assert!(!App::is_shift_char_shortcut(&ctrl, 'j'));
    assert!(!App::is_shift_char_shortcut(&alt, 'k'));
}

#[test]
fn test_collect_unviewed_directory_paths_selected_prefix() {
    let files = vec![
        ChangedFile {
            filename: "src/main.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+test".to_string()),
            viewed: false,
        },
        ChangedFile {
            filename: "src/lib.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+test".to_string()),
            viewed: true,
        },
        ChangedFile {
            filename: "src/utils/mod.rs".to_string(),
            status: "added".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -0,0 +1 @@\n+test".to_string()),
            viewed: false,
        },
        ChangedFile {
            filename: "README.md".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+test".to_string()),
            viewed: false,
        },
    ];

    let paths = App::collect_unviewed_directory_paths(&files, 0);
    assert_eq!(
        paths,
        vec!["src/main.rs".to_string(), "src/utils/mod.rs".to_string()]
    );
}

#[test]
fn test_collect_unviewed_directory_paths_root_prefix_matches_only_root_files() {
    let files = vec![
        ChangedFile {
            filename: "README.md".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+test".to_string()),
            viewed: false,
        },
        ChangedFile {
            filename: "src/main.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+test".to_string()),
            viewed: false,
        },
        ChangedFile {
            filename: "Cargo.toml".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+test".to_string()),
            viewed: true,
        },
    ];

    let paths = App::collect_unviewed_directory_paths(&files, 0);
    assert_eq!(paths, vec!["README.md".to_string()]);
}

#[tokio::test]
async fn test_poll_mark_viewed_applies_unmark() {
    let mut app = App::new_for_test();
    app.pr_number = Some(1);
    app.data_state = DataState::Loaded {
        pr: Box::new(PullRequest {
            number: 1,
            node_id: Some("PR_node1".to_string()),
            title: "Test PR".to_string(),
            body: None,
            state: "open".to_string(),
            head: crate::github::Branch {
                ref_name: "feature".to_string(),
                sha: "abc".to_string(),
            },
            base: crate::github::Branch {
                ref_name: "main".to_string(),
                sha: "def".to_string(),
            },
            user: crate::github::User {
                login: "user".to_string(),
            },
            updated_at: "2024-01-01T00:00:00Z".to_string(),
        }),
        files: vec![
            ChangedFile {
                filename: "src/main.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: None,
                viewed: true,
            },
            ChangedFile {
                filename: "src/lib.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: None,
                viewed: true,
            },
        ],
    };

    let (tx, rx) = mpsc::channel(1);
    app.mark_viewed_receiver = Some((1, rx));

    tx.send(MarkViewedResult::Completed {
        marked_paths: vec!["src/main.rs".to_string()],
        total_targets: 1,
        error: None,
        set_viewed: false,
    })
    .await
    .unwrap();

    app.poll_mark_viewed_updates();

    if let DataState::Loaded { files, .. } = &app.data_state {
        assert!(!files[0].viewed, "src/main.rs should be unviewed");
        assert!(files[1].viewed, "src/lib.rs should remain viewed");
    } else {
        panic!("Expected DataState::Loaded");
    }

    let (success, msg) = app.cmt.submission_result.unwrap();
    assert!(success);
    assert!(msg.contains("unviewed"));
}

#[tokio::test]
async fn test_poll_mark_viewed_skips_apply_on_pr_mismatch() {
    let mut app = App::new_for_test();
    app.pr_number = Some(2);
    app.data_state = DataState::Loaded {
        pr: Box::new(PullRequest {
            number: 2,
            node_id: Some("PR_node2".to_string()),
            title: "Test PR".to_string(),
            body: None,
            state: "open".to_string(),
            head: crate::github::Branch {
                ref_name: "feature".to_string(),
                sha: "abc".to_string(),
            },
            base: crate::github::Branch {
                ref_name: "main".to_string(),
                sha: "def".to_string(),
            },
            user: crate::github::User {
                login: "user".to_string(),
            },
            updated_at: "2024-01-01T00:00:00Z".to_string(),
        }),
        files: vec![ChangedFile {
            filename: "src/main.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };

    let (tx, rx) = mpsc::channel(1);
    // origin_pr=1 but current pr_number=2
    app.mark_viewed_receiver = Some((1, rx));

    tx.send(MarkViewedResult::Completed {
        marked_paths: vec!["src/main.rs".to_string()],
        total_targets: 1,
        error: None,
        set_viewed: true,
    })
    .await
    .unwrap();

    app.poll_mark_viewed_updates();

    // File should NOT be updated because origin_pr != current pr_number
    if let DataState::Loaded { files, .. } = &app.data_state {
        assert!(
            !files[0].viewed,
            "File should remain unviewed due to PR mismatch"
        );
    } else {
        panic!("Expected DataState::Loaded");
    }
}

#[tokio::test]
async fn test_handle_data_result_auto_focus_skips_state_transition_during_bg_rally() {
    let mut app = App::new_for_test();
    app.local_mode = true;
    app.local_auto_focus = true;
    app.state = AppState::FileList;

    // Set up BG rally state (active but not in AiRally AppState)
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 0,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });

    let pr = Box::new(make_local_pr());
    let files = vec![ChangedFile {
        filename: "new.rs".to_string(),
        status: "added".to_string(),
        additions: 1,
        deletions: 0,
        patch: Some("@@ -0,0 +1,1 @@\n+new content".to_string()),
        viewed: false,
    }];

    app.handle_data_result(0, DataLoadResult::Success { pr, files });

    // State should NOT transition to SplitViewDiff during BG rally
    assert_eq!(app.state, AppState::FileList);
    // But file selection IS updated
    assert_eq!(app.selected_file, 0);
}

fn make_local_pr() -> PullRequest {
    PullRequest {
        number: 0,
        node_id: None,
        title: "Local diff".to_string(),
        body: None,
        state: "local".to_string(),
        base: crate::github::Branch {
            ref_name: "local".to_string(),
            sha: "".to_string(),
        },
        head: crate::github::Branch {
            ref_name: "HEAD".to_string(),
            sha: "".to_string(),
        },
        user: crate::github::User {
            login: "local".to_string(),
        },
        updated_at: "".to_string(),
    }
}

#[test]
fn test_toggle_markdown_rich() {
    let mut app = App::new_for_test();
    // Set up loaded state with a markdown file
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "README.md".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+test".to_string()),
            viewed: false,
        }],
    };

    assert!(!app.is_markdown_rich());

    app.toggle_markdown_rich();
    assert!(app.is_markdown_rich());
    assert!(
        app.diff_store.current.is_none(),
        "Cache should be cleared for md file"
    );

    app.toggle_markdown_rich();
    assert!(!app.is_markdown_rich());
}

#[test]
fn test_toggle_markdown_rich_clears_receivers() {
    let mut app = App::new_for_test();
    // Set up loaded state with a markdown file
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "README.md".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+test".to_string()),
            viewed: false,
        }],
    };

    // Simulate having active receivers
    let (_tx, rx) = tokio::sync::mpsc::channel::<(usize, DiffCache)>(1);
    app.diff_store.set_highlight_rx(rx);

    let (_tx2, rx2) = tokio::sync::mpsc::channel::<(usize, DiffCache)>(1);
    app.diff_store.set_prefetch_rx(rx2);

    app.toggle_markdown_rich();
    assert!(
        !app.diff_store.has_highlight_rx(),
        "diff_cache_receiver should be cleared for md file"
    );
    assert!(
        !app.diff_store.has_prefetch_rx(),
        "prefetch_receiver should be cleared on toggle"
    );
}

#[test]
fn test_toggle_markdown_rich_clears_only_md_cache() {
    let mut app = App::new_for_test();
    // Set up loaded state with both md and non-md files
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "README.md".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+test".to_string()),
                viewed: false,
            },
            ChangedFile {
                filename: "main.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
                viewed: false,
            },
        ],
    };

    // Add cache entries for both files
    // md_cache: markdown_rich=false (built with plain)
    let md_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+test", 4);
    // rs_cache: markdown_rich=true (simulates a cache built with the new markdown_rich flag)
    let mut rs_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
    rs_cache.file_index = 1;
    rs_cache.markdown_rich = true; // matches the post-toggle state
    app.diff_store.store.insert(0, md_cache);
    app.diff_store.store.insert(1, rs_cache);
    assert_eq!(app.diff_store.store.len(), 2);

    // toggle_markdown_rich flips from false to true
    // invalidate_if removes caches where markdown_rich != true
    app.toggle_markdown_rich();

    // md_cache (markdown_rich=false) should be removed (doesn't match new flag=true)
    assert!(
        !app.diff_store.store.contains_key(&0),
        "md cache should be cleared"
    );
    // rs_cache (markdown_rich=true) should be preserved (matches new flag=true)
    assert!(
        app.diff_store.store.contains_key(&1),
        "rs cache should be preserved"
    );
    assert_eq!(app.diff_store.store.len(), 1);
}

#[test]
fn test_toggle_markdown_rich_preserves_non_md_diff_cache() {
    let mut app = App::new_for_test();
    // Current file is non-markdown
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "main.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
            viewed: false,
        }],
    };

    let rs_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
    app.diff_store.current = Some(rs_cache);

    app.toggle_markdown_rich();

    assert!(
        app.diff_store.current.is_some(),
        "non-md diff_cache should be preserved"
    );
}

// --- Multiline selection tests ---

fn make_app_with_patch(patch: &str) -> App {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded {
        pr,
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 1,
            patch: Some(patch.to_string()),
            viewed: false,
        }],
    };
    app.selected_file = 0;
    app
}

#[test]
fn test_enter_multiline_selection_sets_anchor() {
    let mut app = make_app_with_patch("@@ -1,3 +1,4 @@\n context\n+added\n more context");
    app.diff_scroll.selected_line = 1; // context line
    app.enter_multiline_selection();
    assert!(app.multiline_selection.is_some());
    let sel = app.multiline_selection.as_ref().unwrap();
    assert_eq!(sel.anchor_line, 1);
    assert_eq!(sel.cursor_line, 1);
}

#[test]
fn test_enter_multiline_selection_rejected_on_header() {
    let mut app = make_app_with_patch("@@ -1,3 +1,4 @@\n context\n+added");
    app.diff_scroll.selected_line = 0; // hunk header line
    app.enter_multiline_selection();
    assert!(app.multiline_selection.is_none());
}

#[test]
fn test_multiline_comment_preserves_selection_on_invalid_range() {
    let patch = "@@ -1,2 +1,2 @@\n line1\n+new line2\n@@ -10,2 +10,2 @@\n line10\n+new line11";
    let mut app = make_app_with_patch(patch);
    // Selection crosses hunk boundary (lines 1..=4)
    app.multiline_selection = Some(MultilineSelection {
        anchor_line: 1,
        cursor_line: 4,
    });
    app.enter_multiline_comment_input();
    // Selection should be preserved because validation failed
    assert!(
        app.multiline_selection.is_some(),
        "selection should not be cleared on validation failure"
    );
    assert!(
        app.input_mode.is_none(),
        "should not enter input mode on validation failure"
    );
}

#[test]
fn test_multiline_comment_clears_selection_on_valid_range() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = make_app_with_patch(patch);
    // Valid range: lines 1..=2 (context + added)
    app.multiline_selection = Some(MultilineSelection {
        anchor_line: 1,
        cursor_line: 2,
    });
    app.enter_multiline_comment_input();
    assert!(
        app.multiline_selection.is_none(),
        "selection should be cleared after successful validation"
    );
    assert!(app.input_mode.is_some(), "should enter input mode");
    assert_eq!(app.state, AppState::TextInput);
}

#[test]
fn test_multiline_suggestion_preserves_selection_on_invalid_range() {
    let patch = "@@ -1,3 +1,3 @@\n context\n-removed\n+added";
    let mut app = make_app_with_patch(patch);
    // Selection includes a removed line (index 2)
    app.multiline_selection = Some(MultilineSelection {
        anchor_line: 1,
        cursor_line: 3,
    });
    app.enter_multiline_suggestion_input();
    assert!(
        app.multiline_selection.is_some(),
        "selection should not be cleared on validation failure"
    );
    assert!(app.input_mode.is_none());
}

#[test]
fn test_multiline_suggestion_clears_selection_on_valid_range() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = make_app_with_patch(patch);
    app.multiline_selection = Some(MultilineSelection {
        anchor_line: 1,
        cursor_line: 2,
    });
    app.enter_multiline_suggestion_input();
    assert!(
        app.multiline_selection.is_none(),
        "selection should be cleared after successful validation"
    );
    assert!(app.input_mode.is_some());
    if let Some(InputMode::Suggestion {
        context,
        original_code,
    }) = &app.input_mode
    {
        assert!(context.start_line_number.is_some());
        assert!(!original_code.is_empty());
    } else {
        panic!("expected InputMode::Suggestion");
    }
}

#[test]
fn test_multiline_cancel_clears_selection() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = make_app_with_patch(patch);
    app.multiline_selection = Some(MultilineSelection {
        anchor_line: 1,
        cursor_line: 2,
    });
    // Simulate Esc to cancel
    app.multiline_selection = None;
    assert!(app.multiline_selection.is_none());
    assert!(app.input_mode.is_none());
}

// --- Help scroll tests ---

fn make_key(code: KeyCode) -> event::KeyEvent {
    event::KeyEvent {
        code,
        modifiers: KeyModifiers::NONE,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    }
}

fn make_ctrl_key(c: char) -> event::KeyEvent {
    event::KeyEvent {
        code: KeyCode::Char(c),
        modifiers: KeyModifiers::CONTROL,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    }
}

#[test]
fn test_help_scroll_j_increments_by_one() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 0;
    app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
    assert_eq!(app.help_scroll_offset, 1);
    app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
    assert_eq!(app.help_scroll_offset, 2);
}

#[test]
fn test_help_scroll_k_decrements_by_one_saturating() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 3;
    app.apply_help_scroll(make_key(KeyCode::Char('k')), 30);
    assert_eq!(app.help_scroll_offset, 2);
    // Saturate at 0
    app.help_scroll_offset = 0;
    app.apply_help_scroll(make_key(KeyCode::Char('k')), 30);
    assert_eq!(app.help_scroll_offset, 0);
}

#[test]
fn test_help_scroll_page_down_j_uppercase() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 0;
    // terminal height 30 → visible_lines = 30 - 6 = 24
    app.apply_help_scroll(make_key(KeyCode::Char('J')), 30);
    assert_eq!(app.help_scroll_offset, 24);
}

#[test]
fn test_help_scroll_page_up_k_uppercase() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 50;
    // terminal height 30 → visible_lines = 24
    app.apply_help_scroll(make_key(KeyCode::Char('K')), 30);
    assert_eq!(app.help_scroll_offset, 26);
}

#[test]
fn test_help_scroll_ctrl_d_half_page() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 0;
    // terminal height 30 → visible_lines = 24, half_page = 12
    app.apply_help_scroll(make_ctrl_key('d'), 30);
    assert_eq!(app.help_scroll_offset, 12);
}

#[test]
fn test_help_scroll_ctrl_u_half_page() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 20;
    // terminal height 30 → visible_lines = 24, half_page = 12
    app.apply_help_scroll(make_ctrl_key('u'), 30);
    assert_eq!(app.help_scroll_offset, 8);
}

#[test]
fn test_help_scroll_ctrl_d_at_least_1_on_small_terminal() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 0;
    // terminal height 6 → visible_lines = 1, half_page = max(0, 1) = 1
    app.apply_help_scroll(make_ctrl_key('d'), 6);
    assert_eq!(app.help_scroll_offset, 1);
}

#[test]
fn test_help_scroll_ctrl_d_at_least_1_on_very_small_terminal() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 0;
    // terminal height 5 → visible_lines = 0, half_page = max(0, 1) = 1
    app.apply_help_scroll(make_ctrl_key('d'), 5);
    assert_eq!(app.help_scroll_offset, 1);
}

#[test]
fn test_help_scroll_gg_jumps_to_top() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 50;
    app.apply_help_scroll(make_key(KeyCode::Char('g')), 30);
    app.apply_help_scroll(make_key(KeyCode::Char('g')), 30);
    assert_eq!(app.help_scroll_offset, 0);
}

#[test]
fn test_help_scroll_g_uppercase_jumps_to_bottom() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 0;
    app.apply_help_scroll(make_key(KeyCode::Char('G')), 30);
    assert_eq!(app.help_scroll_offset, usize::MAX);
}

#[test]
fn test_help_scroll_q_returns_to_previous_state() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.previous_state = AppState::FileList;
    app.state = AppState::Help;
    app.apply_help_scroll(make_key(KeyCode::Char('q')), 30);
    assert_eq!(app.state, AppState::FileList);
}

#[test]
fn test_open_help_from_diff_view_sets_previous_state() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.open_help(AppState::DiffView);
    assert_eq!(app.state, AppState::Help);
    assert_eq!(app.previous_state, AppState::DiffView);
}

#[test]
fn test_open_help_from_split_view_diff_sets_previous_state() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.open_help(AppState::SplitViewDiff);
    assert_eq!(app.state, AppState::Help);
    assert_eq!(app.previous_state, AppState::SplitViewDiff);
}

#[test]
fn test_help_viewport_overhead_matches_render_layout() {
    // The render layout uses:
    //   Constraint::Length(3) for tab header + Constraint::Min(0) for content
    //   + Constraint::Length(1) for footer
    //   Content has Borders::ALL (2 rows overhead)
    // Total overhead = 3 + 2 + 1 = 6
    assert_eq!(App::HELP_VIEWPORT_OVERHEAD, 6);
}

fn make_shift_key(c: char) -> event::KeyEvent {
    event::KeyEvent {
        code: KeyCode::Char(c),
        modifiers: KeyModifiers::SHIFT,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    }
}

#[test]
fn test_help_scroll_shift_j_page_down() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 0;
    // Shift+j should behave the same as J (page down)
    // terminal height 30 → visible_lines = 24
    app.apply_help_scroll(make_shift_key('j'), 30);
    assert_eq!(app.help_scroll_offset, 24);
}

#[test]
fn test_help_scroll_shift_k_page_up() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 50;
    // Shift+k should behave the same as K (page up)
    // terminal height 30 → visible_lines = 24
    app.apply_help_scroll(make_shift_key('k'), 30);
    assert_eq!(app.help_scroll_offset, 26);
}

#[test]
fn test_help_scroll_shift_g_jumps_to_bottom() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 0;
    // Shift+g should behave the same as G (jump to bottom)
    app.apply_help_scroll(make_shift_key('g'), 30);
    assert_eq!(app.help_scroll_offset, usize::MAX);
}

#[test]
fn test_help_scroll_gg_without_modifiers_jumps_to_top() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.help_scroll_offset = 50;
    app.apply_help_scroll(make_key(KeyCode::Char('g')), 30);
    app.apply_help_scroll(make_key(KeyCode::Char('g')), 30);
    assert_eq!(app.help_scroll_offset, 0);
}

#[tokio::test]
async fn test_help_from_pr_list_not_blocked_by_loading_guard() {
    // Regression: PR一覧(DataState::Loading)から?でヘルプを開いた後、
    // handle_inputのLoadingガードでキー入力がブロックされ戻れなくなるバグ
    let config = Config::default();
    let mut app = App::new_pr_list("owner/repo", config);
    // PR一覧のロードが完了した状態をシミュレート
    // (LoadState::Loaded でないとキー入力を受け付けない)
    app.prs.pr_list = LoadState::Loaded(vec![]);
    // data_stateはPR未選択のためLoadingのまま
    assert!(matches!(app.data_state, DataState::Loading));

    // ?でヘルプを開く
    app.handle_pr_list_input(make_key(KeyCode::Char('?')))
        .await
        .unwrap();
    assert_eq!(app.state, AppState::Help);
    assert_eq!(app.previous_state, AppState::PullRequestList);

    // Help状態ではLoadingガードがスキップされるので、qで戻れる
    app.apply_help_scroll(make_key(KeyCode::Char('q')), 30);
    assert_eq!(app.state, AppState::PullRequestList);
}

#[tokio::test]
async fn test_patch_signature_detects_same_numstat_different_patch() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.set_local_mode(true);
    app.set_local_auto_focus(true);
    app.selected_file = 0;

    let make_file = |name: &str, patch: &str| ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some(patch.to_string()),
        viewed: false,
    };

    // 初回: files をセットして patch シグネチャを記録
    let initial_files = vec![
        make_file("file_a.rs", "@@ -1,1 +1,1 @@\n-old\n+new"),
        make_file("file_b.rs", "@@ -1,1 +1,1 @@\n-foo\n+bar"),
    ];
    app.data_state = DataState::Loaded {
        pr: Box::new(PullRequest {
            number: 1,
            node_id: None,
            title: "Test PR".to_string(),
            body: None,
            state: "open".to_string(),
            head: crate::github::Branch {
                ref_name: "feature".to_string(),
                sha: "abc123".to_string(),
            },
            base: crate::github::Branch {
                ref_name: "main".to_string(),
                sha: "def456".to_string(),
            },
            user: crate::github::User {
                login: "user".to_string(),
            },
            updated_at: "2024-01-01T00:00:00Z".to_string(),
        }),
        files: initial_files,
    };

    // 初回バッチ完了: patch シグネチャを保存（オートフォーカスはスキップ）
    app.update_patch_signatures_and_auto_focus();
    assert_eq!(app.local_file_patch_signatures.len(), 2);
    assert_eq!(app.selected_file, 0, "first batch should not auto-focus");

    // ファイル内容が変わったが numstat は同じ（same additions=1, deletions=1）
    let updated_files = vec![
        make_file("file_a.rs", "@@ -1,1 +1,1 @@\n-old\n+new"), // unchanged
        make_file("file_b.rs", "@@ -1,1 +1,1 @@\n-foo\n+baz"), // content changed!
    ];
    app.data_state = DataState::Loaded {
        pr: Box::new(PullRequest {
            number: 1,
            node_id: None,
            title: "Test PR".to_string(),
            body: None,
            state: "open".to_string(),
            head: crate::github::Branch {
                ref_name: "feature".to_string(),
                sha: "abc123".to_string(),
            },
            base: crate::github::Branch {
                ref_name: "main".to_string(),
                sha: "def456".to_string(),
            },
            user: crate::github::User {
                login: "user".to_string(),
            },
            updated_at: "2024-01-01T00:00:00Z".to_string(),
        }),
        files: updated_files,
    };

    // 2回目バッチ完了: file_b.rs の patch が変わった → オートフォーカス
    app.update_patch_signatures_and_auto_focus();
    assert_eq!(
        app.selected_file, 1,
        "should auto-focus to file_b.rs whose patch content changed (same numstat)"
    );
}

// --- KeyEventKind::Press filter tests ---

/// Verify that only KeyEventKind::Press events should be processed.
/// handle_input gates on key.kind == KeyEventKind::Press; Release and Repeat
/// events must be ignored to prevent double-execution when Kitty keyboard
/// protocol is enabled.
#[test]
fn test_key_event_kind_press_only() {
    let press = event::KeyEvent {
        code: KeyCode::Char('j'),
        modifiers: KeyModifiers::NONE,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    };
    let release = event::KeyEvent {
        code: KeyCode::Char('j'),
        modifiers: KeyModifiers::NONE,
        kind: KeyEventKind::Release,
        state: KeyEventState::NONE,
    };
    let repeat = event::KeyEvent {
        code: KeyCode::Char('j'),
        modifiers: KeyModifiers::NONE,
        kind: KeyEventKind::Repeat,
        state: KeyEventState::NONE,
    };

    // Only Press should be accepted by the filter in handle_input
    assert_eq!(press.kind, KeyEventKind::Press);
    assert_ne!(release.kind, KeyEventKind::Press);
    assert_ne!(repeat.kind, KeyEventKind::Press);
}

#[test]
fn test_pending_approve_choice_q_cancels_and_clears_prompt() {
    let mut app = App::new_for_test();
    app.cmt.pending_approve_body = Some(String::new());
    app.cmt.submission_result = Some((true, "placeholder".to_string()));
    app.cmt.submission_result_time = Some(Instant::now());

    let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Char('q')));

    assert_eq!(choice, PendingApproveChoice::Cancel);
    assert!(app.cmt.pending_approve_body.is_none());
    assert!(app.cmt.submission_result.is_none());
    assert!(app.cmt.submission_result_time.is_none());
}

#[test]
fn test_pending_approve_choice_esc_cancels() {
    let mut app = App::new_for_test();
    app.cmt.pending_approve_body = Some("some body".to_string());

    // quit キー（デフォルト: q）でキャンセル
    let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Char('q')));

    assert_eq!(choice, PendingApproveChoice::Cancel);
    assert!(app.cmt.pending_approve_body.is_none());
}

#[test]
fn test_pending_approve_choice_a_submits_empty_body() {
    let mut app = App::new_for_test();
    app.cmt.pending_approve_body = Some(String::new());

    let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Char('a')));

    assert_eq!(choice, PendingApproveChoice::Submit);
    // pending_approve_body is NOT taken by handle_pending_approve_choice;
    // it is taken by the caller (handle_input) before calling submit_review_with_body.
    assert!(app.cmt.pending_approve_body.is_some());
}

#[test]
fn test_pending_approve_choice_a_submits_with_body() {
    let mut app = App::new_for_test();
    app.cmt.pending_approve_body = Some("LGTM!".to_string());

    let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Char('a')));

    assert_eq!(choice, PendingApproveChoice::Submit);
    assert!(app.cmt.pending_approve_body.is_some());
    assert_eq!(app.cmt.pending_approve_body.as_deref(), Some("LGTM!"));
}

// ===================================================================
// 1. diff_cache.rs tests
// ===================================================================

#[test]
fn test_calc_diff_line_count_basic() {
    let files = vec![ChangedFile {
        filename: "test.rs".to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    }];
    assert_eq!(App::calc_diff_line_count(&files, 0), 3);
}

#[test]
fn test_calc_diff_line_count_no_patch() {
    let files = vec![ChangedFile {
        filename: "test.rs".to_string(),
        status: "modified".to_string(),
        additions: 0,
        deletions: 0,
        patch: None,
        viewed: false,
    }];
    assert_eq!(App::calc_diff_line_count(&files, 0), 0);
}

#[test]
fn test_calc_diff_line_count_out_of_bounds() {
    let files = vec![ChangedFile {
        filename: "test.rs".to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 1,
        patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
        viewed: false,
    }];
    assert_eq!(App::calc_diff_line_count(&files, 5), 0);
}

#[test]
fn test_files_returns_empty_when_loading() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loading;
    assert!(app.files().is_empty());
}

#[test]
fn test_files_returns_files_when_loaded() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "a.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };
    assert_eq!(app.files().len(), 1);
    assert_eq!(app.files()[0].filename, "a.rs");
}

#[test]
fn test_pr_returns_none_when_loading() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loading;
    assert!(app.pr().is_none());
}

#[test]
fn test_pr_returns_some_when_loaded() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![],
    };
    assert!(app.pr().is_some());
    assert_eq!(app.pr().unwrap().number, 0);
}

// ===================================================================
// 2. local_mode.rs tests
// ===================================================================

#[tokio::test]
async fn test_restore_data_from_cache_hit() {
    let mut app = App::new_for_test();
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    app.retry_sender = Some(retry_tx);
    app.pr_number = Some(42);

    // Put data in cache
    let cache_key = PrCacheKey {
        repo: "test/repo".to_string(),
        pr_number: 42,
    };
    app.session_cache.put_pr_data(
        cache_key,
        PrData {
            pr: Box::new(make_local_pr()),
            files: vec![ChangedFile {
                filename: "cached.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+line".to_string()),
                viewed: false,
            }],
            pr_updated_at: "2024-01-01T00:00:00Z".to_string(),
        },
    );

    app.restore_data_from_cache();
    assert!(matches!(app.data_state, DataState::Loaded { .. }));
}

#[test]
fn test_restore_data_from_cache_miss() {
    let mut app = App::new_for_test();
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    app.retry_sender = Some(retry_tx);
    app.pr_number = Some(999);

    app.restore_data_from_cache();
    assert!(matches!(app.data_state, DataState::Loading));
}

#[test]
fn test_update_data_receiver_origin() {
    let mut app = App::new_for_test();
    let (_tx, rx) = mpsc::channel::<DataLoadResult>(2);
    app.data_receiver = Some((1, rx));

    app.update_data_receiver_origin(42);

    if let Some((origin, _)) = &app.data_receiver {
        assert_eq!(*origin, 42);
    } else {
        panic!("data_receiver should exist");
    }
}

// ===================================================================
// 3. types.rs tests
// ===================================================================

#[test]
fn test_multiline_selection_start_end() {
    // anchor < cursor
    let sel = MultilineSelection {
        anchor_line: 3,
        cursor_line: 7,
    };
    assert_eq!(sel.start(), 3);
    assert_eq!(sel.end(), 7);

    // cursor < anchor
    let sel2 = MultilineSelection {
        anchor_line: 10,
        cursor_line: 2,
    };
    assert_eq!(sel2.start(), 2);
    assert_eq!(sel2.end(), 10);
}

#[test]
fn test_ai_rally_state_push_log_auto_follow() {
    use crate::ai::RallyState;
    let mut state = AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    };

    // No selection = tail, should auto-follow
    state.push_log(LogEntry::new(LogEventType::Info, "first".to_string()));
    assert_eq!(state.selected_log_index, Some(0));

    state.push_log(LogEntry::new(LogEventType::Info, "second".to_string()));
    assert_eq!(state.selected_log_index, Some(1));
}

#[test]
fn test_ai_rally_state_push_log_no_auto_follow() {
    use crate::ai::RallyState;
    let mut state = AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![
            LogEntry::new(LogEventType::Info, "old1".to_string()),
            LogEntry::new(LogEventType::Info, "old2".to_string()),
            LogEntry::new(LogEventType::Info, "old3".to_string()),
        ],
        log_scroll_offset: 0,
        selected_log_index: Some(0), // user scrolled up to first
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    };

    // User is NOT at tail, should not auto-follow
    state.push_log(LogEntry::new(LogEventType::Info, "new".to_string()));
    assert_eq!(state.selected_log_index, Some(0)); // stays at 0
}

#[test]
fn test_ai_rally_state_is_selection_at_tail() {
    use crate::ai::RallyState;
    let mut state = AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![
            LogEntry::new(LogEventType::Info, "a".to_string()),
            LogEntry::new(LogEventType::Info, "b".to_string()),
        ],
        log_scroll_offset: 0,
        selected_log_index: Some(1), // at tail
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    };

    // At tail: selected_log_index == logs.len() - 1
    // is_selection_at_tail is private, so test via field values
    assert_eq!(state.selected_log_index, Some(1));
    assert_eq!(state.logs.len(), 2);
    // selected_log_index == logs.len() - 1 means at tail

    // Not at tail
    state.selected_log_index = Some(0);
    assert_ne!(state.selected_log_index.unwrap(), state.logs.len() - 1);

    // None means auto-follow (at tail)
    state.selected_log_index = None;
}

#[test]
fn test_hash_string_deterministic() {
    let h1 = hash_string("hello world");
    let h2 = hash_string("hello world");
    assert_eq!(h1, h2);

    let h3 = hash_string("different");
    assert_ne!(h1, h3);
}

// ===================================================================
// 4. key_sequence.rs tests
// ===================================================================

#[test]
fn test_check_sequence_timeout_clears_expired() {
    let mut app = App::new_for_test();
    app.pending_since = Some(Instant::now() - std::time::Duration::from_secs(10));
    app.pending_keys
        .push(crate::keybinding::KeyBinding::char('g'));

    app.check_sequence_timeout();

    assert!(app.pending_keys.is_empty());
    assert!(app.pending_since.is_none());
}

#[test]
fn test_check_sequence_timeout_keeps_active() {
    let mut app = App::new_for_test();
    app.pending_since = Some(Instant::now());
    app.pending_keys
        .push(crate::keybinding::KeyBinding::char('g'));

    app.check_sequence_timeout();

    assert!(!app.pending_keys.is_empty());
    assert!(app.pending_since.is_some());
}

#[test]
fn test_push_pending_key_sets_timestamp() {
    let mut app = App::new_for_test();
    assert!(app.pending_since.is_none());

    app.push_pending_key(crate::keybinding::KeyBinding::char('g'));

    assert!(app.pending_since.is_some());
    assert_eq!(app.pending_keys.len(), 1);
}

#[test]
fn test_push_pending_key_appends() {
    let mut app = App::new_for_test();
    app.push_pending_key(crate::keybinding::KeyBinding::char('g'));
    app.push_pending_key(crate::keybinding::KeyBinding::char('d'));

    assert_eq!(app.pending_keys.len(), 2);
    assert_eq!(
        app.pending_keys[0],
        crate::keybinding::KeyBinding::char('g')
    );
    assert_eq!(
        app.pending_keys[1],
        crate::keybinding::KeyBinding::char('d')
    );
}

#[test]
fn test_clear_pending_keys_resets() {
    let mut app = App::new_for_test();
    app.push_pending_key(crate::keybinding::KeyBinding::char('g'));
    app.push_pending_key(crate::keybinding::KeyBinding::char('d'));

    app.clear_pending_keys();

    assert!(app.pending_keys.is_empty());
    assert!(app.pending_since.is_none());
}

#[test]
fn test_matches_single_key_basic() {
    let app = App::new_for_test();
    let seq = crate::keybinding::KeySequence::single(crate::keybinding::KeyBinding::char('j'));
    let key = make_key(KeyCode::Char('j'));
    assert!(app.matches_single_key(&key, &seq));
}

#[test]
fn test_matches_single_key_ignores_sequence() {
    let app = App::new_for_test();
    let seq = crate::keybinding::KeySequence::double(
        crate::keybinding::KeyBinding::char('g'),
        crate::keybinding::KeyBinding::char('d'),
    );
    let key = make_key(KeyCode::Char('g'));
    assert!(!app.matches_single_key(&key, &seq));
}

#[test]
fn test_try_match_sequence_full_partial_none() {
    use crate::keybinding::{KeyBinding, KeySequence, SequenceMatch};

    let mut app = App::new_for_test();
    let seq = KeySequence::double(KeyBinding::char('g'), KeyBinding::char('d'));

    // No pending keys
    assert_eq!(app.try_match_sequence(&seq), SequenceMatch::None);

    // Partial match
    app.pending_keys.push(KeyBinding::char('g'));
    assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Partial);

    // Full match
    app.pending_keys.push(KeyBinding::char('d'));
    assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Full);
}

#[test]
fn test_key_could_match_sequence_start() {
    use crate::keybinding::{KeyBinding, KeySequence};

    let app = App::new_for_test();
    let seq = KeySequence::double(KeyBinding::char('g'), KeyBinding::char('d'));
    let key = make_key(KeyCode::Char('g'));
    assert!(app.key_could_match_sequence(&key, &seq));
}

#[test]
fn test_key_could_match_sequence_no_match() {
    use crate::keybinding::{KeyBinding, KeySequence};

    let app = App::new_for_test();
    let seq = KeySequence::double(KeyBinding::char('g'), KeyBinding::char('d'));
    let key = make_key(KeyCode::Char('x'));
    assert!(!app.key_could_match_sequence(&key, &seq));
}

// ===================================================================
// 5. filter.rs tests
// ===================================================================

#[test]
fn test_handle_filter_input_char_updates_query() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };
    let mut filter = crate::filter::ListFilter::new();
    filter.apply(app.files(), |_, _| true);
    filter.sync_selection();
    app.file_list_filter = Some(filter);

    let key = make_key(KeyCode::Char('t'));
    assert!(app.handle_filter_input(&key, "file"));
    assert_eq!(app.file_list_filter.as_ref().unwrap().query, "t");
}

#[test]
fn test_handle_filter_input_backspace() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };
    let mut filter = crate::filter::ListFilter::new();
    filter.insert_char('a');
    filter.insert_char('b');
    filter.apply(app.files(), |_, _| true);
    filter.sync_selection();
    app.file_list_filter = Some(filter);

    let key = make_key(KeyCode::Backspace);
    assert!(app.handle_filter_input(&key, "file"));
    assert_eq!(app.file_list_filter.as_ref().unwrap().query, "a");
}

#[test]
fn test_handle_filter_input_enter_deactivates() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };
    let mut filter = crate::filter::ListFilter::new();
    filter.insert_char('t');
    filter.apply(app.files(), |_, _| true);
    filter.sync_selection();
    app.file_list_filter = Some(filter);

    let key = make_key(KeyCode::Enter);
    assert!(app.handle_filter_input(&key, "file"));
    assert!(!app.file_list_filter.as_ref().unwrap().input_active);
}

#[test]
fn test_handle_filter_input_esc_clears() {
    let mut app = App::new_for_test();
    let mut filter = crate::filter::ListFilter::new();
    filter.insert_char('x');
    app.file_list_filter = Some(filter);

    let key = make_key(KeyCode::Esc);
    assert!(app.handle_filter_input(&key, "file"));
    assert!(app.file_list_filter.is_none());
}

#[test]
fn test_handle_filter_input_returns_false_no_filter() {
    let mut app = App::new_for_test();
    app.file_list_filter = None;

    let key = make_key(KeyCode::Char('a'));
    assert!(!app.handle_filter_input(&key, "file"));
}

#[test]
fn test_reapply_filter_pr_list() {
    use crate::github::PullRequestSummary;
    let mut app = App::new_for_test();
    app.prs.pr_list = LoadState::Loaded(vec![
        PullRequestSummary {
            number: 1,
            title: "fix bug".to_string(),
            state: "open".to_string(),
            author: crate::github::User {
                login: "user".to_string(),
            },
            is_draft: false,
            labels: vec![],
            updated_at: "2024-01-01T00:00:00Z".to_string(),
            status_check_rollup: vec![],
        },
        PullRequestSummary {
            number: 2,
            title: "add feature".to_string(),
            state: "open".to_string(),
            author: crate::github::User {
                login: "user".to_string(),
            },
            is_draft: false,
            labels: vec![],
            updated_at: "2024-01-01T00:00:00Z".to_string(),
            status_check_rollup: vec![],
        },
    ]);
    let mut filter = crate::filter::ListFilter::new();
    filter.insert_char('b');
    filter.insert_char('u');
    filter.insert_char('g');
    app.prs.pr_list_filter = Some(filter);

    app.reapply_filter("pr");

    let filter = app.prs.pr_list_filter.as_ref().unwrap();
    assert_eq!(filter.matched_indices, vec![0]); // only "fix bug"
}

#[test]
fn test_reapply_filter_file_list() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "src/main.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: None,
                viewed: false,
            },
            ChangedFile {
                filename: "src/lib.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: None,
                viewed: false,
            },
        ],
    };
    let mut filter = crate::filter::ListFilter::new();
    filter.insert_char('l');
    filter.insert_char('i');
    filter.insert_char('b');
    app.file_list_filter = Some(filter);

    app.reapply_filter("file");

    let filter = app.file_list_filter.as_ref().unwrap();
    assert_eq!(filter.matched_indices, vec![1]); // only "src/lib.rs"
}

#[test]
fn test_handle_filter_navigation_down() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "a.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: None,
                viewed: false,
            },
            ChangedFile {
                filename: "b.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: None,
                viewed: false,
            },
        ],
    };
    let mut filter = crate::filter::ListFilter::new();
    filter.apply(app.files(), |_, _| true);
    filter.sync_selection();
    filter.input_active = false;
    app.file_list_filter = Some(filter);
    app.selected_file = 0;

    assert!(app.handle_filter_navigation("file", true));
    assert_eq!(app.selected_file, 1);
}

#[test]
fn test_handle_filter_navigation_up() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "a.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: None,
                viewed: false,
            },
            ChangedFile {
                filename: "b.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: None,
                viewed: false,
            },
        ],
    };
    let mut filter = crate::filter::ListFilter::new();
    filter.apply(app.files(), |_, _| true);
    filter.sync_selection();
    filter.input_active = false;
    // Navigate to second item first
    filter.navigate_down();
    app.file_list_filter = Some(filter);
    app.selected_file = 1;

    assert!(app.handle_filter_navigation("file", false));
    assert_eq!(app.selected_file, 0);
}

#[test]
fn test_handle_filter_esc_clears_pr() {
    let mut app = App::new_for_test();
    let filter = crate::filter::ListFilter::new();
    app.prs.pr_list_filter = Some(filter);

    assert!(app.handle_filter_esc("pr"));
    assert!(app.prs.pr_list_filter.is_none());
}

#[test]
fn test_handle_filter_esc_no_filter() {
    let mut app = App::new_for_test();
    app.prs.pr_list_filter = None;

    assert!(!app.handle_filter_esc("pr"));
}

#[test]
fn test_is_filter_selection_empty() {
    let mut app = App::new_for_test();

    // No filter -> false
    assert!(!app.is_filter_selection_empty("file"));

    // Filter with selection -> false
    let mut filter = crate::filter::ListFilter::new();
    filter.matched_indices = vec![0];
    filter.selected = Some(0);
    app.file_list_filter = Some(filter);
    assert!(!app.is_filter_selection_empty("file"));

    // Filter with no match -> true (selected=None)
    let mut filter2 = crate::filter::ListFilter::new();
    filter2.matched_indices = vec![];
    filter2.selected = None;
    app.file_list_filter = Some(filter2);
    assert!(app.is_filter_selection_empty("file"));
}

// ===================================================================
// 6. input.rs tests
// ===================================================================

#[test]
fn test_directory_prefix_for_nested() {
    assert_eq!(App::directory_prefix_for("a/b/c.txt"), "a/b/");
}

#[test]
fn test_directory_prefix_for_root() {
    assert_eq!(App::directory_prefix_for("root.txt"), "");
}

#[test]
fn test_directory_prefix_for_single_dir() {
    assert_eq!(App::directory_prefix_for("dir/file.rs"), "dir/");
}

#[test]
fn test_collect_unviewed_all_viewed() {
    let files = vec![
        ChangedFile {
            filename: "src/a.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: true,
        },
        ChangedFile {
            filename: "src/b.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: true,
        },
    ];
    let paths = App::collect_unviewed_directory_paths(&files, 0);
    assert!(paths.is_empty());
}

#[test]
fn test_collect_unviewed_mixed() {
    let files = vec![
        ChangedFile {
            filename: "src/a.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        },
        ChangedFile {
            filename: "src/b.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: true,
        },
        ChangedFile {
            filename: "src/c.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        },
    ];
    let paths = App::collect_unviewed_directory_paths(&files, 0);
    assert_eq!(paths, vec!["src/a.rs", "src/c.rs"]);
}

#[test]
fn test_refresh_all_resets_state() {
    let mut app = App::new_for_test();
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    app.retry_sender = Some(retry_tx);
    app.cmt.review_comments = Some(vec![]);
    app.cmt.discussion_comments = Some(vec![]);
    app.cmt.comments_loading = true;
    app.cmt.discussion_comments_loading = true;
    app.chk.ci_status = Some(crate::github::CiStatus::Failure);
    let filter = crate::filter::ListFilter::new();
    app.file_list_filter = Some(filter);

    app.refresh_all();

    assert!(matches!(app.data_state, DataState::Loading));
    assert!(app.cmt.review_comments.is_none());
    assert!(app.cmt.discussion_comments.is_none());
    assert!(!app.cmt.comments_loading);
    assert!(!app.cmt.discussion_comments_loading);
    assert!(app.chk.ci_status.is_none());
    assert!(app.chk.ci_status_receiver.is_none());
    assert!(app.file_list_filter.is_none());
}

#[test]
fn test_refresh_all_invalidates_session_cache() {
    let mut app = App::new_for_test();
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    app.retry_sender = Some(retry_tx);

    // Pre-populate cache
    let cache_key = PrCacheKey {
        repo: "test/repo".to_string(),
        pr_number: 1,
    };
    app.session_cache.put_pr_data(
        cache_key.clone(),
        PrData {
            pr: Box::new(make_local_pr()),
            files: vec![],
            pr_updated_at: "x".to_string(),
        },
    );
    assert!(app.session_cache.get_pr_data(&cache_key).is_some());

    app.refresh_all();

    assert!(app.session_cache.get_pr_data(&cache_key).is_none());
}

#[tokio::test]
async fn test_handle_mark_viewed_key_v_returns_true() {
    let mut app = App::new_for_test();
    app.pr_number = Some(1);
    app.data_state = DataState::Loaded {
        pr: Box::new(PullRequest {
            number: 1,
            node_id: Some("PR_node".to_string()),
            title: "Test".to_string(),
            body: None,
            state: "open".to_string(),
            head: crate::github::Branch {
                ref_name: "f".to_string(),
                sha: "a".to_string(),
            },
            base: crate::github::Branch {
                ref_name: "m".to_string(),
                sha: "b".to_string(),
            },
            user: crate::github::User {
                login: "u".to_string(),
            },
            updated_at: "".to_string(),
        }),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };
    let key = make_key(KeyCode::Char('v'));
    assert!(app.handle_mark_viewed_key(key));
}

#[test]
fn test_handle_mark_viewed_key_ignored_local_mode() {
    let mut app = App::new_for_test();
    app.local_mode = true;
    let key = make_key(KeyCode::Char('v'));
    assert!(!app.handle_mark_viewed_key(key));
}

#[test]
fn test_handle_mark_viewed_key_other_key() {
    let mut app = App::new_for_test();
    let key = make_key(KeyCode::Char('x'));
    assert!(!app.handle_mark_viewed_key(key));
}

// ===================================================================
// 7. input_text.rs tests
// ===================================================================

#[test]
fn test_cancel_input_clears_mode() {
    let mut app = App::new_for_test();
    app.input_mode = Some(InputMode::Comment(LineInputContext {
        file_index: 0,
        line_number: 1,
        diff_position: 1,
        start_line_number: None,
    }));
    app.state = AppState::TextInput;
    app.preview_return_state = AppState::DiffView;

    app.cancel_input();

    assert!(app.input_mode.is_none());
}

#[test]
fn test_cancel_input_clears_text_area() {
    let mut app = App::new_for_test();
    app.input_text_area.set_content("some text");
    app.state = AppState::TextInput;
    app.preview_return_state = AppState::DiffView;

    app.cancel_input();

    assert!(app.input_text_area.content().is_empty());
}

#[test]
fn test_cancel_input_restores_state() {
    let mut app = App::new_for_test();
    app.state = AppState::TextInput;
    app.preview_return_state = AppState::DiffView;

    app.cancel_input();

    assert_eq!(app.state, AppState::DiffView);
}

#[test]
fn test_cancel_input_clears_multiline_selection() {
    let mut app = App::new_for_test();
    app.multiline_selection = Some(MultilineSelection {
        anchor_line: 1,
        cursor_line: 5,
    });
    app.state = AppState::TextInput;
    app.preview_return_state = AppState::DiffView;

    app.cancel_input();

    // cancel_input does not clear multiline_selection (it's cleared earlier in flow)
    // but input_mode is cleared
    assert!(app.input_mode.is_none());
}

// ===================================================================
// 8. input_diff.rs tests
// ===================================================================

#[test]
fn test_adjust_scroll_above_viewport() {
    let mut app = App::new_for_test();
    app.diff_scroll.selected_line = 2;
    app.diff_scroll.scroll_offset = 5;
    app.diff_scroll.line_count = 100;

    // visible_lines=20, margin=10
    // selected_line(2) < scroll_offset(5) + margin(10) → scroll to keep margin
    app.adjust_scroll(20);

    // scroll_offset = selected_line - margin = 2 - 10 = 0 (saturating)
    assert_eq!(app.diff_scroll.scroll_offset, 0);
}

#[test]
fn test_adjust_scroll_below_viewport() {
    let mut app = App::new_for_test();
    app.diff_scroll.selected_line = 30;
    app.diff_scroll.scroll_offset = 5;
    app.diff_scroll.line_count = 100;

    app.adjust_scroll(20);

    assert!(app.diff_scroll.scroll_offset > 5);
    assert!(app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset);
    assert!(app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + 20);
}

#[test]
fn test_adjust_scroll_within_viewport() {
    let mut app = App::new_for_test();
    app.diff_scroll.selected_line = 15;
    app.diff_scroll.scroll_offset = 5;
    app.diff_scroll.line_count = 100;

    app.adjust_scroll(20);

    // margin=10, selected_line(15) >= scroll_offset(5)+margin(10)=15
    // and selected_line(15)+margin(10)=25 >= scroll_offset(5)+visible_lines(20)=25
    // → bottom margin triggers: scroll_offset = 15 - (20 - 10 - 1) = 6
    assert!(app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset);
    assert!(app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + 20);
}

#[test]
fn test_adjust_scroll_zero_visible() {
    let mut app = App::new_for_test();
    app.diff_scroll.selected_line = 10;
    app.diff_scroll.scroll_offset = 5;
    app.diff_scroll.line_count = 100;

    app.adjust_scroll(0);

    // Should early return, no change
    assert_eq!(app.diff_scroll.scroll_offset, 5);
}

#[test]
fn test_adjust_scroll_at_last_line() {
    let mut app = App::new_for_test();
    app.diff_scroll.line_count = 50;
    app.diff_scroll.selected_line = 49; // last line
    app.diff_scroll.scroll_offset = 0;

    // visible_lines=20, margin=10
    // bottom margin: scroll_offset = 49 - (20 - 10 - 1) = 40
    app.adjust_scroll(20);

    assert!(app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset);
    assert!(app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + 20);
    assert_eq!(app.diff_scroll.scroll_offset, 40);
}

#[test]
fn test_adjust_scroll_single_line() {
    let mut app = App::new_for_test();
    app.diff_scroll.line_count = 1;
    app.diff_scroll.selected_line = 0;
    app.diff_scroll.scroll_offset = 0;

    app.adjust_scroll(20);

    assert_eq!(app.diff_scroll.scroll_offset, 0);
    assert_eq!(app.diff_scroll.selected_line, 0);
}

/// スナップショットテスト: 全カーソル位置で scroll_offset と visible_lines の整合性を検証
///
/// 不変条件:
///   scroll_offset <= selected_line < scroll_offset + visible_lines
/// （visible_lines == 0 の場合は adjust_scroll が何もしないので除外）
#[test]
fn test_adjust_scroll_invariant_all_positions() {
    for diff_line_count in [1usize, 5, 10, 20, 50, 100] {
        for visible_lines in [1usize, 5, 10, 20, 40] {
            for selected_line in 0..diff_line_count {
                // scroll_offset が selected_line より手前にあるケース
                for initial_scroll in [
                    0,
                    selected_line.saturating_sub(visible_lines),
                    selected_line,
                ] {
                    let mut app = App::new_for_test();
                    app.diff_scroll.line_count = diff_line_count;
                    app.diff_scroll.selected_line = selected_line;
                    app.diff_scroll.scroll_offset = initial_scroll;

                    app.adjust_scroll(visible_lines);

                    assert!(
                        app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset,
                        "cursor above viewport: selected_line={}, scroll_offset={}, \
                         visible_lines={}, diff_line_count={}, initial_scroll={}",
                        app.diff_scroll.selected_line,
                        app.diff_scroll.scroll_offset,
                        visible_lines,
                        diff_line_count,
                        initial_scroll,
                    );
                    assert!(
                        app.diff_scroll.selected_line
                            < app.diff_scroll.scroll_offset + visible_lines,
                        "cursor below viewport: selected_line={}, scroll_offset={}, \
                         visible_lines={}, diff_line_count={}, initial_scroll={}",
                        app.diff_scroll.selected_line,
                        app.diff_scroll.scroll_offset,
                        visible_lines,
                        diff_line_count,
                        initial_scroll,
                    );
                }
            }
        }
    }
}

/// スナップショットテスト: j キー連打シミュレーション
///
/// 先頭から末尾まで1行ずつ移動した際に、全ステップで不変条件が維持されること。
/// 特に末尾付近でのジャンプが発生しないことを検証。
#[test]
fn test_adjust_scroll_sequential_down_no_jump() {
    let diff_line_count = 100;
    let visible_lines = 20;

    let mut app = App::new_for_test();
    app.diff_scroll.line_count = diff_line_count;
    app.diff_scroll.selected_line = 0;
    app.diff_scroll.scroll_offset = 0;

    let mut prev_scroll = 0;
    for line in 0..diff_line_count {
        app.diff_scroll.selected_line = line;
        app.adjust_scroll(visible_lines);

        // 不変条件: カーソルは常にビューポート内
        assert!(
            app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset
                && app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + visible_lines,
            "line={}: scroll_offset={}, visible_lines={}",
            line,
            app.diff_scroll.scroll_offset,
            visible_lines,
        );

        // scroll_offset は最大1ずつしか増えない（ジャンプなし）
        assert!(
            app.diff_scroll.scroll_offset <= prev_scroll + 1,
            "scroll jumped at line={}: prev={}, now={}",
            line,
            prev_scroll,
            app.diff_scroll.scroll_offset,
        );

        prev_scroll = app.diff_scroll.scroll_offset;
    }
}

/// スナップショットテスト: k キー連打シミュレーション（末尾→先頭）
#[test]
fn test_adjust_scroll_sequential_up_no_jump() {
    let diff_line_count = 100;
    let visible_lines = 20;

    let mut app = App::new_for_test();
    app.diff_scroll.line_count = diff_line_count;
    app.diff_scroll.selected_line = diff_line_count - 1;
    app.diff_scroll.scroll_offset = diff_line_count.saturating_sub(visible_lines);
    app.adjust_scroll(visible_lines);

    let mut prev_scroll = app.diff_scroll.scroll_offset;
    for line in (0..diff_line_count).rev() {
        app.diff_scroll.selected_line = line;
        app.adjust_scroll(visible_lines);

        assert!(
            app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset
                && app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + visible_lines,
            "line={}: scroll_offset={}, visible_lines={}",
            line,
            app.diff_scroll.scroll_offset,
            visible_lines,
        );

        // scroll_offset は最大1ずつしか減らない（ジャンプなし）
        assert!(
            prev_scroll <= app.diff_scroll.scroll_offset + 1,
            "scroll jumped at line={}: prev={}, now={}",
            line,
            prev_scroll,
            app.diff_scroll.scroll_offset,
        );

        prev_scroll = app.diff_scroll.scroll_offset;
    }
}

/// ファイル行数がビューポートより小さい場合、scroll_offset は常に 0
#[test]
fn test_adjust_scroll_file_shorter_than_viewport() {
    let visible_lines = 40;
    for diff_line_count in [1, 5, 10, 39] {
        for line in 0..diff_line_count {
            let mut app = App::new_for_test();
            app.diff_scroll.line_count = diff_line_count;
            app.diff_scroll.selected_line = line;
            app.diff_scroll.scroll_offset = 0;

            app.adjust_scroll(visible_lines);

            assert_eq!(
                app.diff_scroll.scroll_offset, 0,
                "short file: diff_line_count={}, selected_line={}",
                diff_line_count, line,
            );
        }
    }
}

// ===================================================================
// 9. ai_rally.rs tests
// ===================================================================

#[test]
fn test_cleanup_rally_state() {
    let mut app = App::new_for_test();
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });
    let (cmd_tx, _cmd_rx) = mpsc::channel(10);
    app.rally_command_sender = Some(cmd_tx);
    let (_, event_rx) = mpsc::channel(100);
    app.rally_event_receiver = Some(event_rx);
    app.pending_rally_seed_review = Some(crate::ai::ReviewerOutput {
        action: crate::ai::ReviewAction::RequestChanges,
        summary: "seeded".to_string(),
        comments: vec![],
        blocking_issues: vec![],
    });

    app.cleanup_rally_state();

    assert!(app.ai_rally_state.is_none());
    assert!(app.rally_command_sender.is_none());
    assert!(app.rally_event_receiver.is_none());
    assert!(app.pending_rally_seed_review.is_none());
}

#[test]
#[serial]
fn test_build_seed_review_from_local_comments_returns_none_without_comments() {
    let tempdir = tempdir().unwrap();
    let workdir = tempdir.path().join("worktree");
    std::fs::create_dir_all(&workdir).unwrap();

    let mut app = App::new_for_test();
    app.local_mode = true;
    app.repo = "owner/repo".to_string();
    app.working_dir = Some(workdir.to_string_lossy().to_string());

    let _cache_home = ScopedCacheHome::new(tempdir.path());

    let seed = app.build_seed_review_from_local_comments().unwrap();

    assert!(seed.is_none());
}

#[test]
#[serial]
fn test_build_seed_review_from_local_comments_uses_persisted_comments() {
    let tempdir = tempdir().unwrap();
    let workdir = tempdir.path().join("worktree");
    std::fs::create_dir_all(&workdir).unwrap();

    let _cache_home = ScopedCacheHome::new(tempdir.path());

    crate::cache::save_local_review_comments(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
        &[crate::cache::LocalReviewComment::new(
            crate::github::comment::ReviewComment {
                id: 1,
                path: "src/main.rs".to_string(),
                line: Some(12),
                start_line: None,
                body: "Please simplify this branch.".to_string(),
                user: crate::github::User {
                    login: "local".to_string(),
                },
                created_at: "2026-03-24T00:00:00Z".to_string(),
                in_reply_to_id: None,
            },
        )],
    )
    .unwrap();

    let mut app = App::new_for_test();
    app.local_mode = true;
    app.repo = "owner/repo".to_string();
    app.working_dir = Some(workdir.to_string_lossy().to_string());

    let seed = app
        .build_seed_review_from_local_comments()
        .unwrap()
        .unwrap();

    assert_eq!(seed.action, crate::ai::ReviewAction::RequestChanges);
    assert_eq!(seed.comments.len(), 1);
    assert_eq!(seed.comments[0].path, "src/main.rs");
    assert_eq!(seed.comments[0].line, 12);
    assert_eq!(seed.comments[0].body, "Please simplify this branch.");
    assert_eq!(
        seed.comments[0].severity,
        crate::ai::adapter::CommentSeverity::Major
    );
    assert!(seed.summary.contains("1 local comment"));
}

#[test]
#[serial]
fn test_build_seed_review_from_local_comments_skips_resolved_comments() {
    let tempdir = tempdir().unwrap();
    let workdir = tempdir.path().join("worktree");
    std::fs::create_dir_all(&workdir).unwrap();

    let _cache_home = ScopedCacheHome::new(tempdir.path());

    crate::cache::save_local_review_comments(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
        &[crate::cache::LocalReviewComment::with_meta(
            crate::github::comment::ReviewComment {
                id: 1,
                path: "src/main.rs".to_string(),
                line: Some(12),
                start_line: None,
                body: "Already handled.".to_string(),
                user: crate::github::User {
                    login: "local".to_string(),
                },
                created_at: "2026-03-24T00:00:00Z".to_string(),
                in_reply_to_id: None,
            },
            crate::cache::LocalCommentMeta {
                is_resolved: true,
                resolved_at: Some("2026-03-24T01:00:00Z".to_string()),
            },
        )],
    )
    .unwrap();

    let mut app = App::new_for_test();
    app.local_mode = true;
    app.repo = "owner/repo".to_string();
    app.working_dir = Some(workdir.to_string_lossy().to_string());

    let seed = app.build_seed_review_from_local_comments().unwrap();

    assert!(seed.is_none());
}

#[test]
#[serial]
fn test_start_ai_rally_stashes_seed_review_while_waiting_for_confirmation() {
    let tempdir = tempdir().unwrap();
    let workdir = tempdir.path().join("worktree");
    std::fs::create_dir_all(&workdir).unwrap();

    let _cache_home = ScopedCacheHome::new(tempdir.path());

    crate::cache::save_local_review_comments(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
        &[crate::cache::LocalReviewComment::new(
            crate::github::comment::ReviewComment {
                id: 1,
                path: "src/main.rs".to_string(),
                line: Some(7),
                start_line: None,
                body: "Handle the error explicitly.".to_string(),
                user: crate::github::User {
                    login: "local".to_string(),
                },
                created_at: "2026-03-24T00:00:00Z".to_string(),
                in_reply_to_id: None,
            },
        )],
    )
    .unwrap();

    let mut app = App::new_for_test();
    app.local_mode = true;
    app.repo = "owner/repo".to_string();
    app.pr_number = Some(0);
    app.working_dir = Some(workdir.to_string_lossy().to_string());
    app.config.local_overrides.insert("ai.reviewer".to_string());
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "src/main.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+fn main() {}".to_string()),
            viewed: false,
        }],
    };

    app.start_ai_rally();

    assert_eq!(app.state, AppState::AiRally);
    assert!(app.pending_rally_context.is_some());
    assert!(app.pending_rally_prompt_loader.is_some());
    assert!(app.pending_rally_seed_review.is_some());
    assert_eq!(
        app.pending_rally_seed_review
            .as_ref()
            .unwrap()
            .comments
            .len(),
        1
    );
}

#[test]
fn test_is_rally_running_in_background_not_in_rally() {
    let mut app = App::new_for_test();
    app.state = AppState::FileList;
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });
    assert!(app.is_rally_running_in_background());
}

#[test]
fn test_is_rally_running_in_background_in_rally() {
    let mut app = App::new_for_test();
    app.state = AppState::AiRally;
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });
    assert!(!app.is_rally_running_in_background());
}

#[test]
fn test_is_rally_running_in_background_no_state() {
    let mut app = App::new_for_test();
    app.state = AppState::FileList;
    app.ai_rally_state = None;
    assert!(!app.is_rally_running_in_background());
}

#[test]
fn test_is_rally_running_in_background_finished() {
    let mut app = App::new_for_test();
    app.state = AppState::FileList;
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::Completed,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });
    assert!(!app.is_rally_running_in_background());
}

#[test]
fn test_has_background_rally_true() {
    let mut app = App::new_for_test();
    app.state = AppState::FileList;
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });
    assert!(app.has_background_rally());
}

#[test]
fn test_has_background_rally_false_in_rally() {
    let mut app = App::new_for_test();
    app.state = AppState::AiRally;
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });
    assert!(!app.has_background_rally());
}

#[test]
fn test_has_background_rally_false_no_state() {
    let mut app = App::new_for_test();
    app.state = AppState::FileList;
    app.ai_rally_state = None;
    assert!(!app.has_background_rally());
}

#[test]
fn test_is_background_rally_finished_completed() {
    let mut app = App::new_for_test();
    app.state = AppState::FileList;
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::Completed,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });
    assert!(app.is_background_rally_finished());
}

#[test]
fn test_is_background_rally_finished_running() {
    let mut app = App::new_for_test();
    app.state = AppState::FileList;
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });
    assert!(!app.is_background_rally_finished());
}

#[test]
fn test_adjust_log_scroll_selection_above() {
    let mut app = App::new_for_test();
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: (0..20)
            .map(|i| LogEntry::new(LogEventType::Info, format!("log {}", i)))
            .collect(),
        log_scroll_offset: 10,
        selected_log_index: Some(5), // above visible area
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 5,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });

    app.adjust_log_scroll_to_selection();

    let rally_state = app.ai_rally_state.as_ref().unwrap();
    assert!(rally_state.log_scroll_offset <= 5);
}

#[test]
fn test_adjust_log_scroll_selection_below() {
    let mut app = App::new_for_test();
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: (0..20)
            .map(|i| LogEntry::new(LogEventType::Info, format!("log {}", i)))
            .collect(),
        log_scroll_offset: 1,
        selected_log_index: Some(15), // below visible area
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 5,
        pending_config_warning: None,
        pause_state: PauseState::Running,
    });

    app.adjust_log_scroll_to_selection();

    let rally_state = app.ai_rally_state.as_ref().unwrap();
    assert!(rally_state.log_scroll_offset > 1);
}

// ===================================================================
// 9.5. Pause lifecycle edge-case tests
// ===================================================================

#[test]
fn test_pause_state_reset_on_approve_state_change() {
    use crate::ai::orchestrator::RallyEvent;
    use crate::ai::RallyState;

    // When reviewer approves immediately after pause is requested,
    // pause_state should be reset to Running via StateChanged(Completed).
    let mut app = App::new_for_test();
    let (event_tx, event_rx) = mpsc::channel(100);
    app.rally_event_receiver = Some(event_rx);
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::PauseRequested,
    });

    // Simulate: reviewer approved → StateChanged(Completed) arrives
    event_tx
        .try_send(RallyEvent::StateChanged(RallyState::Completed))
        .unwrap();

    app.poll_rally_events();

    let rally_state = app.ai_rally_state.as_ref().unwrap();
    assert_eq!(rally_state.state, RallyState::Completed);
    assert_eq!(
        rally_state.pause_state,
        PauseState::Running,
        "pause_state must be reset to Running on Completed"
    );
}

#[test]
fn test_pause_state_reset_on_waiting_for_clarification() {
    use crate::ai::orchestrator::RallyEvent;
    use crate::ai::RallyState;

    // When pause is requested during RevieweeFix and reviewee returns
    // NeedsClarification, pause_state should be reset via
    // StateChanged(WaitingForClarification).
    let mut app = App::new_for_test();
    let (event_tx, event_rx) = mpsc::channel(100);
    app.rally_event_receiver = Some(event_rx);
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::RevieweeFix,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::PauseRequested,
    });

    // Simulate: reviewee needs clarification
    event_tx
        .try_send(RallyEvent::StateChanged(
            RallyState::WaitingForClarification,
        ))
        .unwrap();

    app.poll_rally_events();

    let rally_state = app.ai_rally_state.as_ref().unwrap();
    assert_eq!(rally_state.state, RallyState::WaitingForClarification);
    assert_eq!(
        rally_state.pause_state,
        PauseState::Running,
        "pause_state must be reset to Running on WaitingForClarification"
    );
}

#[test]
fn test_pause_state_reset_on_waiting_for_permission() {
    use crate::ai::orchestrator::RallyEvent;
    use crate::ai::RallyState;

    // Similar to above but for WaitingForPermission
    let mut app = App::new_for_test();
    let (event_tx, event_rx) = mpsc::channel(100);
    app.rally_event_receiver = Some(event_rx);
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::RevieweeFix,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::PauseRequested,
    });

    event_tx
        .try_send(RallyEvent::StateChanged(RallyState::WaitingForPermission))
        .unwrap();

    app.poll_rally_events();

    let rally_state = app.ai_rally_state.as_ref().unwrap();
    assert_eq!(rally_state.state, RallyState::WaitingForPermission);
    assert_eq!(
        rally_state.pause_state,
        PauseState::Running,
        "pause_state must be reset to Running on WaitingForPermission"
    );
}

#[test]
fn test_pause_state_reset_on_waiting_for_post_confirmation() {
    use crate::ai::orchestrator::RallyEvent;
    use crate::ai::RallyState;

    // When pause is requested and state transitions to WaitingForPostConfirmation,
    // pause_state should be reset to Running to avoid stale "Pausing..." display.
    let mut app = App::new_for_test();
    let (event_tx, event_rx) = mpsc::channel(100);
    app.rally_event_receiver = Some(event_rx);
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::RevieweeFix,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::PauseRequested,
    });

    event_tx
        .try_send(RallyEvent::StateChanged(
            RallyState::WaitingForPostConfirmation,
        ))
        .unwrap();

    app.poll_rally_events();

    let rally_state = app.ai_rally_state.as_ref().unwrap();
    assert_eq!(rally_state.state, RallyState::WaitingForPostConfirmation);
    assert_eq!(
        rally_state.pause_state,
        PauseState::Running,
        "pause_state must be reset to Running on WaitingForPostConfirmation"
    );
}

#[test]
fn test_post_confirm_response_does_not_force_rally_state_to_reviewee_fix() {
    use crate::ai::orchestrator::ProposalPostInfo;
    use crate::ai::RallyState;

    // Regression: the post-confirmation handler used to unconditionally set
    // `rally_state.state = RallyState::RevieweeFix`. For Proposal-variant
    // confirmations the next correct phase is never RevieweeFix (review_only
    // mode never enters reviewee fix), so the assignment caused a one-tick
    // wrong-phase flash before polling.rs StateChanged overwrote it. The
    // handler must now leave `state` alone and let StateChanged drive it.
    for approved in [true, false] {
        let mut app = App::new_for_test();
        let (cmd_tx, _cmd_rx) = mpsc::channel(10);
        app.rally_command_sender = Some(cmd_tx);
        app.ai_rally_state = Some(AiRallyState {
            iteration: 2,
            max_iterations: 5,
            review_only: true,
            state: RallyState::WaitingForPostConfirmation,
            history: vec![],
            logs: vec![],
            log_scroll_offset: 0,
            selected_log_index: None,
            showing_log_detail: false,
            pending_question: None,
            pending_permission: None,
            pending_post_confirmation: crate::app::PendingPostConfirmation::Proposal(
                ProposalPostInfo {
                    summary: "test proposal".to_string(),
                    target_files: vec!["src/foo.rs".to_string()],
                    plan_item_count: 1,
                },
            ),
            last_visible_log_height: 10,
            pending_config_warning: None,
            pause_state: PauseState::Running,
        });

        app.handle_post_confirm_response(approved);

        let rally_state = app.ai_rally_state.as_ref().unwrap();
        assert_eq!(
            rally_state.state,
            RallyState::WaitingForPostConfirmation,
            "handler must not mutate rally_state.state (approved={}); polling.rs StateChanged is the single writer",
            approved
        );
        assert!(
            matches!(
                rally_state.pending_post_confirmation,
                crate::app::PendingPostConfirmation::None
            ),
            "handler must clear pending_post_confirmation (approved={})",
            approved
        );
    }
}

#[test]
fn test_pause_state_preserved_on_active_state_change() {
    use crate::ai::orchestrator::RallyEvent;
    use crate::ai::RallyState;

    // PauseRequested should NOT be reset when transitioning between
    // active states (e.g. ReviewerReviewing → RevieweeFix)
    let mut app = App::new_for_test();
    let (event_tx, event_rx) = mpsc::channel(100);
    app.rally_event_receiver = Some(event_rx);
    app.ai_rally_state = Some(AiRallyState {
        iteration: 1,
        max_iterations: 10,
        review_only: false,
        state: crate::ai::RallyState::ReviewerReviewing,
        history: vec![],
        logs: vec![],
        log_scroll_offset: 0,
        selected_log_index: None,
        showing_log_detail: false,
        pending_question: None,
        pending_permission: None,
        pending_post_confirmation: crate::app::PendingPostConfirmation::None,
        last_visible_log_height: 10,
        pending_config_warning: None,
        pause_state: PauseState::PauseRequested,
    });

    event_tx
        .try_send(RallyEvent::StateChanged(RallyState::RevieweeFix))
        .unwrap();

    app.poll_rally_events();

    let rally_state = app.ai_rally_state.as_ref().unwrap();
    assert_eq!(rally_state.state, RallyState::RevieweeFix);
    assert_eq!(
        rally_state.pause_state,
        PauseState::PauseRequested,
        "pause_state should remain PauseRequested during active state transitions"
    );
}

// ===================================================================
// 10. symbol.rs tests
// ===================================================================

#[test]
fn test_push_jump_location_basic() {
    let mut app = App::new_for_test();
    app.selected_file = 2;
    app.diff_scroll.selected_line = 10;
    app.diff_scroll.scroll_offset = 5;

    app.push_jump_location();

    assert_eq!(app.jump_stack.len(), 1);
    assert_eq!(app.jump_stack[0].file_index, 2);
    assert_eq!(app.jump_stack[0].line_index, 10);
    assert_eq!(app.jump_stack[0].scroll_offset, 5);
}

#[test]
fn test_push_jump_location_max_capacity() {
    let mut app = App::new_for_test();

    // Push 101 locations (should trim oldest)
    for i in 0..101 {
        app.selected_file = i;
        app.diff_scroll.selected_line = i;
        app.diff_scroll.scroll_offset = 0;
        app.push_jump_location();
    }

    assert_eq!(app.jump_stack.len(), 100);
    // First entry should be index 1 (0 was removed)
    assert_eq!(app.jump_stack[0].file_index, 1);
}

#[test]
fn test_push_jump_location_preserves_fields() {
    let mut app = App::new_for_test();
    app.selected_file = 42;
    app.diff_scroll.selected_line = 99;
    app.diff_scroll.scroll_offset = 33;

    app.push_jump_location();

    let loc = &app.jump_stack[0];
    assert_eq!(loc.file_index, 42);
    assert_eq!(loc.line_index, 99);
    assert_eq!(loc.scroll_offset, 33);
}

#[tokio::test]
async fn test_jump_back_restores_position() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "a.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some(
                    "@@ -1,6 +1,6 @@\n line1\n line2\n line3\n line4\n line5\n+line6".to_string(),
                ),
                viewed: false,
            },
            ChangedFile {
                filename: "b.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some(
                    "@@ -1,11 +1,11 @@\n l1\n l2\n l3\n l4\n l5\n l6\n l7\n l8\n l9\n l10\n+l11"
                        .to_string(),
                ),
                viewed: false,
            },
        ],
    };

    // Push current position (line 5 is valid: a.rs has 7 diff lines, indices 0-6)
    app.selected_file = 0;
    app.diff_scroll.selected_line = 5;
    app.diff_scroll.scroll_offset = 2;
    app.push_jump_location();

    // Move elsewhere (line 10 is valid: b.rs has 12 diff lines, indices 0-11)
    app.selected_file = 1;
    app.diff_scroll.selected_line = 10;
    app.diff_scroll.scroll_offset = 8;

    // Jump back
    app.jump_back();

    assert_eq!(app.selected_file, 0);
    assert_eq!(app.diff_scroll.selected_line, 5);
    assert_eq!(app.diff_scroll.scroll_offset, 2);
}

#[test]
fn test_jump_back_empty_stack() {
    let mut app = App::new_for_test();
    app.selected_file = 3;
    app.diff_scroll.selected_line = 7;
    app.diff_scroll.scroll_offset = 4;

    app.jump_back();

    // Nothing should change
    assert_eq!(app.selected_file, 3);
    assert_eq!(app.diff_scroll.selected_line, 7);
    assert_eq!(app.diff_scroll.scroll_offset, 4);
}

// ===================================================================
// 11. comments.rs tests
// ===================================================================

#[test]
fn test_enter_comment_input_sets_mode() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = make_app_with_patch(patch);
    app.diff_scroll.selected_line = 1; // context line (commentable)
    app.state = AppState::DiffView;

    app.enter_comment_input();

    assert!(matches!(app.input_mode, Some(InputMode::Comment(_))));
    assert_eq!(app.state, AppState::TextInput);
}

#[test]
fn test_enter_comment_input_no_patch() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 0,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };

    // Should not panic
    app.enter_comment_input();
    assert!(app.input_mode.is_none());
}

#[test]
fn test_enter_suggestion_input_sets_mode() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added line\n more context";
    let mut app = make_app_with_patch(patch);
    app.diff_scroll.selected_line = 2; // added line
    app.state = AppState::DiffView;

    app.enter_suggestion_input();

    assert!(matches!(app.input_mode, Some(InputMode::Suggestion { .. })));
    assert_eq!(app.state, AppState::TextInput);
}

#[test]
fn test_enter_comment_input_works_in_local_mode() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = make_app_with_patch(patch);
    app.local_mode = true;
    app.pr_number = Some(0);
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some(patch.to_string()),
            viewed: false,
        }],
    };
    app.diff_scroll.selected_line = 2;
    app.state = AppState::DiffView;

    app.enter_comment_input();

    assert!(matches!(app.input_mode, Some(InputMode::Comment(_))));
    assert_eq!(app.state, AppState::TextInput);
}

#[tokio::test]
async fn test_open_comment_list_transitions_state() {
    let mut app = App::new_for_test();
    let (retry_tx, _) = mpsc::channel::<RefreshRequest>(4);
    app.retry_sender = Some(retry_tx);
    app.state = AppState::FileList;

    // Need loaded PR data for comment list
    app.data_state = DataState::Loaded {
        pr: Box::new(PullRequest {
            number: 1,
            node_id: None,
            title: "Test".to_string(),
            body: None,
            state: "open".to_string(),
            head: crate::github::Branch {
                ref_name: "f".to_string(),
                sha: "a".to_string(),
            },
            base: crate::github::Branch {
                ref_name: "m".to_string(),
                sha: "b".to_string(),
            },
            user: crate::github::User {
                login: "u".to_string(),
            },
            updated_at: "".to_string(),
        }),
        files: vec![],
    };
    app.previous_state = AppState::FileList;
    app.open_comment_list();
    assert_eq!(app.state, AppState::CommentList);
}

#[tokio::test]
async fn test_open_comment_list_sets_previous_state() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![],
    };
    app.state = AppState::FileList;
    app.previous_state = AppState::FileList;
    app.open_comment_list();
    // previous_state is set before open_comment_list, not by it
    assert_eq!(app.state, AppState::CommentList);
}

#[test]
fn test_open_comment_list_transitions_state_in_local_mode() {
    let mut app = App::new_for_test();
    app.local_mode = true;
    app.pr_number = Some(0);
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![],
    };
    app.state = AppState::FileList;
    app.previous_state = AppState::FileList;

    app.open_comment_list();

    assert_eq!(app.state, AppState::CommentList);
    assert_eq!(app.cmt.comment_tab, CommentTab::Review);
}

#[test]
#[serial]
fn test_submit_local_comment_persists_and_loads() {
    let tempdir = tempdir().unwrap();
    let workdir = tempdir.path().join("worktree");
    std::fs::create_dir_all(&workdir).unwrap();

    let _cache_home = ScopedCacheHome::new(tempdir.path());

    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = App::new_for_test();
    app.repo = "owner/repo".to_string();
    app.local_mode = true;
    app.pr_number = Some(0);
    app.working_dir = Some(workdir.to_string_lossy().to_string());
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "src/test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some(patch.to_string()),
            viewed: false,
        }],
    };
    app.diff_scroll.selected_line = 2;
    app.state = AppState::DiffView;

    let cache_key = PrCacheKey {
        repo: app.repo.clone(),
        pr_number: 0,
    };
    app.session_cache.put_pr_data(
        cache_key,
        PrData {
            pr: Box::new(make_local_pr()),
            files: app.files().to_vec(),
            pr_updated_at: "".to_string(),
        },
    );

    app.enter_comment_input();
    let ctx = match app.input_mode.clone() {
        Some(InputMode::Comment(ctx)) => ctx,
        other => panic!("expected comment input mode, got {:?}", other),
    };

    app.submit_comment(ctx, "local note".to_string());

    assert_eq!(
        app.cmt
            .review_comments
            .as_ref()
            .map(|c: &Vec<crate::github::comment::ReviewComment>| c.len()),
        Some(1)
    );
    assert_eq!(
        app.cmt.review_comments.as_ref().unwrap()[0].body,
        "local note".to_string()
    );

    let mut reloaded = App::new_for_test();
    reloaded.repo = "owner/repo".to_string();
    reloaded.local_mode = true;
    reloaded.pr_number = Some(0);
    reloaded.working_dir = Some(workdir.to_string_lossy().to_string());
    reloaded.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "src/test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some(patch.to_string()),
            viewed: false,
        }],
    };
    reloaded.load_review_comments();

    let stored = reloaded.cmt.review_comments.unwrap();
    assert_eq!(stored.len(), 1);
    assert_eq!(stored[0].path, "src/test.rs");
    assert_eq!(stored[0].line, Some(2));
    assert_eq!(stored[0].body, "local note");

    let path = crate::cache::local_review_comments_path(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
    )
    .unwrap();
    let _ = std::fs::remove_file(path);
}

/// CLI `update-local-comment` などでディスク上の resolved 状態が更新された後、
/// load_review_comments を再呼び出しすると最新の LocalCommentMeta が反映される。
/// session_cache に古い ReviewComment が残っていても meta は捨てない。
#[test]
#[serial]
fn test_load_review_comments_local_mode_refreshes_meta_from_disk() {
    let tempdir = tempdir().unwrap();
    let workdir = tempdir.path().join("worktree");
    std::fs::create_dir_all(&workdir).unwrap();

    let _cache_home = ScopedCacheHome::new(tempdir.path());

    let initial = vec![crate::cache::LocalReviewComment::new(
        crate::github::comment::ReviewComment {
            id: 7,
            path: "src/main.rs".to_string(),
            line: Some(3),
            start_line: None,
            body: "needs follow-up".to_string(),
            user: crate::github::User {
                login: "local".to_string(),
            },
            created_at: "2026-03-24T00:00:00Z".to_string(),
            in_reply_to_id: None,
        },
    )];
    crate::cache::save_local_review_comments(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
        &initial,
    )
    .unwrap();

    let mut app = App::new_for_test();
    app.repo = "owner/repo".to_string();
    app.local_mode = true;
    app.pr_number = Some(0);
    app.working_dir = Some(workdir.to_string_lossy().to_string());

    app.load_review_comments();
    assert!(app.cmt.local_comment_meta.is_empty());

    // 別プロセス（CLI 等）からディスク上の状態を resolved に書き換える
    let resolved = vec![crate::cache::LocalReviewComment::with_meta(
        initial[0].comment.clone(),
        crate::cache::LocalCommentMeta {
            is_resolved: true,
            resolved_at: Some("2026-03-24T01:00:00Z".to_string()),
        },
    )];
    crate::cache::save_local_review_comments(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
        &resolved,
    )
    .unwrap();

    // session_cache に古い comments が残っているが、meta は再取得される
    app.load_review_comments();
    let meta = app
        .cmt
        .local_comment_meta
        .get(&7)
        .expect("local meta should reflect disk state");
    assert!(meta.is_resolved);
    assert_eq!(meta.resolved_at.as_deref(), Some("2026-03-24T01:00:00Z"));

    let path = crate::cache::local_review_comments_path(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
    )
    .unwrap();
    let _ = std::fs::remove_file(path);
}

/// In local mode, `load_review_comments` must route through
/// `apply_review_comments` so the threaded renderer and the file-list
/// per-file count badges both work. A regression that goes back to
/// assigning `review_comments` directly leaves `review_threads` empty
/// and `file_comment_counts` un-populated, which the unit-level test
/// of `apply_review_comments` does not catch.
#[test]
#[serial]
fn test_load_review_comments_local_mode_builds_threads_and_counts() {
    let tempdir = tempdir().unwrap();
    let workdir = tempdir.path().join("worktree");
    std::fs::create_dir_all(&workdir).unwrap();

    let _cache_home = ScopedCacheHome::new(tempdir.path());

    let local_comments = vec![
        crate::cache::LocalReviewComment::new(crate::github::comment::ReviewComment {
            id: 1,
            path: "src/a.rs".to_string(),
            line: Some(1),
            start_line: None,
            body: "root on a".to_string(),
            user: crate::github::User {
                login: "local".to_string(),
            },
            created_at: "2026-04-01T00:00:00Z".to_string(),
            in_reply_to_id: None,
        }),
        crate::cache::LocalReviewComment::new(crate::github::comment::ReviewComment {
            id: 2,
            path: "src/a.rs".to_string(),
            line: Some(1),
            start_line: None,
            body: "reply on a".to_string(),
            user: crate::github::User {
                login: "local".to_string(),
            },
            created_at: "2026-04-01T01:00:00Z".to_string(),
            in_reply_to_id: Some(1),
        }),
        crate::cache::LocalReviewComment::new(crate::github::comment::ReviewComment {
            id: 3,
            path: "src/b.rs".to_string(),
            line: Some(7),
            start_line: None,
            body: "independent".to_string(),
            user: crate::github::User {
                login: "local".to_string(),
            },
            created_at: "2026-04-01T02:00:00Z".to_string(),
            in_reply_to_id: None,
        }),
    ];
    crate::cache::save_local_review_comments(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
        &local_comments,
    )
    .unwrap();

    let mut app = App::new_for_test();
    app.repo = "owner/repo".to_string();
    app.local_mode = true;
    app.pr_number = Some(0);
    app.working_dir = Some(workdir.to_string_lossy().to_string());

    app.load_review_comments();

    assert_eq!(
        app.cmt.review_threads.len(),
        2,
        "two threads should exist: the root+reply on a.rs and the independent on b.rs"
    );
    assert_eq!(app.cmt.file_comment_counts.get("src/a.rs"), Some(&2));
    assert_eq!(app.cmt.file_comment_counts.get("src/b.rs"), Some(&1));

    let path = crate::cache::local_review_comments_path(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
    )
    .unwrap();
    let _ = std::fs::remove_file(path);
}

/// Local → PR モード切替時に local_comment_meta がクリアされる。
/// クリアしないと PR モードのコメント描画でローカル限定の resolved ID が
/// GitHub コメントに誤適用される。
#[test]
fn test_toggle_local_mode_clears_local_comment_meta_on_exit() {
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    let mut app = App::new_for_test();
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((0, data_rx));
    app.original_pr_number = Some(42);
    app.pr_number = Some(0);
    app.local_mode = true;
    app.cmt.local_comment_meta.insert(
        99,
        crate::cache::LocalCommentMeta {
            is_resolved: true,
            resolved_at: Some("2026-03-25T00:00:00Z".to_string()),
        },
    );

    app.toggle_local_mode();

    assert!(!app.local_mode);
    assert!(app.cmt.local_comment_meta.is_empty());
}

#[test]
fn test_update_file_comment_positions_empty_comments() {
    let mut app = make_app_with_patch("@@ -1,3 +1,4 @@\n context\n+added\n more context");
    app.cmt.review_comments = Some(vec![]);
    app.update_file_comment_positions();
    assert!(app.cmt.file_comment_positions.is_empty());
}

#[test]
fn test_update_file_comment_positions_with_comments() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = make_app_with_patch(patch);
    app.cmt.review_comments = Some(vec![crate::github::comment::ReviewComment {
        id: 1,
        path: "test.rs".to_string(),
        line: Some(1),
        start_line: None,
        body: "comment at line 1".to_string(),
        user: crate::github::User {
            login: "reviewer".to_string(),
        },
        created_at: "2024-01-01T00:00:00Z".to_string(),
        in_reply_to_id: None,
    }]);
    app.update_file_comment_positions();
    assert_eq!(app.cmt.file_comment_positions.len(), 1);
}

#[test]
fn test_update_file_comment_positions_stale_comment() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = make_app_with_patch(patch);
    app.cmt.review_comments = Some(vec![crate::github::comment::ReviewComment {
        id: 1,
        path: "other_file.rs".to_string(), // different file
        line: Some(1),
        start_line: None,
        body: "wrong file".to_string(),
        user: crate::github::User {
            login: "reviewer".to_string(),
        },
        created_at: "2024-01-01T00:00:00Z".to_string(),
        in_reply_to_id: None,
    }]);
    app.update_file_comment_positions();
    assert!(app.cmt.file_comment_positions.is_empty());
}

#[test]
fn test_wrapped_line_count_short() {
    assert_eq!(App::wrapped_line_count("hello", 80), 1);
}

#[test]
fn test_wrapped_line_count_long() {
    // 100 chars in 40-wide panel = 3 lines
    let text: String = "x".repeat(100);
    assert_eq!(App::wrapped_line_count(&text, 40), 3);
}

#[test]
fn test_wrapped_line_count_empty() {
    assert_eq!(App::wrapped_line_count("", 80), 1);
}

#[test]
fn test_comment_body_wrapped_lines() {
    let body = "short line\na longer line that has more characters";
    // Each line wraps independently
    let count = App::comment_body_wrapped_lines(body, 80);
    assert_eq!(count, 2); // both fit in 80 chars
}

#[test]
fn test_comment_panel_inner_width() {
    let mut app = App::new_for_test();
    app.state = AppState::DiffView;
    // For non-SplitView, width = terminal_width - 2
    assert_eq!(app.comment_panel_inner_width(100), 98);
}

#[test]
fn test_max_comment_panel_scroll() {
    let mut app = App::new_for_test();
    app.state = AppState::DiffView;
    app.cmt.file_comment_positions = vec![];
    app.cmt.review_comments = Some(vec![]);

    // No comments at current line -> content = 1 ("No comments..." message)
    let max = app.max_comment_panel_scroll(40, 80);
    // Content lines (1) - panel inner height
    // Panel inner height ≈ (40-8)*40/100 = 12
    // saturating_sub means 0
    assert_eq!(max, 0);
}

#[test]
fn test_enter_reply_input_sets_mode() {
    let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
    let mut app = make_app_with_patch(patch);
    app.diff_scroll.selected_line = 1;
    app.cmt.review_comments = Some(vec![crate::github::comment::ReviewComment {
        id: 42,
        path: "test.rs".to_string(),
        line: Some(1),
        start_line: None,
        body: "original comment".to_string(),
        user: crate::github::User {
            login: "reviewer".to_string(),
        },
        created_at: "2024-01-01T00:00:00Z".to_string(),
        in_reply_to_id: None,
    }]);
    app.cmt.file_comment_positions = vec![CommentPosition {
        diff_line_index: 1,
        comment_index: 0,
    }];
    app.state = AppState::DiffView;

    app.enter_reply_input();

    assert!(matches!(app.input_mode, Some(InputMode::Reply { .. })));
    assert_eq!(app.state, AppState::TextInput);
}

#[test]
fn test_handle_discussion_detail_scroll_j() {
    let mut app = App::new_for_test();
    app.cmt.discussion_comment_detail_mode = true;
    app.cmt.discussion_comment_detail_scroll = 0;

    let result = app.handle_discussion_detail_input(make_key(KeyCode::Char('j')), 20);
    assert!(result.is_ok());
    assert_eq!(app.cmt.discussion_comment_detail_scroll, 1);
}

#[test]
fn test_handle_discussion_detail_scroll_k() {
    let mut app = App::new_for_test();
    app.cmt.discussion_comment_detail_mode = true;
    app.cmt.discussion_comment_detail_scroll = 5;

    let result = app.handle_discussion_detail_input(make_key(KeyCode::Char('k')), 20);
    assert!(result.is_ok());
    assert_eq!(app.cmt.discussion_comment_detail_scroll, 4);
}

#[tokio::test]
async fn test_jump_to_comment_sets_file_and_line() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(PullRequest {
            number: 1,
            node_id: None,
            title: "Test".to_string(),
            body: None,
            state: "open".to_string(),
            head: crate::github::Branch {
                ref_name: "f".to_string(),
                sha: "a".to_string(),
            },
            base: crate::github::Branch {
                ref_name: "m".to_string(),
                sha: "b".to_string(),
            },
            user: crate::github::User {
                login: "u".to_string(),
            },
            updated_at: "".to_string(),
        }),
        files: vec![
            ChangedFile {
                filename: "first.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1,1 +1,2 @@\n line1\n+line2".to_string()),
                viewed: false,
            },
            ChangedFile {
                filename: "second.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1,1 +1,2 @@\n line1\n+line2".to_string()),
                viewed: false,
            },
        ],
    };
    app.cmt.review_comments = Some(vec![crate::github::comment::ReviewComment {
        id: 1,
        path: "second.rs".to_string(),
        line: Some(2),
        start_line: None,
        body: "check this".to_string(),
        user: crate::github::User {
            login: "r".to_string(),
        },
        created_at: "2024-01-01T00:00:00Z".to_string(),
        in_reply_to_id: None,
    }]);
    app.cmt.selected_comment = 0;

    app.jump_to_comment();

    assert_eq!(app.selected_file, 1); // second.rs
    assert_eq!(app.state, AppState::DiffView);
}

// ===================================================================
// 12. pr_list.rs tests
// ===================================================================

#[tokio::test]
async fn test_handle_pr_list_input_quit() {
    let mut app = App::new_for_test();
    app.state = AppState::PullRequestList;
    app.prs.pr_list = LoadState::Loaded(vec![]);

    app.handle_pr_list_input(make_key(KeyCode::Char('q')))
        .await
        .unwrap();
    assert!(app.should_quit);
}

#[tokio::test]
async fn test_handle_pr_list_input_loading_blocks() {
    use crate::github::PullRequestSummary;
    let mut app = App::new_for_test();
    app.state = AppState::PullRequestList;
    app.prs.pr_list = LoadState::LoadingMore(vec![PullRequestSummary {
        number: 1,
        title: "PR 1".to_string(),
        state: "open".to_string(),
        author: crate::github::User {
            login: "user".to_string(),
        },
        is_draft: false,
        labels: vec![],
        updated_at: "2024-01-01T00:00:00Z".to_string(),
        status_check_rollup: vec![],
    }]);
    app.prs.selected_pr = 0;

    // j key should be blocked during loading
    app.handle_pr_list_input(make_key(KeyCode::Char('j')))
        .await
        .unwrap();
    assert_eq!(app.prs.selected_pr, 0); // unchanged
}

#[tokio::test]
async fn test_handle_pr_list_input_move_down() {
    use crate::github::PullRequestSummary;
    let mut app = App::new_for_test();
    app.state = AppState::PullRequestList;
    app.prs.pr_list = LoadState::Loaded(vec![
        PullRequestSummary {
            number: 1,
            title: "PR 1".to_string(),
            state: "open".to_string(),
            author: crate::github::User {
                login: "user".to_string(),
            },
            is_draft: false,
            labels: vec![],
            updated_at: "2024-01-01T00:00:00Z".to_string(),
            status_check_rollup: vec![],
        },
        PullRequestSummary {
            number: 2,
            title: "PR 2".to_string(),
            state: "open".to_string(),
            author: crate::github::User {
                login: "user".to_string(),
            },
            is_draft: false,
            labels: vec![],
            updated_at: "2024-01-01T00:00:00Z".to_string(),
            status_check_rollup: vec![],
        },
    ]);
    app.prs.selected_pr = 0;

    app.handle_pr_list_input(make_key(KeyCode::Char('j')))
        .await
        .unwrap();
    assert_eq!(app.prs.selected_pr, 1);
}

#[tokio::test]
async fn test_handle_pr_list_input_move_up() {
    use crate::github::PullRequestSummary;
    let mut app = App::new_for_test();
    app.state = AppState::PullRequestList;
    app.prs.pr_list = LoadState::Loaded(vec![
        PullRequestSummary {
            number: 1,
            title: "PR 1".to_string(),
            state: "open".to_string(),
            author: crate::github::User {
                login: "user".to_string(),
            },
            is_draft: false,
            labels: vec![],
            updated_at: "2024-01-01T00:00:00Z".to_string(),
            status_check_rollup: vec![],
        },
        PullRequestSummary {
            number: 2,
            title: "PR 2".to_string(),
            state: "open".to_string(),
            author: crate::github::User {
                login: "user".to_string(),
            },
            is_draft: false,
            labels: vec![],
            updated_at: "2024-01-01T00:00:00Z".to_string(),
            status_check_rollup: vec![],
        },
    ]);
    app.prs.selected_pr = 1;

    app.handle_pr_list_input(make_key(KeyCode::Char('k')))
        .await
        .unwrap();
    assert_eq!(app.prs.selected_pr, 0);
}

#[tokio::test]
async fn test_handle_pr_list_input_jump_to_last() {
    use crate::github::PullRequestSummary;
    let mut app = App::new_for_test();
    app.state = AppState::PullRequestList;
    app.prs.pr_list = LoadState::Loaded(
        (0..10)
            .map(|i| PullRequestSummary {
                number: i,
                title: format!("PR {}", i),
                state: "open".to_string(),
                author: crate::github::User {
                    login: "user".to_string(),
                },
                is_draft: false,
                labels: vec![],
                updated_at: "2024-01-01T00:00:00Z".to_string(),
                status_check_rollup: vec![],
            })
            .collect(),
    );
    app.prs.selected_pr = 0;

    app.handle_pr_list_input(make_key(KeyCode::Char('G')))
        .await
        .unwrap();
    assert_eq!(app.prs.selected_pr, 9);
}

#[tokio::test]
async fn test_reload_pr_list_resets_state() {
    let mut app = App::new_for_test();
    app.prs.selected_pr = 5;
    app.prs.pr_list_scroll_offset = 10;
    let filter = crate::filter::ListFilter::new();
    app.prs.pr_list_filter = Some(filter);

    app.reload_pr_list();

    assert_eq!(app.prs.selected_pr, 0);
    assert_eq!(app.prs.pr_list_scroll_offset, 0);
    assert!(app.prs.pr_list.is_loading());
    assert!(!app.prs.pr_list_has_more);
    assert!(app.prs.pr_list_filter.is_none());
}

#[test]
fn test_load_more_prs_skips_when_loading() {
    let mut app = App::new_for_test();
    app.prs.pr_list = LoadState::Loading;
    let prev_receiver = app.prs.pr_list_receiver.is_some();

    app.load_more_prs();

    // Should not create a new receiver
    assert_eq!(app.prs.pr_list_receiver.is_some(), prev_receiver);
}

#[tokio::test]
async fn test_select_pr_cache_miss_sets_loading() {
    let mut app = App::new_for_test();
    let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
    let (_data_tx, data_rx) = mpsc::channel(2);
    app.retry_sender = Some(retry_tx);
    app.data_receiver = Some((0, data_rx));

    app.select_pr(42);

    assert_eq!(app.pr_number, Some(42));
    assert_eq!(app.state, AppState::FileList);
    assert!(matches!(app.data_state, DataState::Loading));
}

// ===================================================================
// 13. polling.rs tests
// ===================================================================

#[tokio::test]
async fn test_poll_diff_cache_accepts_valid() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+line".to_string()),
            viewed: false,
        }],
    };
    app.selected_file = 0;
    app.diff_scroll.line_count = 2;

    // Set up a plain cache as current (poll_highlight checks current_key and patch_hash)
    let plain_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
    let patch_hash = plain_cache.patch_hash;
    app.diff_store.set_current(0, plain_cache);

    let (tx, rx) = mpsc::channel(1);
    app.diff_store.set_highlight_rx(rx);

    let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
    cache.highlighted = true;
    cache.patch_hash = patch_hash;
    tx.send((0_usize, cache)).await.unwrap();

    app.poll_diff_cache_updates();

    assert!(app.diff_store.current.is_some());
    assert!(app.diff_store.current.as_ref().unwrap().highlighted);
}

#[tokio::test]
async fn test_poll_diff_cache_rejects_stale_file() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "a.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+line".to_string()),
                viewed: false,
            },
            ChangedFile {
                filename: "b.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+line2".to_string()),
                viewed: false,
            },
        ],
    };
    app.selected_file = 1; // We're now looking at file 1

    // Set current to file 1 (the selected file)
    let mut current_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line2", 4);
    current_cache.file_index = 1;
    app.diff_store.set_current(1, current_cache);

    let (tx, rx) = mpsc::channel(1);
    app.diff_store.set_highlight_rx(rx);

    // Send cache for file 0 (stale - key doesn't match current_key)
    let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
    cache.file_index = 0;
    cache.highlighted = true;
    tx.send((0_usize, cache)).await.unwrap();

    app.poll_diff_cache_updates();

    // Stale cache should be rejected (current stays as file 1)
    if let Some(ref c) = app.diff_store.current {
        assert_ne!(c.file_index, 0, "stale cache should not be applied");
    }
}

#[tokio::test]
async fn test_poll_prefetch_stores_cache() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "a.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+line".to_string()),
                viewed: false,
            },
            ChangedFile {
                filename: "b.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+line2".to_string()),
                viewed: false,
            },
        ],
    };
    app.selected_file = 0;

    let (tx, rx) = mpsc::channel(2);
    app.diff_store.set_prefetch_rx(rx);

    let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line2", 4);
    cache.file_index = 1;
    cache.highlighted = true;
    tx.send((1_usize, cache)).await.unwrap();

    app.poll_prefetch_updates();

    assert!(app.diff_store.store.contains_key(&1));
}

#[tokio::test]
async fn test_poll_prefetch_skips_current_file() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "a.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+line".to_string()),
            viewed: false,
        }],
    };
    app.selected_file = 0;

    // Set up an existing highlighted diff_cache for current file
    // poll_prefetch_updates skips when current has highlighted=true for same key
    let mut existing_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
    existing_cache.file_index = 0;
    existing_cache.highlighted = true;
    app.diff_store.set_current(0, existing_cache);

    let (tx, rx) = mpsc::channel(2);
    app.diff_store.set_prefetch_rx(rx);

    let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
    cache.file_index = 0; // same as selected
    cache.highlighted = true;
    tx.send((0_usize, cache)).await.unwrap();

    app.poll_prefetch_updates();

    // Should skip storing cache for current file because diff_store.current already has it highlighted
    assert!(!app.diff_store.store.contains_key(&0));
}

#[tokio::test]
async fn test_poll_comment_submit_success() {
    use crate::loader::CommentSubmitResult;
    let mut app = App::new_for_test();
    app.pr_number = Some(1);
    app.cmt.comment_submitting = true;

    let (tx, rx) = mpsc::channel(1);
    app.cmt.comment_submit_receiver = Some((1, rx));

    tx.send(CommentSubmitResult::Success).await.unwrap();

    app.poll_comment_submit_updates();

    assert!(!app.cmt.comment_submitting);
    let (success, _) = app.cmt.submission_result.unwrap();
    assert!(success);
}

#[tokio::test]
async fn test_poll_comment_submit_failure() {
    use crate::loader::CommentSubmitResult;
    let mut app = App::new_for_test();
    app.pr_number = Some(1);
    app.cmt.comment_submitting = true;

    let (tx, rx) = mpsc::channel(1);
    app.cmt.comment_submit_receiver = Some((1, rx));

    tx.send(CommentSubmitResult::Error("network error".to_string()))
        .await
        .unwrap();

    app.poll_comment_submit_updates();

    assert!(!app.cmt.comment_submitting);
    let (success, msg) = app.cmt.submission_result.unwrap();
    assert!(!success);
    assert!(msg.contains("network error"));
}

#[tokio::test]
async fn test_poll_mark_viewed_success() {
    let mut app = App::new_for_test();
    app.pr_number = Some(1);
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };

    let (tx, rx) = mpsc::channel(1);
    app.mark_viewed_receiver = Some((1, rx));

    tx.send(MarkViewedResult::Completed {
        marked_paths: vec!["test.rs".to_string()],
        total_targets: 1,
        error: None,
        set_viewed: true,
    })
    .await
    .unwrap();

    app.poll_mark_viewed_updates();

    if let DataState::Loaded { files, .. } = &app.data_state {
        assert!(files[0].viewed);
    }
    let (success, _) = app.cmt.submission_result.unwrap();
    assert!(success);
}

#[tokio::test]
async fn test_poll_mark_viewed_error() {
    let mut app = App::new_for_test();
    app.pr_number = Some(1);
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "test.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: None,
            viewed: false,
        }],
    };

    let (tx, rx) = mpsc::channel(1);
    app.mark_viewed_receiver = Some((1, rx));

    tx.send(MarkViewedResult::Completed {
        marked_paths: vec![],
        total_targets: 1,
        error: Some("API error".to_string()),
        set_viewed: true,
    })
    .await
    .unwrap();

    app.poll_mark_viewed_updates();

    let (success, msg) = app.cmt.submission_result.unwrap();
    assert!(!success);
    assert!(msg.contains("API error"));
}

#[tokio::test]
async fn test_poll_comment_updates_cross_pr_discards() {
    let mut app = App::new_for_test();
    app.pr_number = Some(2); // Current PR is 2

    let (tx, rx) = mpsc::channel(1);
    app.cmt.comment_receiver = Some((1, rx)); // Data is for PR 1
    app.cmt.comments_loading = true;

    tx.send(Ok(vec![])).await.unwrap();

    app.poll_comment_updates();

    // Should NOT update UI for wrong PR
    assert!(app.cmt.comments_loading);
    assert!(app.cmt.review_comments.is_none());
}

#[tokio::test]
async fn test_poll_discussion_comment_cross_pr_discards() {
    let mut app = App::new_for_test();
    app.pr_number = Some(2); // Current PR is 2

    let (tx, rx) = mpsc::channel(1);
    app.cmt.discussion_comment_receiver = Some((1, rx)); // Data is for PR 1
    app.cmt.discussion_comments_loading = true;

    tx.send(Ok(vec![])).await.unwrap();

    app.poll_discussion_comment_updates();

    // Should NOT update UI for wrong PR
    assert!(app.cmt.discussion_comments_loading);
    assert!(app.cmt.discussion_comments.is_none());
}

// --- Help Tab Tests ---

#[test]
fn test_help_tab_switch_with_bracket_keys() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::Help;

    // Default tab is Keybindings
    assert_eq!(app.help_tab, HelpTab::Keybindings);

    // ] switches to Config
    app.apply_help_scroll(make_key(KeyCode::Char(']')), 30);
    assert_eq!(app.help_tab, HelpTab::Config);

    // ] again switches back to Keybindings
    app.apply_help_scroll(make_key(KeyCode::Char(']')), 30);
    assert_eq!(app.help_tab, HelpTab::Keybindings);

    // [ switches to Config
    app.apply_help_scroll(make_key(KeyCode::Char('[')), 30);
    assert_eq!(app.help_tab, HelpTab::Config);

    // [ again switches back to Keybindings
    app.apply_help_scroll(make_key(KeyCode::Char('[')), 30);
    assert_eq!(app.help_tab, HelpTab::Keybindings);
}

#[test]
fn test_help_tab_independent_scroll_offsets() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::Help;

    // Scroll keybindings tab
    app.help_tab = HelpTab::Keybindings;
    app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
    app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
    app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
    assert_eq!(app.help_scroll_offset, 3);
    assert_eq!(app.config_scroll_offset, 0);

    // Switch to Config tab and scroll
    app.apply_help_scroll(make_key(KeyCode::Char(']')), 30);
    assert_eq!(app.help_tab, HelpTab::Config);
    app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
    assert_eq!(app.config_scroll_offset, 1);
    // Keybindings offset unchanged
    assert_eq!(app.help_scroll_offset, 3);

    // Switch back and verify keybindings offset preserved
    app.apply_help_scroll(make_key(KeyCode::Char('[')), 30);
    assert_eq!(app.help_tab, HelpTab::Keybindings);
    assert_eq!(app.help_scroll_offset, 3);
}

#[test]
fn test_help_tab_switch_does_not_scroll() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::Help;
    app.help_scroll_offset = 5;
    app.config_scroll_offset = 10;

    // Tab switch should not change scroll offsets
    app.apply_help_scroll(make_key(KeyCode::Char(']')), 30);
    assert_eq!(app.help_scroll_offset, 5);
    assert_eq!(app.config_scroll_offset, 10);
}

#[test]
fn test_help_reopen_resets_scroll_but_preserves_tab() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::Help;

    // Switch to Config tab and scroll
    app.help_tab = HelpTab::Config;
    app.config_scroll_offset = 5;
    app.help_scroll_offset = 10;

    // Close help
    app.apply_help_scroll(make_key(KeyCode::Char('q')), 30);
    assert_ne!(app.state, AppState::Help);

    // Simulate reopening help (like input.rs does)
    app.previous_state = AppState::FileList;
    app.state = AppState::Help;
    app.help_scroll_offset = 0;
    app.config_scroll_offset = 0;

    // Tab state is preserved (not reset)
    assert_eq!(app.help_tab, HelpTab::Config);
    // Scroll offsets are reset
    assert_eq!(app.help_scroll_offset, 0);
    assert_eq!(app.config_scroll_offset, 0);
}

#[test]
fn test_config_tab_scroll_with_jk() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::Help;
    app.help_tab = HelpTab::Config;

    app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
    assert_eq!(app.config_scroll_offset, 1);
    app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
    assert_eq!(app.config_scroll_offset, 2);
    app.apply_help_scroll(make_key(KeyCode::Char('k')), 30);
    assert_eq!(app.config_scroll_offset, 1);
}

// ============================================================
// PR Description tests
// ============================================================

#[test]
fn test_open_pr_description_state_transition() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: Some("# Hello\nWorld".to_string()),
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded { pr, files: vec![] };
    app.state = AppState::FileList;

    app.open_pr_description();

    assert_eq!(app.state, AppState::PrDescription);
    assert_eq!(app.previous_state, AppState::FileList);
    assert_eq!(app.pr_description_scroll_offset, 0);
    assert!(app.pr_description_cache.is_some());
}

#[test]
fn test_open_pr_description_from_split_view() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: Some("description body".to_string()),
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded { pr, files: vec![] };
    app.state = AppState::SplitViewFileList;

    app.open_pr_description();

    assert_eq!(app.state, AppState::PrDescription);
    assert_eq!(app.previous_state, AppState::SplitViewFileList);
}

#[test]
fn test_open_pr_description_body_none() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded { pr, files: vec![] };

    app.open_pr_description();

    assert_eq!(app.state, AppState::PrDescription);
    assert!(app.pr_description_cache.is_none());
}

#[test]
fn test_open_pr_description_body_empty() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: Some("".to_string()),
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded { pr, files: vec![] };

    app.open_pr_description();

    assert_eq!(app.state, AppState::PrDescription);
    assert!(app.pr_description_cache.is_none());
}

#[test]
fn test_toggle_markdown_rich_clears_pr_description_cache() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: Some("# Title\nBody".to_string()),
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded { pr, files: vec![] };

    // Build cache
    app.open_pr_description();
    assert!(app.pr_description_cache.is_some());

    // Toggle markdown rich should clear the cache
    app.toggle_markdown_rich();
    assert!(app.pr_description_cache.is_none());
}

#[test]
fn test_pr_description_cache_reuse() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: Some("Same body".to_string()),
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded { pr, files: vec![] };

    // First open
    app.open_pr_description();
    assert!(app.pr_description_cache.is_some());
    let first_hash = app.pr_description_cache.as_ref().unwrap().patch_hash;

    // Go back
    app.state = AppState::FileList;

    // Second open - should reuse cache (same body hash)
    app.open_pr_description();
    assert!(app.pr_description_cache.is_some());
    assert_eq!(
        app.pr_description_cache.as_ref().unwrap().patch_hash,
        first_hash
    );
}

// --- ensure_diff_cache: markdown_rich と非markdownファイルのキャッシュ保持テスト ---

/// ensure_diff_cache ステージ1: 非markdownファイルでは markdown_rich 不一致でもキャッシュを保持する。
/// markdown_rich は markdown ファイルのハイライトにのみ影響するため、非markdownのキャッシュを
/// 破棄するとプリフェッチ済みキャッシュが無駄に失われるデグレが発生する。
#[tokio::test]
async fn test_ensure_diff_cache_non_md_ignores_markdown_rich_mismatch() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "main.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
            viewed: false,
        }],
    };
    app.selected_file = 0;
    app.markdown_rich = false;

    // markdown_rich=true で構築されたハイライト済みキャッシュをセット
    let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
    cache.file_index = 0;
    cache.highlighted = true;
    cache.markdown_rich = true; // 現在の app.markdown_rich (false) と不一致
    app.diff_store.set_current(0, cache);

    // ensure_diff_cache は非markdown なので markdown_rich 不一致を無視してキャッシュを維持
    app.ensure_diff_cache();
    assert!(
        app.diff_store
            .current
            .as_ref()
            .is_some_and(|c| c.highlighted),
        "non-md file: highlighted cache should be preserved despite markdown_rich mismatch"
    );
}

/// ensure_diff_cache ステージ2: ストア内の非markdownキャッシュも markdown_rich 不一致で破棄しない
#[test]
fn test_ensure_diff_cache_store_non_md_ignores_markdown_rich_mismatch() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "main.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
            viewed: false,
        }],
    };
    app.selected_file = 0;
    app.markdown_rich = false;
    app.diff_store.current = None;

    // ストアに markdown_rich=true のキャッシュを配置
    let mut store_cache =
        crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
    store_cache.file_index = 0;
    store_cache.highlighted = true;
    store_cache.markdown_rich = true;
    app.diff_store.store.insert(0, store_cache);

    app.ensure_diff_cache();

    // ストアから復元されるはず（markdown_rich 不一致は無視）
    assert!(
        app.diff_store
            .current
            .as_ref()
            .is_some_and(|c| c.highlighted),
        "non-md file: store cache should be restored despite markdown_rich mismatch"
    );
}

/// ensure_diff_cache: markdownファイルでは markdown_rich 不一致時にキャッシュを再構築する
#[tokio::test]
async fn test_ensure_diff_cache_md_invalidates_on_markdown_rich_mismatch() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![ChangedFile {
            filename: "README.md".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+# Hello".to_string()),
            viewed: false,
        }],
    };
    app.selected_file = 0;
    app.markdown_rich = true;

    // markdown_rich=false で構築されたキャッシュをセット
    let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+# Hello", 4);
    cache.file_index = 0;
    cache.highlighted = true;
    cache.markdown_rich = false; // 現在の app.markdown_rich (true) と不一致
    app.diff_store.current = Some(cache);

    app.ensure_diff_cache();

    // markdown ファイルなので markdown_rich 不一致 → 再構築（plain に戻る）
    assert!(
        app.diff_store
            .current
            .as_ref()
            .is_some_and(|c| !c.highlighted),
        "md file: cache should be rebuilt (plain) on markdown_rich mismatch"
    );
}

/// PR description view での markdown_rich トグルが prefetch_receiver と
/// highlighted_cache_store を温存することを検証する。
/// toggle_markdown_rich() を使うとこれらが破棄されるため、PR description view では
/// フラグ反転 + PR description キャッシュ再構築のみ行う設計。
#[tokio::test]
async fn test_pr_description_toggle_rich_preserves_prefetch_and_store() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: Some("# Description\nBody text".to_string()),
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded {
        pr,
        files: vec![ChangedFile {
            filename: "main.rs".to_string(),
            status: "modified".to_string(),
            additions: 1,
            deletions: 0,
            patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
            viewed: false,
        }],
    };

    // プリフェッチ済みキャッシュをストアに配置
    let mut rs_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
    rs_cache.file_index = 0;
    rs_cache.highlighted = true;
    app.diff_store.store.insert(0, rs_cache);

    // prefetch_receiver をセット
    let (_tx, rx) = tokio::sync::mpsc::channel::<(usize, DiffCache)>(1);
    app.diff_store.set_prefetch_rx(rx);

    // PR description を開く
    app.open_pr_description();
    assert_eq!(app.state, AppState::PrDescription);

    // M キーと同等の処理: フラグ反転 + PR description キャッシュ再構築
    app.markdown_rich = !app.markdown_rich;
    app.pr_description_cache = None;
    app.rebuild_pr_description_cache();

    // prefetch_receiver は温存されていること
    assert!(
        app.diff_store.has_prefetch_rx(),
        "prefetch_receiver should be preserved after toggling rich in PR description view"
    );
    // ストアのキャッシュも温存されていること
    assert!(
        app.diff_store.store.contains_key(&0),
        "highlighted_cache_store should be preserved after toggling rich in PR description view"
    );
    // PR description キャッシュは再構築されていること
    assert!(
        app.pr_description_cache.is_some(),
        "pr_description_cache should be rebuilt"
    );
}

/// rebuild_pr_description_cache がスクロール位置を維持することを検証する。
/// open_pr_description() はスクロールを 0 にリセットするが、rebuild はリセットしない。
#[test]
fn test_rebuild_pr_description_cache_preserves_scroll() {
    let config = Config::default();
    let (mut app, _) = App::new_loading("owner/repo", 1, config);

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: Some("line1\nline2\nline3\nline4\nline5".to_string()),
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.data_state = DataState::Loaded { pr, files: vec![] };

    app.open_pr_description();
    assert_eq!(app.pr_description_scroll_offset, 0);

    // スクロールしてから rebuild
    app.pr_description_scroll_offset = 3;
    app.markdown_rich = !app.markdown_rich;
    app.pr_description_cache = None;
    app.rebuild_pr_description_cache();

    assert_eq!(
        app.pr_description_scroll_offset, 3,
        "rebuild_pr_description_cache should not reset scroll offset"
    );
    assert!(app.pr_description_cache.is_some());
}

// ===================================================================
// Async symbol search state machine tests

#[test]
fn test_symbol_search_stale_result_discarded_when_file_changed() {
    let mut app = App::new_for_test();
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "a.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+line".to_string()),
                viewed: false,
            },
            ChangedFile {
                filename: "b.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+line".to_string()),
                viewed: false,
            },
        ],
    };

    // Simulate an async search started from file 0
    let (tx, rx) = tokio::sync::mpsc::channel(1);
    app.symbol_search = SymbolSearchState::Searching {
        receiver: rx,
        origin_file_index: 0,
    };

    // User navigates to file 1 before the result arrives
    app.selected_file = 1;

    // Send a Found result
    tx.try_send(SymbolSearchUpdate::Found(RepoSymbolSearchResult {
        file_path: "src/foo.rs".to_string(),
        line_number: 10,
        repo_root: "/tmp".to_string(),
    }))
    .unwrap();

    // Poll: should discard the stale result
    app.poll_symbol_search_updates();
    assert!(
        matches!(app.symbol_search, SymbolSearchState::Idle),
        "Stale result should be discarded when user navigated to a different file"
    );
}

#[test]
fn test_symbol_search_result_accepted_when_file_unchanged() {
    let mut app = App::new_for_test();
    app.selected_file = 2;

    let (tx, rx) = tokio::sync::mpsc::channel(1);
    app.symbol_search = SymbolSearchState::Searching {
        receiver: rx,
        origin_file_index: 2,
    };

    tx.try_send(SymbolSearchUpdate::Found(RepoSymbolSearchResult {
        file_path: "src/bar.rs".to_string(),
        line_number: 5,
        repo_root: "/tmp".to_string(),
    }))
    .unwrap();

    app.poll_symbol_search_updates();
    assert!(
        matches!(app.symbol_search, SymbolSearchState::Ready(..)),
        "Result should be accepted when user is still on the same file"
    );
}

#[test]
fn test_symbol_search_not_found_resets_to_idle() {
    let mut app = App::new_for_test();
    app.selected_file = 0;

    let (tx, rx) = tokio::sync::mpsc::channel(1);
    app.symbol_search = SymbolSearchState::Searching {
        receiver: rx,
        origin_file_index: 0,
    };

    tx.try_send(SymbolSearchUpdate::NotFound).unwrap();

    app.poll_symbol_search_updates();
    assert!(matches!(app.symbol_search, SymbolSearchState::Idle));
    assert!(app.cmt.submission_result.is_some());
    let (success, msg) = app.cmt.submission_result.unwrap();
    assert!(!success);
    assert!(msg.contains("not found"));
}

#[test]
fn test_symbol_search_failed_resets_to_idle() {
    let mut app = App::new_for_test();
    app.selected_file = 0;

    let (tx, rx) = tokio::sync::mpsc::channel(1);
    app.symbol_search = SymbolSearchState::Searching {
        receiver: rx,
        origin_file_index: 0,
    };

    tx.try_send(SymbolSearchUpdate::Failed("rg not found".to_string()))
        .unwrap();

    app.poll_symbol_search_updates();
    assert!(matches!(app.symbol_search, SymbolSearchState::Idle));
    assert!(app.cmt.submission_result.is_some());
    let (success, msg) = app.cmt.submission_result.unwrap();
    assert!(!success);
    assert!(msg.contains("Search failed"));
}

#[test]
fn test_symbol_search_clears_submission_result_on_new_search() {
    let mut app = App::new_for_test();

    // Set an old submission_result
    app.cmt.submission_result = Some((true, "old result".to_string()));
    app.cmt.submission_result_time = Some(std::time::Instant::now());

    // Simulate starting a new search (same as what jump_to_symbol_definition_async does)
    let (_tx, rx) = tokio::sync::mpsc::channel(1);
    app.cmt.submission_result = None;
    app.cmt.submission_result_time = None;
    app.symbol_search = SymbolSearchState::Searching {
        receiver: rx,
        origin_file_index: 0,
    };

    assert!(
        app.cmt.submission_result.is_none(),
        "submission_result should be cleared when a new search starts"
    );
    assert!(app.cmt.submission_result_time.is_none());
    assert!(app.symbol_search.is_searching());
}

#[test]
fn test_symbol_search_is_searching_visible_when_no_submission_result() {
    let mut app = App::new_for_test();

    // Old submission_result cleared, new search started
    app.cmt.submission_result = None;
    app.cmt.submission_result_time = None;

    let (_tx, rx) = tokio::sync::mpsc::channel(1);
    app.symbol_search = SymbolSearchState::Searching {
        receiver: rx,
        origin_file_index: 0,
    };

    // submission_result is None so footer priority won't suppress the search indicator
    assert!(app.cmt.submission_result.is_none());
    assert!(app.symbol_search.is_searching());
}

// =================================================================
// Git Ops プリフェッチ統合テスト
// =================================================================

/// poll_git_ops_updates で status_updated 後にプリフェッチが開始されることを検証。
/// プリフェッチ未実装のデグレを防止する回帰テスト。
#[tokio::test]
async fn test_poll_git_ops_starts_prefetch_after_status_update() {
    let mut app = App::new_for_test();
    app.set_local_mode(true);

    // DataState::Loaded を設定（start_git_ops_prefetch が files() を参照）
    app.data_state = DataState::Loaded {
        pr: Box::new(make_local_pr()),
        files: vec![
            ChangedFile {
                filename: "a.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+a".to_string()),
                viewed: false,
            },
            ChangedFile {
                filename: "b.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 0,
                patch: Some("@@ -1 +1 @@\n+b".to_string()),
                viewed: false,
            },
        ],
    };

    // GitOpsState をセットアップ（status_receiver に結果を送信済み）
    let entries = vec![
        GitStatusEntry {
            path: "a.rs".to_string(),
            index_status: FileStatus::Unmodified,
            worktree_status: FileStatus::Modified,
            additions: 1,
            deletions: 0,
            staged_additions: 0,
            staged_deletions: 0,
            orig_path: None,
            unmerged: false,
        },
        GitStatusEntry {
            path: "b.rs".to_string(),
            index_status: FileStatus::Unmodified,
            worktree_status: FileStatus::Modified,
            additions: 1,
            deletions: 0,
            staged_additions: 0,
            staged_deletions: 0,
            orig_path: None,
            unmerged: false,
        },
    ];
    let (tx, rx) = tokio::sync::mpsc::channel(1);
    tx.send(Ok(entries)).await.unwrap();

    let mut ops = GitOpsState::new(Vec::new());
    ops.status_receiver = Some(rx);
    app.git_ops_state = Some(ops);

    // poll_git_ops_updates を呼ぶ → status_updated → prefetch 開始
    app.poll_git_ops_updates();

    // プリフェッチレシーバーが設定されていること（= start_git_ops_prefetch が呼ばれた証拠）
    let ops = app.git_ops_state.as_ref().unwrap();
    assert!(
        ops.diff_store.has_prefetch_rx(),
        "prefetch should be started after status update (regression: start_git_ops_prefetch not called)"
    );
}

// ============================================================
// Step 1: LeftPaneFocus / CommitLogState / GitOpsState 拡張
// ============================================================

#[test]
fn test_left_pane_focus_default_is_tree() {
    use crate::app::types::LeftPaneFocus;
    assert_eq!(LeftPaneFocus::default(), LeftPaneFocus::Tree);
}

#[test]
fn test_commit_log_state_new_has_correct_defaults() {
    use crate::app::types::CommitLogState;
    let state = CommitLogState::new();
    assert!(state.commits.is_empty());
    assert_eq!(state.selected, 0);
    assert_eq!(state.scroll_offset, 0);
    assert!(!state.diff_loading);
    assert!(!state.loading);
    assert!(!state.has_more);
    assert_eq!(state.page, 0);
    assert!(state.error.is_none());
    assert!(state.diff_error.is_none());
    assert!(state.pending_diff_sha.is_none());
    assert!(!state.initialized);
}

#[test]
fn test_git_ops_state_has_left_focus_and_commit_log() {
    use crate::app::types::LeftPaneFocus;
    let ops = GitOpsState::new(Vec::new());
    assert_eq!(ops.left_focus, LeftPaneFocus::Tree);
    assert_eq!(ops.left_return_focus, LeftPaneFocus::Tree);
    assert!(ops.commit_log.commits.is_empty());
}

// ============================================================
// KeySequence alt (代替キー) テスト
// ============================================================

#[test]
fn test_matches_single_key_alt() {
    use crate::keybinding::KeyBinding;
    let app = App::new_for_test();
    let seq = crate::keybinding::KeySequence::single(KeyBinding::char('j'))
        .with_alt(vec![KeyBinding::named(crate::keybinding::NamedKey::Down)]);

    // Primary matches
    assert!(app.matches_single_key(&make_key(KeyCode::Char('j')), &seq));
    // Alt matches
    assert!(app.matches_single_key(&make_key(KeyCode::Down), &seq));
    // Unrelated does not match
    assert!(!app.matches_single_key(&make_key(KeyCode::Char('k')), &seq));
}

#[test]
fn test_matches_single_key_no_alt_no_arrow() {
    use crate::keybinding::KeyBinding;
    let app = App::new_for_test();
    // User configured move_down = "j" without alt
    let seq = crate::keybinding::KeySequence::single(KeyBinding::char('j'));

    assert!(app.matches_single_key(&make_key(KeyCode::Char('j')), &seq));
    // Arrow key does NOT match when no alt is set
    assert!(!app.matches_single_key(&make_key(KeyCode::Down), &seq));
}

#[test]
fn test_default_move_down_matches_arrow() {
    let app = App::new_for_test();
    let kb = &app.config.keybindings;

    // Default move_down = "j/Down"
    assert!(app.matches_single_key(&make_key(KeyCode::Char('j')), &kb.move_down));
    assert!(app.matches_single_key(&make_key(KeyCode::Down), &kb.move_down));
}

#[test]
fn test_default_quit_matches_esc() {
    let app = App::new_for_test();
    let kb = &app.config.keybindings;

    // Default quit = "q/Esc"
    assert!(app.matches_single_key(&make_key(KeyCode::Char('q')), &kb.quit));
    assert!(app.matches_single_key(&make_key(KeyCode::Esc), &kb.quit));
}

#[test]
fn test_key_sequence_alt_deserialize_roundtrip() {
    use crate::keybinding::KeySequence;
    // "j/Down" → primary j, alt Down
    let toml_str = r#"key = "j/Down""#;
    #[derive(serde::Deserialize)]
    struct Test {
        key: KeySequence,
    }
    let test: Test = toml::from_str(toml_str).unwrap();
    assert_eq!(test.key.keys.len(), 1);
    assert_eq!(test.key.alt.len(), 1);
    assert_eq!(test.key.display(), "j/Down");
}

#[test]
fn test_key_sequence_no_alt_deserialize() {
    use crate::keybinding::KeySequence;
    // "j" → primary j, no alt
    let toml_str = r#"key = "j""#;
    #[derive(serde::Deserialize)]
    struct Test {
        key: KeySequence,
    }
    let test: Test = toml::from_str(toml_str).unwrap();
    assert_eq!(test.key.keys.len(), 1);
    assert!(test.key.alt.is_empty());
    assert_eq!(test.key.display(), "j");
}

#[test]
fn test_try_match_sequence_alt() {
    use crate::keybinding::{KeyBinding, KeySequence, SequenceMatch};

    let mut app = App::new_for_test();
    // gg with alt Home
    let seq = KeySequence::double(KeyBinding::char('g'), KeyBinding::char('g'))
        .with_alt(vec![KeyBinding::named(crate::keybinding::NamedKey::Home)]);

    // Alt single key: Home → Full match (alt is single key)
    app.push_pending_key(KeyBinding::named(crate::keybinding::NamedKey::Home));
    assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Full);
    app.clear_pending_keys();

    // Primary sequence: g → Partial
    app.push_pending_key(KeyBinding::char('g'));
    assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Partial);

    // Primary sequence: gg → Full
    app.push_pending_key(KeyBinding::char('g'));
    assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Full);
}

// ============================================================
// Step 2: open_git_ops にコミット取得が統合されていること
// ============================================================

#[tokio::test]
async fn test_open_git_ops_starts_commit_loading() {
    let mut app = App::new_for_test();
    app.set_local_mode(true);
    app.open_git_ops();
    let ops = app.git_ops_state.as_ref().unwrap();
    assert!(
        ops.commit_log.loading,
        "open_git_ops should start commit loading"
    );
    assert!(
        ops.commit_log.list_receiver.is_some(),
        "open_git_ops should set commit list receiver"
    );
}

#[tokio::test]
async fn test_poll_git_ops_receives_commit_list() {
    use crate::github::CommitListPage;
    let mut app = App::new_for_test();
    app.set_local_mode(true);

    // GitOpsState をセットアップ
    let mut ops = GitOpsState::new(Vec::new());
    let (tx, rx) = mpsc::channel(1);
    ops.commit_log.list_receiver = Some(rx);
    ops.commit_log.loading = true;
    app.git_ops_state = Some(ops);
    app.state = AppState::GitOpsSplitTree;

    // コミット一覧を送信
    let page = CommitListPage {
        items: vec![PrCommit {
            sha: "abc123".to_string(),
            message: "test commit".to_string(),
            author_name: "test".to_string(),
            author_login: None,
            date: "2025-01-01T00:00:00Z".to_string(),
        }],
        has_more: false,
    };
    tx.send(Ok(page)).await.unwrap();

    app.poll_git_ops_updates();

    let ops = app.git_ops_state.as_ref().unwrap();
    assert_eq!(ops.commit_log.commits.len(), 1);
    assert_eq!(ops.commit_log.commits[0].sha, "abc123");
    assert!(!ops.commit_log.loading);
}

// ============================================================
// Step 3: フォーカス遷移
// ============================================================

#[tokio::test]
async fn test_tab_in_tree_focus_switches_to_commits() {
    use crate::app::types::LeftPaneFocus;
    let mut app = App::new_for_test();
    app.open_git_ops();

    // Tab で Commits へ
    app.toggle_git_ops_left_focus();
    let ops = app.git_ops_state.as_ref().unwrap();
    assert_eq!(ops.left_focus, LeftPaneFocus::Commits);
}

#[tokio::test]
async fn test_tab_in_commits_focus_switches_to_tree() {
    use crate::app::types::LeftPaneFocus;
    let mut app = App::new_for_test();
    app.open_git_ops();
    if let Some(ref mut ops) = app.git_ops_state {
        ops.left_focus = LeftPaneFocus::Commits;
    }

    app.toggle_git_ops_left_focus();
    let ops = app.git_ops_state.as_ref().unwrap();
    assert_eq!(ops.left_focus, LeftPaneFocus::Tree);
}

#[tokio::test]
async fn test_diff_returns_to_left_return_focus() {
    use crate::app::types::LeftPaneFocus;
    let mut app = App::new_for_test();
    app.open_git_ops();
    if let Some(ref mut ops) = app.git_ops_state {
        ops.left_return_focus = LeftPaneFocus::Commits;
    }
    app.state = AppState::GitOpsSplitDiff;

    // Esc で left_return_focus へ戻る
    app.return_from_git_ops_diff();
    assert_eq!(app.state, AppState::GitOpsSplitTree);
    let ops = app.git_ops_state.as_ref().unwrap();
    assert_eq!(ops.left_focus, LeftPaneFocus::Commits);
}

// ============================================================
// FileTree integration tests (Step 4)
// ============================================================

fn make_test_pr() -> Box<PullRequest> {
    Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "f".to_string(),
            sha: "a".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "m".to_string(),
            sha: "b".to_string(),
        },
        user: crate::github::User {
            login: "u".to_string(),
        },
        updated_at: String::new(),
    })
}

fn make_changed_file(name: &str) -> ChangedFile {
    ChangedFile {
        filename: name.to_string(),
        status: "modified".to_string(),
        additions: 1,
        deletions: 0,
        patch: Some("@@ -1 +1 @@\n-old\n+new".to_string()),
        viewed: false,
    }
}

fn make_app_with_files(filenames: &[&str]) -> App {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    let files: Vec<ChangedFile> = filenames.iter().map(|n| make_changed_file(n)).collect();
    app.data_state = DataState::Loaded {
        pr: make_test_pr(),
        files,
    };
    app.state = AppState::FileList;
    app
}

#[test]
fn test_toggle_file_tree_on() {
    let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs", "README.md"]);

    assert!(!app.tree_mode_active);
    assert!(app.file_tree_state.is_none());

    app.toggle_file_tree();

    assert!(app.tree_mode_active);
    assert!(app.file_tree_state.is_some());
    assert!(app.is_file_tree_active());

    // ツリーが構築されていること
    let tree = app.file_tree_state.as_ref().unwrap();
    assert!(tree.row_count() > 0);
}

#[test]
fn test_toggle_file_tree_off_preserves_state() {
    let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs"]);

    app.toggle_file_tree(); // ON
    assert!(app.tree_mode_active);

    // ディレクトリを折り畳む
    {
        let tree = app.file_tree_state.as_mut().unwrap();
        let app_dir = tree.find_row_for_dir("src/app").unwrap();
        tree.selected_row = app_dir;
        tree.toggle_expand();
    }

    app.toggle_file_tree(); // OFF

    // tree_mode_active は false だが file_tree_state は保持される
    assert!(!app.tree_mode_active);
    assert!(app.file_tree_state.is_some());
    assert!(!app.is_file_tree_active());

    // 折り畳み状態が保持されている
    let tree = app.file_tree_state.as_ref().unwrap();
    assert!(!tree.expanded_dirs.contains("src/app"));
}

#[test]
fn test_toggle_preserves_selection() {
    let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs", "README.md"]);
    app.selected_file = 1; // src/lib.rs

    app.toggle_file_tree(); // ON

    // ツリーモードでも selected_file は変わらない
    assert_eq!(app.selected_file, 1);

    // ツリーのカーソルが selected_file=1 の行を指していること
    let tree = app.file_tree_state.as_ref().unwrap();
    let row = tree.find_row_for_file(1);
    assert!(row.is_some());
    assert_eq!(tree.selected_row, row.unwrap());
}

#[test]
fn test_retoggle_restores_expanded_dirs() {
    let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs"]);

    app.toggle_file_tree(); // ON

    // src/app/ を折り畳む
    {
        let tree = app.file_tree_state.as_mut().unwrap();
        let app_dir = tree.find_row_for_dir("src/app").unwrap();
        tree.selected_row = app_dir;
        tree.toggle_expand();
    }

    app.toggle_file_tree(); // OFF
    app.toggle_file_tree(); // ON again

    // expanded_dirs が維持されている（src/app は折り畳みのまま）
    let tree = app.file_tree_state.as_ref().unwrap();
    assert!(!tree.expanded_dirs.contains("src/app"));
}

#[test]
fn test_tree_nav_down_updates_selected_file() {
    let mut app = make_app_with_files(&["src/main.rs", "README.md"]);

    app.toggle_file_tree();

    // toggle 後、selected_file=0 なので tree の row 1 (File(0) main.rs) にカーソルがある。
    // ツリー構造 (DirsFirst):
    //   row 0: Dir "src/"
    //   row 1: File(0) "main.rs"  ← カーソルここ
    //   row 2: File(1) "README.md"
    assert_eq!(app.selected_file, 0);
    let tree = app.file_tree_state.as_ref().unwrap();
    assert_eq!(tree.selected_row, 1);

    app.file_tree_move_down(); // row 1 → row 2 (File README.md)
    assert_eq!(app.selected_file, 1);

    app.file_tree_move_down(); // 境界: row 2 のまま
    assert_eq!(app.selected_file, 1);
}

#[test]
fn test_tree_nav_on_dir_keeps_selected_file() {
    let mut app = make_app_with_files(&["src/main.rs", "tests/test.rs", "README.md"]);
    app.selected_file = 2; // README.md

    app.toggle_file_tree();

    // row 0 は Dir 行のはず (DirsFirst: src/ が先)
    let tree = app.file_tree_state.as_ref().unwrap();
    assert!(tree.selected_dir_path().is_some() || tree.selected_file_index().is_some());

    // Dir 行に移動しても selected_file は変わらない
    let old_selected = app.selected_file;
    // Dir 行にカーソルを強制配置
    if let Some(dir_row) = app
        .file_tree_state
        .as_ref()
        .unwrap()
        .find_row_for_dir("src")
    {
        app.file_tree_state.as_mut().unwrap().selected_row = dir_row;
    }
    // file_tree_move_down で Dir 行を通過
    app.file_tree_move_down();
    // Dir→File に移動したら selected_file が更新される
    // Dir→Dir に留まったら selected_file は不変
    let tree = app.file_tree_state.as_ref().unwrap();
    if tree.selected_file_index().is_none() {
        // Dir 行にいる場合、selected_file は変わらない
        assert_eq!(app.selected_file, old_selected);
    }
}

#[test]
fn test_tree_enter_dir_toggles_expand() {
    let mut app = make_app_with_files(&["src/main.rs", "README.md"]);

    app.toggle_file_tree();

    // src/ ディレクトリ行にカーソルを合わせる
    let tree = app.file_tree_state.as_mut().unwrap();
    let src_row = tree.find_row_for_dir("src").unwrap();
    tree.selected_row = src_row;

    let initial_count = tree.row_count();

    // Enter: Dir の展開トグル（diff 遷移しない）
    let is_dir = app.file_tree_enter();
    assert!(is_dir); // Dir 行だったので true を返す

    // 折り畳まれて行数が減る
    let tree = app.file_tree_state.as_ref().unwrap();
    assert!(tree.row_count() < initial_count);

    // state は変わらない（diff 遷移しない）
    assert_eq!(app.state, AppState::FileList);
}

#[test]
fn test_tree_enter_file_opens_split() {
    let mut app = make_app_with_files(&["src/main.rs", "README.md"]);

    app.toggle_file_tree();

    // README.md (File 行) にカーソルを合わせる
    let tree = app.file_tree_state.as_mut().unwrap();
    let readme_row = tree.find_row_for_file(1).unwrap();
    tree.selected_row = readme_row;

    // Enter: File 行では false を返す（呼び出し元が diff 遷移を行う）
    let is_dir = app.file_tree_enter();
    assert!(!is_dir);
}

#[test]
fn test_tree_dir_row_blocks_mark_viewed() {
    let mut app = make_app_with_files(&["src/main.rs", "README.md"]);

    app.toggle_file_tree();

    // Dir 行にカーソル
    let tree = app.file_tree_state.as_mut().unwrap();
    let src_row = tree.find_row_for_dir("src").unwrap();
    tree.selected_row = src_row;

    // is_on_dir_row() が true ならば mark_viewed (v) を無効化すべき
    assert!(app.is_file_tree_on_dir_row());
}

#[test]
fn test_tree_filter_shows_flat() {
    let mut app = make_app_with_files(&["src/main.rs", "README.md"]);

    app.toggle_file_tree();
    assert!(app.is_file_tree_active());

    // フィルタを設定
    app.file_list_filter = Some(crate::filter::ListFilter::new());

    // フィルタ有効中は is_file_tree_active() == false
    assert!(!app.is_file_tree_active());
}

#[test]
fn test_tree_survives_filter_clear() {
    let mut app = make_app_with_files(&["src/main.rs", "README.md"]);

    app.toggle_file_tree();
    let _tree_row_count = app.file_tree_state.as_ref().unwrap().row_count();

    // フィルタを設定→解除
    app.file_list_filter = Some(crate::filter::ListFilter::new());
    assert!(!app.is_file_tree_active());

    app.file_list_filter = None;
    // フィルタ解除後、rebuild_file_tree_if_active でツリー復元
    app.rebuild_file_tree_if_active();

    assert!(app.is_file_tree_active());
    // ツリーが再構築されている
    let tree = app.file_tree_state.as_ref().unwrap();
    assert!(tree.row_count() > 0);
}

#[test]
fn test_selected_file_always_valid_index() {
    let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs", "README.md"]);

    app.toggle_file_tree();

    // 様々なナビゲーション操作後も selected_file は有効
    for _ in 0..10 {
        app.file_tree_move_down();
        assert!(
            app.selected_file < app.files().len(),
            "selected_file {} >= files().len() {}",
            app.selected_file,
            app.files().len()
        );
    }
    for _ in 0..10 {
        app.file_tree_move_up();
        assert!(
            app.selected_file < app.files().len(),
            "selected_file {} >= files().len() {}",
            app.selected_file,
            app.files().len()
        );
    }
}

#[test]
fn test_rebuild_on_data_reload() {
    let mut app = make_app_with_files(&["src/main.rs", "README.md"]);

    app.toggle_file_tree();

    let old_count = app.file_tree_state.as_ref().unwrap().row_count();

    // データリロード: ファイルが増える
    let files = vec![
        make_changed_file("src/main.rs"),
        make_changed_file("src/lib.rs"),
        make_changed_file("README.md"),
    ];
    app.data_state = DataState::Loaded {
        pr: make_test_pr(),
        files,
    };

    app.rebuild_file_tree_if_active();

    // ツリーが再構築されている（行数が変わる）
    let new_count = app.file_tree_state.as_ref().unwrap().row_count();
    assert_ne!(old_count, new_count, "tree should be rebuilt with new data");
}

#[test]
fn test_tree_page_down_up() {
    let mut app = make_app_with_files(&[
        "src/a.rs",
        "src/b.rs",
        "src/c.rs",
        "src/d.rs",
        "src/e.rs",
        "README.md",
    ]);

    app.toggle_file_tree();

    // page_down で大きく移動
    app.file_tree_page_down(3);
    let tree = app.file_tree_state.as_ref().unwrap();
    assert!(tree.selected_row >= 3);

    // page_up で戻る
    app.file_tree_page_up(3);
    let tree = app.file_tree_state.as_ref().unwrap();
    assert!(tree.selected_row <= 1); // 0 or near start
}

#[test]
fn test_tree_jump_to_first_last() {
    let mut app = make_app_with_files(&["src/a.rs", "src/b.rs", "README.md"]);

    app.toggle_file_tree();

    // jump to last
    app.file_tree_jump_to_last();
    let tree = app.file_tree_state.as_ref().unwrap();
    assert_eq!(tree.selected_row, tree.row_count() - 1);

    // jump to first
    app.file_tree_jump_to_first();
    let tree = app.file_tree_state.as_ref().unwrap();
    assert_eq!(tree.selected_row, 0);
}

#[test]
fn test_collect_unviewed_paths_under_dir() {
    let files = vec![
        make_changed_file("src/app/mod.rs"),
        make_changed_file("src/app/types.rs"),
        make_changed_file("src/lib.rs"),
        make_changed_file("README.md"),
    ];

    let paths = App::collect_unviewed_paths_under_dir(&files, "src/app");
    assert_eq!(paths.len(), 2);
    assert!(paths.contains(&"src/app/mod.rs".to_string()));
    assert!(paths.contains(&"src/app/types.rs".to_string()));

    // src/ 全体
    let paths = App::collect_unviewed_paths_under_dir(&files, "src");
    assert_eq!(paths.len(), 3);

    // ルートだと空（ルートディレクトリは "/" にならないため）
    let paths = App::collect_unviewed_paths_under_dir(&files, "");
    assert_eq!(paths.len(), 0);
}

#[test]
fn test_deep_nested_collapse_hides_descendants() {
    // 3階層以上のネストで祖先を折り畳むと、全子孫が非表示になること
    use crate::app::file_tree::FileTreeState;

    let mut tree = FileTreeState::new();
    tree.rebuild(&[(0, "a/b/c/file.rs"), (1, "a/b/other.rs"), (2, "a/top.rs")]);

    // 初回: 全ディレクトリ展開、全ファイル表示
    assert!(
        tree.find_row_for_file(0).is_some(),
        "file.rs should be visible"
    );
    assert!(
        tree.find_row_for_file(1).is_some(),
        "other.rs should be visible"
    );
    assert!(
        tree.find_row_for_file(2).is_some(),
        "top.rs should be visible"
    );

    // "a" を折り畳む → 全子孫が非表示
    let a_row = tree.find_row_for_dir("a").unwrap();
    tree.selected_row = a_row;
    tree.toggle_expand();

    // "a" 行のみ残る
    assert_eq!(
        tree.row_count(),
        1,
        "only 'a' dir should remain, dump:\n{}",
        tree.dump_tree()
    );
    assert!(
        tree.find_row_for_dir("a/b").is_none(),
        "a/b should be hidden"
    );
    assert!(
        tree.find_row_for_dir("a/b/c").is_none(),
        "a/b/c should be hidden"
    );
    assert!(
        tree.find_row_for_file(0).is_none(),
        "file.rs should be hidden"
    );
    assert!(
        tree.find_row_for_file(1).is_none(),
        "other.rs should be hidden"
    );
    assert!(
        tree.find_row_for_file(2).is_none(),
        "top.rs should be hidden"
    );

    // "a" を再展開 → "a/b" は expanded のまま、"a/b/c" も
    tree.toggle_expand();
    assert!(
        tree.find_row_for_file(0).is_some(),
        "file.rs should be visible again"
    );
    assert!(
        tree.find_row_for_file(1).is_some(),
        "other.rs should be visible again"
    );
    assert!(
        tree.find_row_for_file(2).is_some(),
        "top.rs should be visible again"
    );
}

#[tokio::test]
async fn test_select_pr_resets_tree_state() {
    let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
    app.started_from_pr_list = true;

    app.toggle_file_tree();
    assert!(app.tree_mode_active);
    assert!(app.file_tree_state.is_some());

    app.select_pr(2);

    assert!(!app.tree_mode_active);
    assert!(app.file_tree_state.is_none());
}

#[test]
fn test_toggle_file_tree_with_empty_files() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    // DataState::Loading — files() returns empty
    app.state = AppState::FileList;

    app.toggle_file_tree();

    // 空ファイルの場合、tree_mode_active は false のまま
    assert!(!app.tree_mode_active);
    assert!(app.file_tree_state.is_none());
}

// ========== Zen Mode Tests ==========

/// レンダリングバッファから先頭 N 行を抽出してスナップショット用文字列にする
fn render_top_lines(app: &mut App, height: u16, n: usize) -> String {
    use ratatui::backend::TestBackend;
    use ratatui::Terminal;

    let width = 80;
    let backend = TestBackend::new(width, height);
    let mut terminal = Terminal::new(backend).unwrap();
    terminal
        .draw(|frame| {
            crate::ui::render(frame, app);
        })
        .unwrap();
    let buf = terminal.backend().buffer();
    (0..n.min(height as usize))
        .map(|y| {
            let mut line = String::new();
            for x in 0..width {
                let cell = &buf[(x, y as u16)];
                line.push_str(cell.symbol());
            }
            line.trim_end().to_string()
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn make_loaded_app() -> App {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Add zen mode".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feat-zen".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });
    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: vec![ChangedFile {
                filename: "src/app.rs".to_string(),
                status: "modified".to_string(),
                additions: 3,
                deletions: 1,
                patch: Some(
                    "@@ -1,3 +1,5 @@\n context\n-old line\n+new line\n+added\n+more".to_string(),
                ),
                viewed: false,
            }],
        },
    );
    app
}

#[tokio::test]
async fn test_zen_mode_navigation_scenario() {
    use insta::assert_snapshot;

    let mut app = make_loaded_app();
    app.state = AppState::FileList;

    // Step 1: FileList (zen OFF)
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌octorus───────────────────────────────────────────────────────────────────────┐
    │PR #1: Add zen mode by @user                                                  │
    └──────────────────────────────────────────────────────────────────────────────┘
    ┌Changed Files (1)─────────────────────────────────────────────────────────────┐
    │[M]   src/app.rs +3 -1                                                        │
    │                                                                              │
    "#);

    // Step 2: Toggle zen ON → still FileList
    app.toggle_zen_mode();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌octorus───────────────────────────────────────────────────────────────────────┐
    │PR #1: Add zen mode by @user                                                  │
    └──────────────────────────────────────────────────────────────────────────────┘
    ┌Changed Files (1)─────────────────────────────────────────────────────────────┐
    │[M]   src/app.rs +3 -1                                                        │
    │                                                                              │
    "#);

    // Step 3: Enter → DiffView (fullscreen, NOT split)
    app.enter_diff_from_file_list();
    app.sync_diff_to_selected_file();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌Diff──────────────────────────────────────────────────────────────────────────┐
    │src/app.rs (+3 -1)                                                            │
    └──────────────────────────────────────────────────────────────────────────────┘
    ┌──────────────────────────────────────────────────────────────────────────────┐
    │@@ -1,3 +1,5 @@                                                               │
    │ context                                                                      │
    "#);

    // Step 4: Quit → back to FileList
    app.handle_fullscreen_diff_quit();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌octorus───────────────────────────────────────────────────────────────────────┐
    │PR #1: Add zen mode by @user                                                  │
    └──────────────────────────────────────────────────────────────────────────────┘
    ┌Changed Files (1)─────────────────────────────────────────────────────────────┐
    │[M]   src/app.rs +3 -1                                                        │
    │                                                                              │
    "#);

    // Step 5: Toggle zen OFF → Enter → SplitView
    app.toggle_zen_mode();
    app.enter_diff_from_file_list();
    app.sync_diff_to_selected_file();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌octorus───────────────────┐┌Diff Preview──────────────────────────────────────┐
    │PR #1: Add zen mode       ││src/app.rs (+3 -1)                                │
    └──────────────────────────┘└──────────────────────────────────────────────────┘
    ┌Files (1)─────────────────┐┌──────────────────────────────────────────────────┐
    │[M]   src/app.rs +3 -1    ││@@ -1,3 +1,5 @@                                   │
    │                          ││ context                                          │
    "#);
}

#[tokio::test]
async fn test_zen_mode_pr_list_origin_quit_scenario() {
    use insta::assert_snapshot;

    let mut app = make_loaded_app();
    app.started_from_pr_list = true;

    // zen ON: FileList → DiffView → quit → FileList (not PR list)
    app.zen_mode = true;
    app.state = AppState::FileList;
    app.enter_diff_from_file_list();
    app.sync_diff_to_selected_file();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌Diff──────────────────────────────────────────────────────────────────────────┐
    │src/app.rs (+3 -1)                                                            │
    └──────────────────────────────────────────────────────────────────────────────┘
    ┌──────────────────────────────────────────────────────────────────────────────┐
    │@@ -1,3 +1,5 @@                                                               │
    │ context                                                                      │
    "#);

    app.handle_fullscreen_diff_quit();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌octorus───────────────────────────────────────────────────────────────────────┐
    │PR #1: Add zen mode by @user                                                  │
    └──────────────────────────────────────────────────────────────────────────────┘
    ┌Changed Files (1)─────────────────────────────────────────────────────────────┐
    │[M]   src/app.rs +3 -1                                                        │
    │                                                                              │
    "#);

    // zen OFF: same setup → quit → PullRequestList
    app.zen_mode = false;
    app.state = AppState::DiffView;
    app.diff_view_return_state = AppState::FileList;
    app.handle_fullscreen_diff_quit();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌octorus───────────────────────────────────────────────────────────────────────┐
    │PR List: owner/repo (open)                                                    │
    └──────────────────────────────────────────────────────────────────────────────┘
    ┌Pull Requests─────────────────────────────────────────────────────────────────┐
    │Failed to load pull requests                                                  │
    │                                                                              │
    "#);
}

#[tokio::test]
async fn test_zen_mode_split_view_preservation_scenario() {
    use insta::assert_snapshot;

    let mut app = make_loaded_app();
    app.zen_mode = true;

    // SplitViewFileList → enter → SplitViewDiff (zen mode does NOT force fullscreen)
    app.state = AppState::SplitViewFileList;
    app.enter_diff_from_file_list();
    app.sync_diff_to_selected_file();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌octorus───────────────────┐┌Diff Preview──────────────────────────────────────┐
    │PR #1: Add zen mode       ││src/app.rs (+3 -1)                                │
    └──────────────────────────┘└──────────────────────────────────────────────────┘
    ┌Files (1)─────────────────┐┌──────────────────────────────────────────────────┐
    │[M]   src/app.rs +3 -1    ││@@ -1,3 +1,5 @@                                   │
    │                          ││ context                                          │
    "#);

    // Same with zen OFF
    app.zen_mode = false;
    app.state = AppState::SplitViewFileList;
    app.enter_diff_from_file_list();
    app.sync_diff_to_selected_file();
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌octorus───────────────────┐┌Diff Preview──────────────────────────────────────┐
    │PR #1: Add zen mode       ││src/app.rs (+3 -1)                                │
    └──────────────────────────┘└──────────────────────────────────────────────────┘
    ┌Files (1)─────────────────┐┌──────────────────────────────────────────────────┐
    │[M]   src/app.rs +3 -1    ││@@ -1,3 +1,5 @@                                   │
    │                          ││ context                                          │
    "#);
}

#[tokio::test]
async fn test_zen_mode_auto_focus_transitions_to_diff_view() {
    use insta::assert_snapshot;

    let mut config = Config::default();
    config.layout.zen_mode = true;
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.set_local_mode(true);
    app.set_local_auto_focus(true);
    app.state = AppState::FileList;

    let pr = Box::new(PullRequest {
        number: 1,
        node_id: None,
        title: "Test PR".to_string(),
        body: None,
        state: "open".to_string(),
        head: crate::github::Branch {
            ref_name: "feature".to_string(),
            sha: "abc123".to_string(),
        },
        base: crate::github::Branch {
            ref_name: "main".to_string(),
            sha: "def456".to_string(),
        },
        user: crate::github::User {
            login: "user".to_string(),
        },
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    });

    app.handle_data_result(
        1,
        DataLoadResult::Success {
            pr,
            files: vec![ChangedFile {
                filename: "initial.rs".to_string(),
                status: "modified".to_string(),
                additions: 1,
                deletions: 1,
                patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
                viewed: false,
            }],
        },
    );

    // auto_focus + zen → fullscreen DiffView
    assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
    ┌Diff──────────────────────────────────────────────────────────────────────────┐
    │initial.rs (+1 -1)                                                            │
    └──────────────────────────────────────────────────────────────────────────────┘
    ┌──────────────────────────────────────────────────────────────────────────────┐
    │@@ -1,1 +1,1 @@                                                               │
    │-old                                                                          │
    "#);
}

// =============================================================================
// LoadState<T> tests
// =============================================================================

#[test]
fn load_state_default_is_not_loaded() {
    let state: super::types::LoadState<Vec<i32>> = Default::default();
    assert!(matches!(state, super::types::LoadState::NotLoaded));
    assert!(!state.is_loading());
    assert!(!state.is_loaded());
    assert!(state.as_loaded().is_none());
}

#[test]
fn load_state_loading_transitions() {
    let state: super::types::LoadState<Vec<i32>> = super::types::LoadState::Loading;
    assert!(state.is_loading());
    assert!(!state.is_loaded());
    assert!(state.as_loaded().is_none());
}

#[test]
fn load_state_loaded_accessors() {
    let state = super::types::LoadState::Loaded(vec![1, 2, 3]);
    assert!(!state.is_loading());
    assert!(state.is_loaded());
    assert_eq!(state.as_loaded(), Some(&vec![1, 2, 3]));
}

#[test]
fn load_state_loading_more_preserves_data() {
    let state = super::types::LoadState::LoadingMore(vec![1, 2]);
    assert!(state.is_loading());
    assert!(!state.is_loaded());
    assert_eq!(state.as_loaded(), Some(&vec![1, 2]));
}

#[test]
fn load_state_error_accessors() {
    let state: super::types::LoadState<Vec<i32>> =
        super::types::LoadState::Error("fail".to_string());
    assert!(!state.is_loading());
    assert!(!state.is_loaded());
    assert!(state.as_loaded().is_none());
}

#[test]
fn load_state_as_loaded_mut() {
    let mut state = super::types::LoadState::Loaded(vec![1]);
    if let Some(data) = state.as_loaded_mut() {
        data.push(2);
    }
    assert_eq!(state.as_loaded(), Some(&vec![1, 2]));
}

#[test]
fn load_state_into_loaded() {
    let state = super::types::LoadState::Loaded(vec![42]);
    assert_eq!(state.into_loaded(), Some(vec![42]));

    let state = super::types::LoadState::LoadingMore(vec![99]);
    assert_eq!(state.into_loaded(), Some(vec![99]));

    let state: super::types::LoadState<Vec<i32>> = super::types::LoadState::Loading;
    assert_eq!(state.into_loaded(), None);
}

#[test]
fn load_state_recover_or_preserves_loading_more() {
    let mut state = super::types::LoadState::LoadingMore(vec![1, 2, 3]);
    state.recover_or(vec![]);
    assert_eq!(state.as_loaded(), Some(&vec![1, 2, 3]));
    assert!(state.is_loaded());
}

#[test]
fn load_state_recover_or_preserves_loaded() {
    let mut state = super::types::LoadState::Loaded(vec![4, 5]);
    state.recover_or(vec![]);
    assert_eq!(state.as_loaded(), Some(&vec![4, 5]));
}

#[test]
fn load_state_recover_or_uses_fallback_for_loading() {
    let mut state: super::types::LoadState<Vec<i32>> = super::types::LoadState::Loading;
    state.recover_or(vec![]);
    assert_eq!(state.as_loaded(), Some(&vec![]));
    assert!(state.is_loaded());
}

#[tokio::test]
async fn test_poll_pr_list_error_recovers_from_loading_more() {
    use tokio::sync::mpsc;

    let mut app = App::new_for_test();
    let existing = vec![crate::github::PullRequestSummary {
        number: 1,
        title: "existing".to_string(),
        state: "OPEN".to_string(),
        author: crate::github::User {
            login: "user".to_string(),
        },
        is_draft: false,
        labels: vec![],
        updated_at: "".to_string(),
        status_check_rollup: vec![],
    }];
    app.prs.pr_list = LoadState::LoadingMore(existing);

    let (tx, rx) = mpsc::channel(1);
    app.prs.pr_list_receiver = Some(rx);
    tx.send(Err("network error".to_string())).await.unwrap();

    app.poll_pr_list_updates();

    assert!(app.prs.pr_list.is_loaded());
    assert!(!app.prs.pr_list.is_loading());
    let items = app.prs.pr_list.as_loaded().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0].number, 1);
}

#[tokio::test]
async fn test_poll_pr_list_disconnect_recovers_from_loading_more() {
    use tokio::sync::mpsc;

    let mut app = App::new_for_test();
    let existing = vec![crate::github::PullRequestSummary {
        number: 2,
        title: "existing".to_string(),
        state: "OPEN".to_string(),
        author: crate::github::User {
            login: "user".to_string(),
        },
        is_draft: false,
        labels: vec![],
        updated_at: "".to_string(),
        status_check_rollup: vec![],
    }];
    app.prs.pr_list = LoadState::LoadingMore(existing);

    let (tx, rx) = mpsc::channel(1);
    app.prs.pr_list_receiver = Some(rx);
    drop(tx);

    app.poll_pr_list_updates();

    assert!(app.prs.pr_list.is_loaded());
    let items = app.prs.pr_list.as_loaded().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0].number, 2);
}

#[tokio::test]
async fn test_poll_issue_list_error_recovers_from_loading_more() {
    use tokio::sync::mpsc;

    let mut app = App::new_for_test();
    let existing = vec![crate::github::IssueSummary {
        number: 10,
        title: "existing issue".to_string(),
        state: "OPEN".to_string(),
        author: crate::github::User {
            login: "user".to_string(),
        },
        labels: vec![],
        updated_at: "".to_string(),
        comments: vec![],
    }];
    let mut state = super::types::IssueState::new();
    state.issues = LoadState::LoadingMore(existing);

    let (tx, rx) = mpsc::channel(1);
    state.issue_list_receiver = Some(rx);
    app.issue_state = Some(state);
    tx.send(Err("network error".to_string())).await.unwrap();

    app.poll_issue_list_updates();

    let issue_state = app.issue_state.as_ref().unwrap();
    assert!(issue_state.issues.is_loaded());
    assert!(!issue_state.issues.is_loading());
    let items = issue_state.issues.as_loaded().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0].number, 10);
}

#[tokio::test]
async fn test_poll_issue_list_disconnect_recovers_from_loading_more() {
    use tokio::sync::mpsc;

    let mut app = App::new_for_test();
    let existing = vec![crate::github::IssueSummary {
        number: 20,
        title: "existing issue".to_string(),
        state: "OPEN".to_string(),
        author: crate::github::User {
            login: "user".to_string(),
        },
        labels: vec![],
        updated_at: "".to_string(),
        comments: vec![],
    }];
    let mut state = super::types::IssueState::new();
    state.issues = LoadState::LoadingMore(existing);

    let (tx, rx) = mpsc::channel(1);
    state.issue_list_receiver = Some(rx);
    app.issue_state = Some(state);
    drop(tx);

    app.poll_issue_list_updates();

    let issue_state = app.issue_state.as_ref().unwrap();
    assert!(issue_state.issues.is_loaded());
    let items = issue_state.issues.as_loaded().unwrap();
    assert_eq!(items.len(), 1);
    assert_eq!(items[0].number, 20);
}

#[test]
fn test_try_open_comment_panel_in_local_mode() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("local", 0, config);
    app.local_mode = true;
    app.cmt.review_comments = Some(vec![ReviewComment {
        id: 1,
        path: "src/main.rs".to_string(),
        line: Some(10),
        start_line: None,
        body: "local note".to_string(),
        user: crate::github::User {
            login: "local".to_string(),
        },
        created_at: "2026-04-27T00:00:00Z".to_string(),
        in_reply_to_id: None,
    }]);

    let kb = app.config.keybindings.clone();
    let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);

    assert!(app.try_open_comment_panel(&key, &kb));
    assert!(app.cmt.comment_panel_open);
    assert_eq!(app.cmt.comment_panel_scroll, 0);
    assert_eq!(app.cmt.selected_inline_comment, 0);
}

#[test]
fn test_try_open_comment_panel_ignores_non_matching_key() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("local", 0, config);
    app.local_mode = true;

    let kb = app.config.keybindings.clone();
    let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);

    assert!(!app.try_open_comment_panel(&key, &kb));
    assert!(!app.cmt.comment_panel_open);
}

/// Re-applying a comment set with the same thread roots plus a new reply
/// must preserve the user's expanded-thread selection by comment ID, not by
/// positional index. This is the central correctness claim of the merged
/// per-file-counts + threading features: a background poll that returns
/// identical roots with new replies should not silently shift the user
/// to a different comment.
#[test]
fn test_apply_review_comments_preserves_expanded_selection_by_id() {
    fn comment(id: u64, parent: Option<u64>, created_at: &str) -> ReviewComment {
        ReviewComment {
            id,
            path: "src/main.rs".to_string(),
            line: Some(10),
            start_line: None,
            body: format!("comment {id}"),
            user: crate::github::User {
                login: "alice".to_string(),
            },
            created_at: created_at.to_string(),
            in_reply_to_id: parent,
        }
    }

    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);

    // Initial poll: one thread, root + 1 reply.
    app.apply_review_comments(vec![
        comment(100, None, "2025-01-01T00:00:00Z"),
        comment(101, Some(100), "2025-01-01T01:00:00Z"),
    ]);
    assert_eq!(app.cmt.review_threads.len(), 1);

    // User expands the thread and selects the reply.
    app.cmt.expanded_thread = Some(0);
    app.cmt.expanded_selected = 1;
    app.cmt.expanded_selected_comment_id = Some(101);

    // Background poll returns the same root with an additional newer reply
    // sorted ahead of the previously-selected reply by created_at order.
    app.apply_review_comments(vec![
        comment(100, None, "2025-01-01T00:00:00Z"),
        comment(102, Some(100), "2025-01-01T00:30:00Z"),
        comment(101, Some(100), "2025-01-01T01:00:00Z"),
    ]);

    // Expansion must still resolve to comment id 101, even though its
    // positional index within the thread shifted from 1 to 2.
    assert_eq!(app.cmt.expanded_thread, Some(0));
    assert_eq!(app.cmt.expanded_selected_comment_id, Some(101));
    assert_eq!(
        app.cmt.expanded_selected, 2,
        "expanded_selected should be re-derived from the comment id"
    );
}

/// Per-file comment counts must be populated whenever review_comments
/// is applied — including from the cache-hit path through
/// apply_review_comments. Without this, the file-list badge feature
/// silently degrades after the first load.
#[test]
fn test_apply_review_comments_populates_file_counts() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);

    app.apply_review_comments(vec![
        ReviewComment {
            id: 1,
            path: "src/a.rs".to_string(),
            line: Some(1),
            start_line: None,
            body: "x".to_string(),
            user: crate::github::User {
                login: "u".to_string(),
            },
            created_at: "2025-01-01T00:00:00Z".to_string(),
            in_reply_to_id: None,
        },
        ReviewComment {
            id: 2,
            path: "src/a.rs".to_string(),
            line: Some(2),
            start_line: None,
            body: "y".to_string(),
            user: crate::github::User {
                login: "u".to_string(),
            },
            created_at: "2025-01-01T01:00:00Z".to_string(),
            in_reply_to_id: None,
        },
        ReviewComment {
            id: 3,
            path: "src/b.rs".to_string(),
            line: Some(1),
            start_line: None,
            body: "z".to_string(),
            user: crate::github::User {
                login: "u".to_string(),
            },
            created_at: "2025-01-01T02:00:00Z".to_string(),
            in_reply_to_id: None,
        },
    ]);

    assert_eq!(app.cmt.file_comment_counts.get("src/a.rs"), Some(&2));
    assert_eq!(app.cmt.file_comment_counts.get("src/b.rs"), Some(&1));
}

fn pr157_rc(id: u64, parent: Option<u64>, path: &str, created_at: &str) -> ReviewComment {
    ReviewComment {
        id,
        path: path.to_string(),
        line: Some(10),
        start_line: None,
        body: format!("comment {id}"),
        user: crate::github::User {
            login: "alice".to_string(),
        },
        created_at: created_at.to_string(),
        in_reply_to_id: parent,
    }
}

/// In local mode the comment list renders threads driven by `selected_thread`,
/// so navigation keys must advance `selected_thread`, not the flat
/// `selected_comment` the renderer ignores.
#[test]
fn test_local_comment_list_move_down_advances_thread() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("local", 0, config);
    app.local_mode = true;
    app.apply_review_comments(vec![
        pr157_rc(1, None, "src/a.rs", "2025-01-01T00:00:00Z"),
        pr157_rc(2, None, "src/b.rs", "2025-01-01T01:00:00Z"),
        pr157_rc(3, Some(2), "src/b.rs", "2025-01-01T02:00:00Z"),
    ]);
    assert_eq!(app.cmt.review_threads.len(), 2);
    assert_eq!(app.cmt.selected_thread, 0);

    let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
    app.handle_local_comment_list_key(key, 40).unwrap();

    assert_eq!(app.cmt.selected_thread, 1);
}

/// Enter on a thread with replies expands it in local mode, matching
/// GitHub-mode behavior, instead of jumping straight to the file.
#[test]
fn test_local_comment_list_enter_expands_thread_with_replies() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("local", 0, config);
    app.local_mode = true;
    app.apply_review_comments(vec![
        pr157_rc(1, None, "src/a.rs", "2025-01-01T00:00:00Z"),
        pr157_rc(2, None, "src/b.rs", "2025-01-01T01:00:00Z"),
        pr157_rc(3, Some(2), "src/b.rs", "2025-01-01T02:00:00Z"),
    ]);
    app.cmt.selected_thread = 1;

    let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
    app.handle_local_comment_list_key(enter, 40).unwrap();

    assert_eq!(app.cmt.expanded_thread, Some(1));
    assert_eq!(app.cmt.expanded_selected, 0);
}

/// Inside an expanded thread, navigation moves `expanded_selected`, and quit
/// collapses the thread rather than leaving the comment list screen.
#[test]
fn test_local_comment_list_expanded_move_then_quit_collapses() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("local", 0, config);
    app.local_mode = true;
    app.previous_state = AppState::FileList;
    app.state = AppState::CommentList;
    app.apply_review_comments(vec![
        pr157_rc(2, None, "src/b.rs", "2025-01-01T01:00:00Z"),
        pr157_rc(3, Some(2), "src/b.rs", "2025-01-01T02:00:00Z"),
    ]);
    app.cmt.expanded_thread = Some(0);
    app.cmt.expanded_selected = 0;

    let down = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
    app.handle_local_comment_list_key(down, 40).unwrap();
    assert_eq!(app.cmt.expanded_selected, 1, "should move to the reply");

    let quit = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
    app.handle_local_comment_list_key(quit, 40).unwrap();
    assert_eq!(app.cmt.expanded_thread, None, "quit collapses the thread");
    assert_eq!(app.state, AppState::CommentList, "stays on comment list");
}

/// A reply created in local mode must record its parent's id in
/// `in_reply_to_id` so the threading view nests it under the parent.
#[test]
#[serial]
fn test_submit_local_reply_links_parent_id() {
    let tempdir = tempdir().unwrap();
    let workdir = tempdir.path().join("worktree");
    std::fs::create_dir_all(&workdir).unwrap();
    let _cache_home = ScopedCacheHome::new(tempdir.path());

    crate::cache::save_local_review_comments(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
        &[crate::cache::LocalReviewComment::new(pr157_rc(
            1,
            None,
            "src/main.rs",
            "2026-03-24T00:00:00Z",
        ))],
    )
    .unwrap();

    let mut app = App::new_for_test();
    app.local_mode = true;
    app.repo = "owner/repo".to_string();
    app.working_dir = Some(workdir.to_string_lossy().to_string());
    app.load_review_comments();

    app.submit_local_reply(1, "a threaded reply".to_string());

    let saved = crate::cache::load_local_review_comments(
        "owner/repo",
        Some(workdir.to_string_lossy().as_ref()),
    )
    .unwrap();
    let reply = saved
        .iter()
        .map(|l| &l.comment)
        .find(|c| c.body == "a threaded reply")
        .expect("reply persisted");
    assert_eq!(reply.in_reply_to_id, Some(1));
}

/// The eager comment load (on PR select) and the post-data-load fallback must
/// not both fetch: an in-flight receiver suppresses a redundant reload.
#[test]
fn test_needs_review_comment_load_respects_inflight_receiver() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.local_mode = false;
    app.cmt.review_comments = None;
    app.cmt.comment_receiver = None;
    assert!(app.needs_review_comment_load());

    let (_tx2, rx) = tokio::sync::mpsc::channel::<Result<Vec<ReviewComment>, String>>(1);
    app.cmt.comment_receiver = Some((1, rx));
    assert!(
        !app.needs_review_comment_load(),
        "in-flight fetch must suppress reload"
    );

    app.cmt.comment_receiver = None;
    app.cmt.review_comments = Some(vec![]);
    assert!(
        !app.needs_review_comment_load(),
        "already loaded -> no reload"
    );

    app.cmt.review_comments = None;
    app.local_mode = true;
    assert!(
        !app.needs_review_comment_load(),
        "local mode -> no remote load"
    );
}

// ========================================
// Diff page-scroll pure functions (issue #161)
// ========================================

#[test]
fn test_scroll_diff_page_down_advances_by_step() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.diff_scroll.line_count = 100;
    app.diff_scroll.selected_line = 0;

    app.scroll_diff_page_down(40);

    assert_eq!(
        app.diff_scroll.selected_line,
        super::input_diff::DIFF_PAGE_STEP
    );
}

#[test]
fn test_scroll_diff_page_down_clamps_at_end() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.diff_scroll.line_count = 10;
    app.diff_scroll.selected_line = 5;

    app.scroll_diff_page_down(40);

    assert_eq!(app.diff_scroll.selected_line, 9);
}

#[test]
fn test_scroll_diff_page_down_no_op_on_empty_diff() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.diff_scroll.line_count = 0;
    app.diff_scroll.selected_line = 0;

    app.scroll_diff_page_down(40);

    assert_eq!(app.diff_scroll.selected_line, 0);
}

#[test]
fn test_scroll_diff_page_up_saturates_at_zero() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.diff_scroll.line_count = 100;
    app.diff_scroll.selected_line = 5;

    app.scroll_diff_page_up(40);

    assert_eq!(app.diff_scroll.selected_line, 0);
}

#[test]
fn test_scroll_diff_page_up_full_step_when_room() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.diff_scroll.line_count = 100;
    app.diff_scroll.selected_line = 50;

    app.scroll_diff_page_up(40);

    assert_eq!(
        app.diff_scroll.selected_line,
        50 - super::input_diff::DIFF_PAGE_STEP
    );
}

#[test]
fn test_diff_visible_lines_panel_closed_uses_term_minus_8() {
    let config = Config::default();
    let (app, _tx) = App::new_loading("owner/repo", 1, config);
    assert!(!app.cmt.comment_panel_open);

    let lines = app.diff_visible_lines(50, super::types::DiffViewVariant::SplitPane);
    assert_eq!(lines, 50 - 8);
}

#[test]
fn test_diff_visible_lines_tiny_terminal_does_not_panic() {
    let config = Config::default();
    let (app, _tx) = App::new_loading("owner/repo", 1, config);

    let lines = app.diff_visible_lines(4, super::types::DiffViewVariant::SplitPane);
    assert_eq!(lines, 0);
}

#[test]
fn test_diff_visible_lines_panel_open_split_pane_pct() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.cmt.comment_panel_open = true;

    let term_h = 50usize;
    let lines = app.diff_visible_lines(term_h, super::types::DiffViewVariant::SplitPane);
    let fixed = 6;
    let expected = (term_h.saturating_sub(fixed) * 50 / 90).saturating_sub(2);
    assert_eq!(lines, expected);
}

// ========================================
// Scenario: diff_page_down/up in split view contexts (issue #161)
// ========================================

#[test]
fn test_diff_page_down_in_split_file_list_advances_only_diff() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::SplitViewFileList;
    app.diff_scroll.line_count = 100;
    app.diff_scroll.selected_line = 0;
    app.selected_file = 1;

    app.scroll_diff_page_down(40);

    assert_eq!(
        app.diff_scroll.selected_line,
        super::input_diff::DIFF_PAGE_STEP
    );
    assert_eq!(app.selected_file, 1, "file selection must not change");
}

#[test]
fn test_diff_page_up_in_split_file_list_does_not_change_file_selection() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::SplitViewFileList;
    app.diff_scroll.line_count = 100;
    app.diff_scroll.selected_line = 60;
    app.selected_file = 2;

    app.scroll_diff_page_up(40);

    assert_eq!(
        app.diff_scroll.selected_line,
        60 - super::input_diff::DIFF_PAGE_STEP
    );
    assert_eq!(app.selected_file, 2);
}

#[test]
fn test_diff_page_down_works_with_filter_active() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::SplitViewFileList;
    app.file_list_filter = Some(crate::filter::ListFilter::new());
    app.diff_scroll.line_count = 100;
    app.diff_scroll.selected_line = 0;

    app.scroll_diff_page_down(40);

    assert_eq!(
        app.diff_scroll.selected_line,
        super::input_diff::DIFF_PAGE_STEP
    );
}

#[test]
fn test_diff_page_down_in_diff_focus_matches_page_down_step() {
    let config = Config::default();
    let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
    app.state = AppState::SplitViewDiff;
    app.diff_scroll.line_count = 200;
    app.diff_scroll.selected_line = 30;

    app.scroll_diff_page_down(40);

    assert_eq!(
        app.diff_scroll.selected_line,
        30 + super::input_diff::DIFF_PAGE_STEP
    );
}
