// Lint configuration for code quality
#![warn(
    clippy::unwrap_used,        // Require .expect() over .unwrap()
    clippy::redundant_clone,    // Catch unnecessary clones
    clippy::too_many_lines,     // Flag long functions (configured in clippy.toml)
    clippy::excessive_nesting,  // Flag deeply nested code
)]

mod html;
mod print;

use branchdiff::app::{self, App, FrameContext};
use branchdiff::cli::{Cli, OutputMode};
use clap::Parser;
use branchdiff::file_events::VcsLockState;
#[cfg(target_os = "linux")]
use branchdiff::gitignore::GitignoreFilter;
use branchdiff::input::{handle_event, AppAction};
use branchdiff::limits;
use branchdiff::message::{
    FetchResult, LoopAction, Message, RefreshOutcome, RefreshTrigger, FALLBACK_REFRESH_SECS,
};
use branchdiff::update::{
    classify_error, update, watchdog_timeout_from_env, ErrorClass, RecoveryAction,
    RefreshState, Timers, UpdateConfig,
};
use branchdiff::vcs::{self, ComparisonContext, RefreshResult, Vcs};
use branchdiff::ui;

use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
#[cfg(target_os = "linux")]
use ignore::WalkBuilder;
use notify::RecursiveMode::{NonRecursive, Recursive};
use notify::{PollWatcher, RecommendedWatcher};
use notify_debouncer_mini::{new_debouncer_opt, Config as DebouncerConfig, Debouncer};
use ratatui::prelude::*;

/// Wrapper enum to hold either watcher type while keeping the debouncer alive.
enum AnyDebouncer {
    Recommended(Debouncer<RecommendedWatcher>),
    Poll(Debouncer<PollWatcher>),
}

impl AnyDebouncer {
    fn watcher(&mut self) -> &mut dyn notify::Watcher {
        match self {
            Self::Recommended(d) => d.watcher(),
            Self::Poll(d) => d.watcher(),
        }
    }
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    let repo_path = cli
        .path
        .canonicalize()
        .context("Failed to resolve repository path")?;

    // Try to detect VCS - for non-TUI modes, fail immediately if not found
    let detected = match vcs::detect(&repo_path) {
        Ok(vcs) => Some(vcs),
        Err(_) => {
            if cli.output.mode() != OutputMode::Tui {
                anyhow::bail!("Not a git or jj repository");
            }
            None
        }
    };

    // Non-interactive modes (detected is always Some here due to bail above)
    if let Some(vcs) = &detected {
        let mode = cli.output.mode();
        if mode != OutputMode::Tui {
            let repo_root = vcs.repo_path().to_path_buf();
            let comparison = vcs.comparison_context()?;
            let cancel_flag = Arc::new(AtomicBool::new(false));
            let initial = vcs.refresh(&cancel_flag)?;
            let mut app = app::App::new(repo_root, comparison, initial);

            match mode {
                OutputMode::Diff => {
                    let patch = branchdiff::patch::generate_patch(&app.lines);
                    print!("{}", patch);
                }
                OutputMode::Print | OutputMode::Html => {
                    // Print shows everything; HTML defaults to context mode
                    app.view.view_mode = match mode {
                        OutputMode::Print => app::ViewMode::Full,
                        _ => app::ViewMode::Context,
                    };

                    let data = branchdiff::output::prepare(&mut app);

                    // Pre-load images for HTML embedding
                    if mode == OutputMode::Html {
                        for file in &data.files {
                            for line in &file.lines {
                                if line.is_image_marker()
                                    && let Some(ref path) = line.file_path
                                    && !app.image_cache.contains(path)
                                    && let Some(state) = branchdiff::image_diff::load_image_diff(vcs.as_ref(), path)
                                {
                                    app.image_cache.insert(path.clone(), state);
                                }
                            }
                        }
                    }

                    match mode {
                        OutputMode::Print => print::print_diff(&data)?,
                        OutputMode::Html => html::render_html(&data, &app.image_cache)?,
                        _ => unreachable!(),
                    }
                }
                OutputMode::Tui => unreachable!(),
            }

            return Ok(());
        }
    }

    // TUI mode
    match detected {
        Some(vcs) => {
            let repo_root = vcs.repo_path().to_path_buf();
            if let Some(frames) = cli.benchmark {
                return run_benchmark(vcs, repo_root, frames);
            }
            run_main_app(vcs, repo_root, !cli.no_auto_fetch)
        }
        None => run_waiting_for_vcs(&repo_path, !cli.no_auto_fetch),
    }
}

/// Run in "waiting for VCS" mode until a repository is detected.
///
/// Uses a filesystem watcher for instant detection of `.jj`/`.git` directory
/// creation, with a polling fallback. Once a VCS directory is found, retries
/// full `vcs::detect()` (which runs external commands) with backoff until it
/// succeeds — surfacing errors on screen so PATH issues are visible.
fn run_waiting_for_vcs(path: &Path, auto_fetch: bool) -> Result<()> {
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use ratatui::widgets::{Block, Borders, Paragraph};
    use ratatui::layout::Alignment;

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Set up a filesystem watcher on the target directory for instant
    // detection of .jj/ or .git/ creation.
    let (watch_tx, watch_rx) = mpsc::channel();
    let _watcher = {
        use notify::{Watcher, RecursiveMode};
        let mut watcher = notify::recommended_watcher(move |_res: notify::Result<notify::Event>| {
            let _ = watch_tx.send(());
        })?;
        watcher.watch(path, RecursiveMode::NonRecursive)?;
        watcher
    };

    let poll_interval = Duration::from_secs(2);
    let mut last_poll = Instant::now();

    // Two-phase state: first wait for directory, then wait for commands.
    let mut repo_found: Option<&str> = None; // VCS type once directory detected
    let mut last_error: Option<String> = None;
    let mut retry_delay = Duration::from_millis(500);
    let mut last_detect_attempt = Instant::now();
    let max_retry_delay = Duration::from_secs(2);

    // Check immediately in case the repo was created between startup detection
    // and watcher setup.
    if let Some((vcs_type, _)) = vcs::detect_repo_dir(path) {
        repo_found = Some(vcs_type);
    }

    loop {
        let display_msg = match (&repo_found, &last_error) {
            (None, _) => "Not a repository.\n\nWaiting for git init or jj init...".to_string(),
            (Some(vcs_type), None) => format!("Repository found (.{vcs_type} detected)\n\nInitializing..."),
            (Some(vcs_type), Some(err)) => format!("Repository found (.{vcs_type} detected)\n\nInitializing...\n\n{err}"),
        };

        terminal.draw(|f| {
            let area = f.area();
            let message = Paragraph::new(display_msg.as_str())
                .alignment(Alignment::Center)
                .block(Block::default().borders(Borders::NONE));

            let y = area.height / 2;
            let line_count: u16 = display_msg.lines().count().try_into().unwrap_or(4);
            let box_height = (line_count + 2).min(area.height);
            let centered_area = ratatui::layout::Rect {
                x: 0,
                y: y.saturating_sub(line_count / 2),
                width: area.width,
                height: box_height,
            };
            f.render_widget(message, centered_area);
        })?;

        // Handle keyboard events.
        if event::poll(Duration::from_millis(100))?
            && let crossterm::event::Event::Key(KeyEvent { code, modifiers, .. }) = event::read()?
        {
            match (code, modifiers) {
                (KeyCode::Char('q'), _)
                | (KeyCode::Char('c'), KeyModifiers::CONTROL)
                | (KeyCode::Esc, _) => {
                    disable_raw_mode()?;
                    execute!(
                        terminal.backend_mut(),
                        LeaveAlternateScreen,
                        DisableMouseCapture
                    )?;
                    terminal.show_cursor()?;
                    return Ok(());
                }
                _ => {}
            }
        }

        // Drain any watcher events (they signal potential directory creation).
        let mut watcher_fired = false;
        while watch_rx.try_recv().is_ok() {
            watcher_fired = true;
        }

        // Phase 1: check for VCS directory existence (filesystem only, no commands).
        if repo_found.is_none()
            && (watcher_fired || last_poll.elapsed() >= poll_interval)
        {
            last_poll = Instant::now();
            if let Some((vcs_type, _)) = vcs::detect_repo_dir(path) {
                repo_found = Some(vcs_type);
                last_detect_attempt = Instant::now() - retry_delay; // trigger immediate detect
            }
        }

        // Phase 2: repo directory exists — try full detection with commands.
        if repo_found.is_some()
            && last_detect_attempt.elapsed() >= retry_delay
        {
            last_detect_attempt = Instant::now();
            match vcs::detect(path) {
                Ok(detected) => {
                    let repo_root = detected.repo_path().to_path_buf();
                    disable_raw_mode()?;
                    execute!(
                        terminal.backend_mut(),
                        LeaveAlternateScreen,
                        DisableMouseCapture
                    )?;
                    terminal.show_cursor()?;
                    return run_main_app(detected, repo_root, auto_fetch);
                }
                Err(e) => {
                    last_error = Some(format!("{e:#}"));
                    retry_delay = (retry_delay * 2).min(max_retry_delay);
                }
            }
        }
    }
}

/// Main app logic, extracted for reuse after VCS detection.
fn run_main_app(
    mut detected: Box<dyn Vcs>,
    mut repo_root: PathBuf,
    auto_fetch: bool,
) -> Result<()> {
    // Initialize image protocol picker (once — survives restarts)
    let in_multiplexer = std::env::var("ZELLIJ").is_ok()
        || std::env::var("TMUX").is_ok()
        || std::env::var("STY").is_ok();

    let mut image_picker = if in_multiplexer {
        ratatui_image::picker::Picker::halfblocks()
    } else {
        ratatui_image::picker::Picker::from_query_stdio()
            .unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks())
    };
    image_picker.set_background_color(image::Rgba([30, 30, 30, 255]));

    // Setup terminal (once — survives restarts)
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let watch_limit = limits::get_watch_limit();

    loop {
        let vcs: Arc<dyn Vcs> = Arc::from(detected);

        let mut app = build_initial_app(&*vcs, repo_root.clone());
        app.load_images_for_markers(&*vcs);
        app.set_image_picker(image_picker.clone());

        // Setup file watcher (recreated on restart — old watcher is dropped)
        let (file_tx, file_rx) = mpsc::channel();
        let debouncer_config = DebouncerConfig::default()
            .with_timeout(Duration::from_millis(100))
            .with_notify_config(
                notify::Config::default().with_poll_interval(Duration::from_millis(500)),
            );

        let mut debouncer = if limits::is_wsl() {
            AnyDebouncer::Poll(new_debouncer_opt::<_, PollWatcher>(debouncer_config, file_tx)?)
        } else {
            AnyDebouncer::Recommended(new_debouncer_opt::<_, RecommendedWatcher>(
                debouncer_config,
                file_tx,
            )?)
        };

        let watcher_metrics = setup_watcher(debouncer.watcher(), &*vcs, watch_limit)?;

        let needs_fallback_refresh =
            limits::check_watch_warning(&watcher_metrics, watch_limit).is_some();
        if needs_fallback_refresh {
            app.performance_warning = Some(format!(
                "Large repo: refreshing every {}s",
                FALLBACK_REFRESH_SECS
            ));
        }

        let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshOutcome>();

        let config = UpdateConfig {
            auto_fetch,
            needs_fallback_refresh,
            repo_path: repo_root.clone(),
            refresh_watchdog_timeout: watchdog_timeout_from_env(),
            ..Default::default()
        };

        let loop_action = run_app(
            &mut terminal,
            &mut app,
            debouncer.watcher(),
            file_rx,
            refresh_tx,
            refresh_rx,
            vcs,
            config,
            watch_limit,
        )?;

        match loop_action {
            LoopAction::Quit => break,
            LoopAction::RestartVcs => {
                match vcs::detect(&repo_root) {
                    Ok(new_vcs) => {
                        repo_root = new_vcs.repo_path().to_path_buf();
                        detected = new_vcs;
                        continue;
                    }
                    Err(_) => break,
                }
            }
            LoopAction::Continue => unreachable!("run_app should not return Continue"),
        }
    }

    // Restore terminal (once)
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    Ok(())
}

fn run_benchmark(detected: Box<dyn Vcs>, repo_root: PathBuf, frames: usize) -> Result<()> {
    use ratatui::backend::TestBackend;

    eprintln!("Loading diff from {}...", repo_root.display());
    let load_start = Instant::now();
    let comparison = detected.comparison_context()?;
    let cancel_flag = Arc::new(AtomicBool::new(false));
    let initial = detected.refresh(&cancel_flag)?;
    let mut app = App::new(repo_root, comparison, initial);
    let load_time = load_start.elapsed();
    eprintln!(
        "Loaded {} lines across {} files in {:?}",
        app.lines.len(),
        app.files.len(),
        load_time
    );

    if app.lines.is_empty() {
        eprintln!("No changes to benchmark. Try running in a repo with uncommitted changes.");
        return Ok(());
    }

    let backend = TestBackend::new(120, 40);
    let mut terminal = Terminal::new(backend)?;
    let visible_height = 40_usize;

    app.set_viewport_height(visible_height);
    app.view.collapsed_files.clear();

    eprintln!("Running {} frames...", frames);
    let bench_start = Instant::now();

    let ctx = FrameContext::new(&app);
    let max_scroll = ctx.max_scroll(&app);

    for frame_num in 0..frames {
        let action = match frame_num % 20 {
            0..=4 => AppAction::ScrollDown(3),
            5..=9 => AppAction::ScrollUp(2),
            10 => AppAction::NextFile,
            11 => AppAction::PrevFile,
            12 => AppAction::CycleViewMode,
            13 => AppAction::GoToBottom,
            14 => AppAction::GoToTop,
            15 => AppAction::PageDown,
            16 => AppAction::PageUp,
            _ => AppAction::ScrollDown(1),
        };

        match action {
            AppAction::ScrollDown(n) => {
                let new_offset = (app.view.scroll_offset + n).min(max_scroll);
                app.view.scroll_offset = new_offset;
            }
            AppAction::ScrollUp(n) => {
                app.view.scroll_offset = app.view.scroll_offset.saturating_sub(n);
            }
            AppAction::NextFile => app.next_file(),
            AppAction::PrevFile => app.prev_file(),
            AppAction::CycleViewMode => app.cycle_view_mode(),
            AppAction::GoToBottom => app.go_to_bottom(),
            AppAction::GoToTop => app.go_to_top(),
            AppAction::PageDown => app.page_down(),
            AppAction::PageUp => app.page_up(),
            _ => {}
        }

        let items = if app.needs_inline_spans() {
            let items = app.ensure_inline_spans_for_visible(visible_height);
            app.clear_needs_inline_spans();
            Some(items)
        } else {
            None
        };

        terminal.draw(|f| {
            let frame_ctx = match items {
                Some(items) => FrameContext::with_items(items, &app),
                None => FrameContext::new(&app),
            };
            ui::draw_with_frame(f, &mut app, &frame_ctx)
        })?;
    }

    let bench_time = bench_start.elapsed();
    let avg_frame = bench_time.as_micros() as f64 / frames as f64;

    eprintln!("\nResults:");
    eprintln!("  Total time:     {:?}", bench_time);
    eprintln!("  Frames:         {}", frames);
    eprintln!("  Avg frame:      {:.1} µs", avg_frame);
    eprintln!("  Throughput:     {:.0} fps", 1_000_000.0 / avg_frame);

    Ok(())
}

fn spawn_single_file_refresh(
    vcs: Arc<dyn Vcs>,
    file_path: String,
    refresh_tx: mpsc::Sender<RefreshOutcome>,
) {
    thread::spawn(move || {
        let diff = vcs.single_file_diff(&file_path);
        let revision_id = vcs.current_revision_id().ok();
        let _ = refresh_tx.send(RefreshOutcome::SingleFile { path: file_path, diff, revision_id });
    });
}

/// Build the initial `App` for a TUI session.
///
/// Tries the initial refresh; on failure, returns an `App` with empty data and
/// the error pre-populated on `app.error` (plus a recovery hint when the error
/// is recognized). This is the single seam where startup and runtime share
/// error-classification logic — previously, a failed initial refresh bubbled
/// `Err` out of `main()` and the user had to restart branchdiff manually after
/// fixing the condition. Now the TUI comes up, the watcher is wired, and an
/// external fix (or pressing the recovery key) auto-recovers without restart.
fn build_initial_app(vcs: &dyn Vcs, repo_root: PathBuf) -> App {
    // Fallback labels if comparison_context itself fails (e.g. jj is stale and
    // even the log query bails). The first successful refresh replaces these.
    let comparison = vcs.comparison_context().unwrap_or_else(|_| ComparisonContext {
        from_label: "base".to_string(),
        to_label: "working copy".to_string(),
        stack_position: None,
        vcs_backend: vcs.backend(),
        bookmark_name: None,
        divergence: None,
    });
    let cancel_flag = Arc::new(AtomicBool::new(false));
    apply_initial_refresh(
        vcs.refresh(&cancel_flag).map_err(|e| format!("{e:#}")),
        repo_root,
        comparison,
    )
}

/// Pure helper that maps an initial-refresh outcome to an `App` — extracted
/// from `build_initial_app` so the error→banner→hint wiring can be tested
/// without spinning up a real VCS backend.
fn apply_initial_refresh(
    initial: std::result::Result<RefreshResult, String>,
    repo_root: PathBuf,
    comparison: ComparisonContext,
) -> App {
    match initial {
        Ok(result) => App::new(repo_root, comparison, result),
        Err(msg) => {
            let mut app = App::new(repo_root, comparison, RefreshResult::empty());
            app.error = Some(msg.clone());
            if let ErrorClass::Actionable(hint) = classify_error(&msg) {
                app.pending_recovery = Some(hint);
            }
            app
        }
    }
}

#[cfg(test)]
mod startup_tests {
    use super::*;
    use branchdiff::update::{RecoveryAction, RecoveryHint};
    use branchdiff::vcs::VcsBackend;

    fn test_comparison() -> ComparisonContext {
        ComparisonContext {
            from_label: "main".to_string(),
            to_label: "feature".to_string(),
            stack_position: None,
            vcs_backend: VcsBackend::Jj,
            bookmark_name: None,
            divergence: None,
        }
    }

    /// Happy path: a successful refresh produces an App with that data and no
    /// error banner. The empty-result fallback must NOT be applied here.
    #[test]
    fn apply_initial_refresh_success_uses_provided_result() {
        let mut r = RefreshResult::empty();
        r.base_identifier = "abc123".to_string();
        let app = apply_initial_refresh(Ok(r), PathBuf::from("/tmp/r"), test_comparison());
        assert_eq!(app.base_identifier, "abc123");
        assert!(app.error.is_none());
        assert!(app.pending_recovery.is_none());
    }

    /// Failure path: the App still comes up (this is the bug fix — no more
    /// process exit), the banner shows the message, and a recognized error
    /// gets a one-key fix offered.
    #[test]
    fn apply_initial_refresh_failure_surfaces_banner_with_stale_hint() {
        let msg = "Error: The working copy is stale. Hint: Run `jj workspace update-stale`.";
        let app = apply_initial_refresh(
            Err(msg.to_string()),
            PathBuf::from("/tmp/r"),
            test_comparison(),
        );
        assert_eq!(app.error.as_deref(), Some(msg));
        let hint = app.pending_recovery.expect("stale message should offer recovery");
        assert_eq!(hint.action, RecoveryAction::JjUpdateStale);
        assert_eq!(hint.key_hint, 'u');
        // Empty data so the diff view renders nothing under the banner.
        assert!(app.files.is_empty());
        assert!(app.lines.is_empty());
    }

    /// Failure with an unrecognized error: banner shows the message but no
    /// fix is offered. Pressing 'u' would be a no-op.
    #[test]
    fn apply_initial_refresh_unrecognized_failure_has_no_hint() {
        let app = apply_initial_refresh(
            Err("no such revision: foo".to_string()),
            PathBuf::from("/tmp/r"),
            test_comparison(),
        );
        assert!(app.error.is_some());
        assert!(app.pending_recovery.is_none());
    }

    #[test]
    fn apply_initial_refresh_failure_preserves_comparison_labels() {
        // Even on failure, the labels we computed via comparison_context (or
        // its fallback) must survive — that's what the status bar reads.
        let comparison = ComparisonContext {
            from_label: "from-label".to_string(),
            to_label: "to-label".to_string(),
            ..test_comparison()
        };
        let app = apply_initial_refresh(
            Err("stale".to_string()),
            PathBuf::from("/tmp/r"),
            comparison,
        );
        assert_eq!(app.comparison.from_label, "from-label");
        assert_eq!(app.comparison.to_label, "to-label");
    }

    /// Regression guard: ensure the helper handles a hint that doesn't get
    /// stripped or relocated when round-tripped through the App field.
    #[test]
    fn pending_recovery_field_matches_classifier_output() {
        let app = apply_initial_refresh(
            Err("The working copy is stale".to_string()),
            PathBuf::from("/tmp/r"),
            test_comparison(),
        );
        let hint = app.pending_recovery.expect("expected hint");
        assert_eq!(hint, RecoveryHint::jj_update_stale());
    }
}

/// Run the recovery command then a follow-up refresh — both reported as a
/// single `RefreshOutcome` so the existing pipeline handles the result.
fn spawn_recovery(
    vcs: Arc<dyn Vcs>,
    action: RecoveryAction,
    refresh_tx: mpsc::Sender<RefreshOutcome>,
    cancel_flag: Arc<AtomicBool>,
) {
    thread::spawn(move || {
        if let Err(e) = vcs.try_recover(action, &cancel_flag) {
            // A cancel mid-`try_recover` surfaces as a `RunError::Cancelled`
            // that anyhow propagates by Display — the flag is still our
            // source of truth for whether to treat this as user-initiated
            // cancellation versus a genuine failure.
            let outcome = if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
                RefreshOutcome::Cancelled
            } else {
                RefreshOutcome::Error(format!("Recovery action failed: {e:#}"))
            };
            let _ = refresh_tx.send(outcome);
            return;
        }
        match vcs.refresh(&cancel_flag) {
            Ok(mut result) => {
                result.revision_id = vcs.current_revision_id().ok();
                let _ = refresh_tx.send(RefreshOutcome::success(result));
            }
            Err(e) => {
                let outcome = if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
                    RefreshOutcome::Cancelled
                } else {
                    RefreshOutcome::Error(format!("{e:#}"))
                };
                let _ = refresh_tx.send(outcome);
            }
        }
    });
}

fn spawn_refresh(
    vcs: Arc<dyn Vcs>,
    refresh_tx: mpsc::Sender<RefreshOutcome>,
    cancel_flag: Arc<AtomicBool>,
) {
    thread::spawn(move || {
        match vcs.refresh(&cancel_flag) {
            Ok(mut result) => {
                result.revision_id = vcs.current_revision_id().ok();
                let _ = refresh_tx.send(RefreshOutcome::success(result));
            }
            Err(e) => {
                let outcome = if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
                    RefreshOutcome::Cancelled
                } else {
                    RefreshOutcome::Error(format!("{e:#}"))
                };
                let _ = refresh_tx.send(outcome);
            }
        }
    });
}

fn spawn_fetch(vcs: Arc<dyn Vcs>, fetch_tx: mpsc::Sender<FetchResult>) {
    thread::spawn(move || {
        if vcs.fetch().is_ok() {
            let has_conflicts = vcs.has_conflicts().unwrap_or(false);
            let new_merge_base = vcs.base_identifier().ok();

            let _ = fetch_tx.send(FetchResult {
                has_conflicts,
                new_merge_base,
            });
        }
    });
}

#[allow(unused_variables)] // watcher and watch_limit only used on Linux
fn run_app<B: Backend>(
    terminal: &mut Terminal<B>,
    app: &mut App,
    watcher: &mut (impl notify::Watcher + ?Sized),
    file_events: mpsc::Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
    refresh_tx: mpsc::Sender<RefreshOutcome>,
    refresh_rx: mpsc::Receiver<RefreshOutcome>,
    vcs: Arc<dyn Vcs>,
    config: UpdateConfig,
    watch_limit: Option<usize>,
) -> Result<LoopAction>
where
    B::Error: Send + Sync + 'static,
{
    let mut refresh_state = RefreshState::Idle;
    let mut vcs_lock = VcsLockState::default();
    let mut timers = Timers::new(config.repo_path.join(".jj").is_dir());

    let (fetch_tx, fetch_rx) = mpsc::channel::<FetchResult>();

    // Draw initial frame before entering event loop
    // Must set viewport_height AND content_width BEFORE creating FrameContext,
    // which snapshots them for visible_range calculation
    let terminal_size = terminal.size()?;
    let status_height = ui::status_bar_height(app, terminal_size.width);
    let content_height = (terminal_size.height - status_height).saturating_sub(2) as usize;
    app.set_viewport_height(content_height);
    app.estimate_content_width(terminal_size.width);
    let items = app.ensure_inline_spans_for_visible(content_height);
    app.clear_needs_inline_spans();
    terminal.draw(|f| {
        let frame_ctx = FrameContext::with_items(items, app);
        ui::draw_with_frame(f, app, &frame_ctx)
    })?;

    loop {
        // Collect messages from all sources
        let messages = collect_messages(
            &file_events,
            &refresh_rx,
            &fetch_rx,
            app.is_search_input_active(),
        )?;

        // Process each message
        #[cfg(target_os = "linux")]
        for msg in &messages {
            if let Message::FileChanged(events) = msg {
                // Watch any newly created directories (Linux only - macOS/Windows use recursive)
                watch_new_directories(watcher, vcs.repo_path(), events);

                // When .gitignore changes, add watches for newly visible directories
                let gitignore_changed = events
                    .iter()
                    .any(|e| GitignoreFilter::is_gitignore_file(&e.path));
                if gitignore_changed {
                    add_watches_for_visible_directories(watcher, vcs.repo_path(), watch_limit);
                }
            }
        }

        let mut needs_redraw = false;
        for msg in messages {
            let result = update(
                msg,
                app,
                &mut refresh_state,
                &mut vcs_lock,
                &mut timers,
                &config,
                &*vcs,
            );

            needs_redraw |= result.needs_redraw;

            if result.loop_action == LoopAction::Quit
                || result.loop_action == LoopAction::RestartVcs
            {
                return Ok(result.loop_action);
            }

            // Recovery takes precedence over Refresh: accepting a recovery
            // already implies running a follow-up refresh, so a Full trigger
            // queued on the same iteration would be redundant work.
            if let Some(action) = result.trigger_recovery {
                let cancel_flag = refresh_state.start();
                spawn_recovery(vcs.clone(), action, refresh_tx.clone(), cancel_flag);
            } else {
                match result.refresh {
                    RefreshTrigger::Full => {
                        vcs.set_diff_base(app.diff_base);
                        let cancel_flag = refresh_state.start();
                        spawn_refresh(
                            vcs.clone(),
                            refresh_tx.clone(),
                            cancel_flag,
                        );
                    }
                    RefreshTrigger::SingleFile(file_path) => {
                        refresh_state.start_single_file();
                        spawn_single_file_refresh(
                            vcs.clone(),
                            file_path.to_string_lossy().to_string(),
                            refresh_tx.clone(),
                        );
                    }
                    RefreshTrigger::None => {}
                }
            }

            if result.trigger_fetch {
                spawn_fetch(vcs.clone(), fetch_tx.clone());
            }
        }

        // Only render when state has changed
        if needs_redraw {
            let visible_height = terminal.size()?.height as usize;
            // Compute items once, reuse for both inline spans and FrameContext
            let items = if app.needs_inline_spans() {
                let items = app.ensure_inline_spans_for_visible(visible_height);
                app.clear_needs_inline_spans();
                Some(items)
            } else {
                None
            };
            terminal.draw(|f| {
                let frame_ctx = match items {
                    Some(items) => FrameContext::with_items(items, app),
                    None => FrameContext::new(app),
                };
                ui::draw_with_frame(f, app, &frame_ctx)
            })?;
        }
    }
}

/// Collect messages from all event sources.
fn collect_messages(
    file_events: &mpsc::Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
    refresh_rx: &mpsc::Receiver<RefreshOutcome>,
    fetch_rx: &mpsc::Receiver<FetchResult>,
    search_input_active: bool,
) -> Result<Vec<Message>> {
    let mut messages = Vec::new();

    // Check for input with short timeout for responsiveness
    if event::poll(Duration::from_millis(10))? {
        let event = event::read()?;
        if search_input_active {
            messages.push(Message::SearchInput(event));
        } else {
            let action = handle_event(event);
            if action != AppAction::None {
                messages.push(Message::Input(action));
            }
        }
    }

    // Check for completed refresh (non-blocking)
    if let Ok(outcome) = refresh_rx.try_recv() {
        messages.push(Message::RefreshCompleted(Box::new(outcome)));
    }

    // Check for file change events
    if let Ok(Ok(events)) = file_events.try_recv()
        && !events.is_empty()
    {
        messages.push(Message::FileChanged(events));
    }

    // Check for completed fetch results
    if let Ok(result) = fetch_rx.try_recv() {
        messages.push(Message::FetchCompleted(result));
    }

    // Always send a tick for timer-based operations
    messages.push(Message::Tick);

    Ok(messages)
}

/// Setup file watcher with platform-appropriate strategy.
///
/// On macOS and Windows, uses native recursive watching on the repo root.
/// On Linux, watches each non-ignored directory individually (respecting .gitignore).
///
/// Returns metrics about directories watched (meaningful on Linux only).
fn setup_watcher(
    watcher: &mut (impl notify::Watcher + ?Sized),
    vcs: &dyn Vcs,
    watch_limit: Option<usize>,
) -> Result<limits::WatcherMetrics> {
    // Watch VCS-specific paths (e.g., .git/index, .git/HEAD, .git/refs/)
    setup_vcs_watches(watcher, vcs)?;

    let repo_root = vcs.repo_path();

    #[cfg(any(target_os = "macos", target_os = "windows"))]
    {
        // Native recursive watching - efficient, 1 watch for entire tree.
        // Events for gitignored files are filtered in handle_file_change().
        let _ = watch_limit; // unused on these platforms
        watcher.watch(repo_root, Recursive)?;
        Ok(limits::WatcherMetrics::default())
    }

    #[cfg(target_os = "linux")]
    {
        // Linux inotify: notify-rs creates 1 watch per directory.
        // We walk with gitignore to avoid watching node_modules, target, etc.
        // This is the approach recommended by notify-rs maintainers.
        setup_linux_watches(watcher, repo_root, watch_limit)
    }

    // Other Unix platforms (FreeBSD, etc.) - use recursive as default
    #[cfg(all(unix, not(target_os = "macos"), not(target_os = "linux")))]
    {
        let _ = watch_limit;
        watcher.watch(repo_root, Recursive)?;
        Ok(limits::WatcherMetrics::default())
    }
}

/// Watch VCS-specific paths for detecting commits, branch switches, etc.
fn setup_vcs_watches(
    watcher: &mut (impl notify::Watcher + ?Sized),
    vcs: &dyn Vcs,
) -> Result<()> {
    let watch_paths = vcs.watch_paths();
    for file in &watch_paths.files {
        if file.exists() {
            watcher.watch(file, NonRecursive)?;
        }
    }
    for dir in &watch_paths.recursive_dirs {
        if dir.exists() {
            watcher.watch(dir, Recursive)?;
        }
    }
    Ok(())
}

/// Linux-specific: Watch non-ignored directories individually.
///
/// Uses `ignore::WalkBuilder` to respect .gitignore rules, avoiding watches
/// on large ignored directories like `target/` or `node_modules/`.
#[cfg(target_os = "linux")]
fn setup_linux_watches(
    watcher: &mut (impl notify::Watcher + ?Sized),
    repo_root: &Path,
    watch_limit: Option<usize>,
) -> Result<limits::WatcherMetrics> {
    let mut metrics = limits::WatcherMetrics::default();
    let mut watches_added = 0;
    let limit = watch_limit.unwrap_or(usize::MAX);

    for entry in WalkBuilder::new(repo_root)
        .hidden(false) // Don't skip hidden files (but .git is handled separately)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        .filter_entry(|e| {
            // Skip .git directory (handled above)
            e.file_name() != ".git"
        })
        .build()
        .flatten()
    {
        if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            metrics.directory_count += 1;

            // Stop adding watches if we've hit the limit
            if watches_added >= limit {
                metrics.skipped_count += 1;
                continue;
            }

            if watcher.watch(entry.path(), NonRecursive).is_ok() {
                watches_added += 1;
            } else {
                metrics.skipped_count += 1;
            }
        }
    }

    Ok(metrics)
}

/// Add watches for any newly created directories in file change events.
///
/// When a new directory is created in the repo, we need to watch it to detect
/// file changes. This function checks each event path and adds watches for
/// new directories that aren't gitignored.
///
/// Note: Deleted directories are handled automatically by notify - the watch
/// becomes invalid when the directory is removed.
///
/// Linux only - macOS/Windows use recursive watching which handles this automatically.
#[cfg(target_os = "linux")]
fn watch_new_directories(
    watcher: &mut (impl notify::Watcher + ?Sized),
    repo_root: &Path,
    events: &[notify_debouncer_mini::DebouncedEvent],
) {
    for event in events {
        let path = &event.path;

        // Only care about directories that currently exist
        if !path.is_dir() {
            continue;
        }

        // Must be under repo_root
        if !path.starts_with(repo_root) {
            continue;
        }

        // Skip anything inside .git
        if let Ok(relative) = path.strip_prefix(repo_root)
            && relative.components().any(|c| c.as_os_str() == ".git")
        {
            continue;
        }

        // Check if this directory should be watched (respects gitignore)
        // Ignore errors - directory may already be watched or was deleted between check and watch
        if is_directory_watchable(path) {
            let _ = watcher.watch(path, NonRecursive);
        }
    }
}

/// Check if a directory should be watched by verifying it's not gitignored.
///
/// Uses WalkBuilder on the parent directory to check if the target would be
/// included when respecting gitignore rules.
///
/// Linux only - used by watch_new_directories.
#[cfg(target_os = "linux")]
fn is_directory_watchable(dir_path: &Path) -> bool {
    let parent = match dir_path.parent() {
        Some(p) => p,
        None => return false,
    };

    // Walk the parent with depth 1 and see if our directory is yielded
    WalkBuilder::new(parent)
        .max_depth(Some(1))
        .hidden(false)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        .build()
        .flatten()
        .any(|entry| entry.path() == dir_path)
}

/// Re-walk the repository and add watches for any visible directories.
///
/// Called when .gitignore changes - directories that were previously ignored
/// may now be visible and need watches. Already-watched directories will
/// return an error that we ignore.
///
/// Respects watch_limit to avoid exceeding kernel inotify limits.
///
/// Linux only - macOS/Windows use recursive watching.
#[cfg(target_os = "linux")]
fn add_watches_for_visible_directories(
    watcher: &mut (impl notify::Watcher + ?Sized),
    repo_root: &Path,
    watch_limit: Option<usize>,
) {
    let limit = watch_limit.unwrap_or(usize::MAX);
    let mut watches_added = 0;

    for entry in WalkBuilder::new(repo_root)
        .hidden(false)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        .filter_entry(|e| e.file_name() != ".git")
        .build()
        .flatten()
    {
        if watches_added >= limit {
            break;
        }

        if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            // Ignore errors - directory may already be watched or was deleted
            if watcher.watch(entry.path(), NonRecursive).is_ok() {
                watches_added += 1;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::mpsc;
    use tempfile::TempDir;

    // =========================================================================
    // Linux-specific watch limit tests
    // =========================================================================

    #[test]
    #[cfg(target_os = "linux")]
    fn test_setup_linux_watches_respects_limit() {
        use std::fs;

        // Given: a temp repo with 10 subdirectories
        let temp_dir = TempDir::new().unwrap();
        let repo_root = temp_dir.path();
        fs::create_dir(repo_root.join(".git")).unwrap();
        for i in 0..10 {
            fs::create_dir(repo_root.join(format!("dir{}", i))).unwrap();
        }

        let (tx, _rx) = mpsc::channel();
        let config = DebouncerConfig::default()
            .with_timeout(Duration::from_millis(100))
            .with_notify_config(
                notify::Config::default().with_poll_interval(Duration::from_millis(500)),
            );
        let mut debouncer =
            AnyDebouncer::Recommended(new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap());

        // When: we setup watches with a limit of 5
        let metrics = setup_linux_watches(debouncer.watcher(), repo_root, Some(5)).unwrap();

        // Then: we should have counted all directories but only watched up to limit
        // directory_count includes root (1) + 10 subdirs = 11
        assert!(metrics.directory_count >= 10);
        assert!(metrics.skipped_count >= 5, "Expected at least 5 skipped, got {}", metrics.skipped_count);
    }

    #[test]
    #[cfg(target_os = "linux")]
    fn test_add_watches_for_visible_directories_respects_limit() {
        use std::fs;

        // Given: a temp repo with 10 subdirectories
        let temp_dir = TempDir::new().unwrap();
        let repo_root = temp_dir.path();
        fs::create_dir(repo_root.join(".git")).unwrap();
        for i in 0..10 {
            fs::create_dir(repo_root.join(format!("dir{}", i))).unwrap();
        }

        let (tx, _rx) = mpsc::channel();
        let config = DebouncerConfig::default()
            .with_timeout(Duration::from_millis(100))
            .with_notify_config(
                notify::Config::default().with_poll_interval(Duration::from_millis(500)),
            );
        let mut debouncer =
            AnyDebouncer::Recommended(new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap());

        // When: we add watches with a limit of 3
        // Then: the function should complete without panic (limit is enforced)
        // Note: We can't directly count watches added, but setup_linux_watches
        // tests verify the limit logic which add_watches_for_visible_directories shares
        add_watches_for_visible_directories(debouncer.watcher(), repo_root, Some(3));
    }

    #[test]
    #[cfg(target_os = "linux")]
    fn test_add_watches_for_visible_directories_no_limit() {
        use std::fs;

        // Given: a temp repo with 5 subdirectories
        let temp_dir = TempDir::new().unwrap();
        let repo_root = temp_dir.path();
        fs::create_dir(repo_root.join(".git")).unwrap();
        for i in 0..5 {
            fs::create_dir(repo_root.join(format!("dir{}", i))).unwrap();
        }

        let (tx, _rx) = mpsc::channel();
        let config = DebouncerConfig::default()
            .with_timeout(Duration::from_millis(100))
            .with_notify_config(
                notify::Config::default().with_poll_interval(Duration::from_millis(500)),
            );
        let mut debouncer =
            AnyDebouncer::Recommended(new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap());

        // When: we add watches with no limit (None)
        // Then: function should complete without panic
        add_watches_for_visible_directories(debouncer.watcher(), repo_root, None);
    }

    // =========================================================================
    // Debouncer tests (all platforms)
    // =========================================================================

    #[test]
    fn test_any_debouncer_poll_variant_creates_working_watcher() {
        // Given: a channel for file events and a temp directory to watch
        let (tx, _rx) = mpsc::channel();
        let temp_dir = TempDir::new().unwrap();

        let config = DebouncerConfig::default()
            .with_timeout(Duration::from_millis(100))
            .with_notify_config(
                notify::Config::default().with_poll_interval(Duration::from_millis(500)),
            );

        // When: we create a Poll variant debouncer
        let mut debouncer =
            AnyDebouncer::Poll(new_debouncer_opt::<_, PollWatcher>(config, tx).unwrap());

        // Then: we can watch a directory through the trait object
        let result = debouncer.watcher().watch(temp_dir.path(), NonRecursive);
        assert!(result.is_ok());
    }

    #[test]
    fn test_any_debouncer_recommended_variant_creates_working_watcher() {
        // Given: a channel for file events and a temp directory to watch
        let (tx, _rx) = mpsc::channel();
        let temp_dir = TempDir::new().unwrap();

        let config = DebouncerConfig::default()
            .with_timeout(Duration::from_millis(100))
            .with_notify_config(
                notify::Config::default().with_poll_interval(Duration::from_millis(500)),
            );

        // When: we create a Recommended variant debouncer
        let mut debouncer = AnyDebouncer::Recommended(
            new_debouncer_opt::<_, RecommendedWatcher>(config, tx).unwrap(),
        );

        // Then: we can watch a directory through the trait object
        let result = debouncer.watcher().watch(temp_dir.path(), NonRecursive);
        assert!(result.is_ok());
    }
}
