use super::*;

// ── Phase 1: window split tests ────────────────────────────────────────────

#[test]
fn sp_splits_focused_window() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.windows.len(), 1);
    assert_eq!(app.focused_window(), 0);

    app.dispatch_ex("sp");

    // A second window should now exist.
    assert_eq!(
        app.windows.iter().filter(|w| w.is_some()).count(),
        2,
        "expected 2 open windows after :sp"
    );
    // Focus moved to the new (upper) window.
    let new_win_id = app.focused_window();
    assert_ne!(new_win_id, 0, "focus must have moved to the new window");
    // The layout should no longer be a single leaf.
    assert!(
        app.layout().leaves().len() == 2,
        "layout must have 2 leaves after split"
    );
}

#[test]
fn close_focused_window_collapses_split() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    assert_eq!(app.windows.iter().filter(|w| w.is_some()).count(), 2);
    let focused_before_close = app.focused_window();

    app.dispatch_ex("close");

    // After closing, the closed window's entry is None.
    assert!(
        app.windows[focused_before_close].is_none(),
        "closed window entry must be None"
    );
    // Layout should be back to a single leaf.
    assert_eq!(
        app.layout().leaves().len(),
        1,
        "layout must collapse to 1 leaf"
    );
    // Status confirms closure.
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("window closed"),
        "expected 'window closed' status"
    );
}

#[test]
fn close_last_window_errors() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.windows.iter().filter(|w| w.is_some()).count(), 1);

    app.dispatch_ex("close");

    // Must not close the only window.
    assert_eq!(
        app.windows.iter().filter(|w| w.is_some()).count(),
        1,
        "last window must not be closed"
    );
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E444"), "expected E444 error, got: {msg}");
}

#[test]
fn ctrl_w_j_focuses_below() {
    let mut app = App::new(None, false, None, None).unwrap();
    // After :sp, focused window is the new (top) one.
    app.dispatch_ex("sp");
    let top_win = app.focused_window();

    // Ctrl-w j should move focus to the window below (the original).
    app.focus_below();
    let bottom_win = app.focused_window();
    assert_ne!(top_win, bottom_win, "focus must have moved down");
    // Moving below from bottom-most is a no-op.
    app.focus_below();
    assert_eq!(
        app.focused_window(),
        bottom_win,
        "focus must not move below the bottom-most window"
    );
}

#[test]
fn ctrl_w_k_focuses_above() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    // Currently on top — move to bottom first.
    app.focus_below();
    let bottom_win = app.focused_window();

    // Ctrl-w k should move back up.
    app.focus_above();
    let top_win = app.focused_window();
    assert_ne!(bottom_win, top_win, "focus must have moved up");
    // Moving above from top-most is a no-op.
    app.focus_above();
    assert_eq!(
        app.focused_window(),
        top_win,
        "focus must not move above the top-most window"
    );
}

#[test]
fn non_focused_window_keeps_scroll_after_focused_scrolls() {
    let mut app = App::new(None, false, None, None).unwrap();
    // Fill the buffer with enough lines so scrolling has room.
    let content: String = (0..100)
        .map(|i| format!("line {i}"))
        .collect::<Vec<_>>()
        .join("\n");
    seed_buffer(&mut app, &content);

    // Split: new window on top (id = 1 typically), original below (id = 0).
    app.dispatch_ex("sp");
    let top_win = app.focused_window();
    // Move focus to bottom window.
    app.focus_below();
    let bottom_win = app.focused_window();
    assert_ne!(top_win, bottom_win);

    // Record top window's scroll before we scroll the bottom one.
    let top_top_row_before = app.window_scroll(top_win).0;

    // Manually advance bottom window's scroll to simulate scrolling (#151
    // Phase D: scroll lives on the window's own editor).
    app.window_editors
        .get_mut(&bottom_win)
        .unwrap()
        .host_mut()
        .viewport_mut()
        .top_row = 20;

    // Top window's scroll must be unaffected.
    let top_top_row_after = app.window_scroll(top_win).0;
    assert_eq!(
        top_top_row_before, top_top_row_after,
        "non-focused window scroll must not change when focused window scrolls"
    );
}

// ── Phase 2: vertical split tests ─────────────────────────────────────────────

#[test]
fn vsp_creates_vertical_split_with_new_on_left() {
    let mut app = App::new(None, false, None, None).unwrap();
    let original_win = app.focused_window();

    app.dispatch_ex("vsp");

    // Two windows now exist.
    assert_eq!(
        app.windows.iter().filter(|w| w.is_some()).count(),
        2,
        "expected 2 open windows after :vsp"
    );
    // Layout has 2 leaves.
    assert_eq!(app.layout().leaves().len(), 2, "layout must have 2 leaves");

    // Focus moved to the new (left) window.
    let new_win = app.focused_window();
    assert_ne!(new_win, original_win, "focus must have moved to new window");

    // New window is on the left (a-side) of a Vertical split — its
    // neighbor_right is the original window.
    let right = app.layout().neighbor_right(new_win);
    assert_eq!(
        right,
        Some(original_win),
        "original window must be to the right of the new one"
    );

    // Status says "vsplit".
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("vsplit"),
        "expected 'vsplit' status, got: {msg}"
    );
}

#[test]
fn vnew_creates_empty_buffer_in_left_split() {
    let mut app = App::new(None, false, None, None).unwrap();
    let original_win = app.focused_window();

    app.dispatch_ex("vnew");

    assert_eq!(
        app.windows.iter().filter(|w| w.is_some()).count(),
        2,
        "expected 2 open windows after :vnew"
    );
    assert_eq!(app.layout().leaves().len(), 2);

    let new_win = app.focused_window();
    assert_ne!(new_win, original_win);

    // The new window points at an unnamed empty slot.
    let new_slot_idx = app.windows[new_win].as_ref().unwrap().slot;
    assert!(
        app.slots[new_slot_idx].filename.is_none(),
        "vnew window must point to an unnamed slot"
    );

    // Status says "vnew".
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("vnew"), "expected 'vnew' status, got: {msg}");
}

#[test]
fn ctrl_w_lt_resize_width_negative_registers() {
    // Regression: the binding string `<C-w><` failed to parse because the
    // trailing bare `<` was interpreted as an unclosed tag. Fix is to use
    // the `<lt>` escape. Verify that the chord registers via app_keymap and
    // resolves to AppAction::ResizeWidth(-1).
    use hjkl_keymap::{
        Chord, KeyCode, KeyEvent as KmKeyEvent, KeyModifiers as KmKeyMods, KeyResolve,
    };
    use hjkl_vim::Mode;
    let mut app = App::new(None, false, None, None).unwrap();

    let ctrl_w = KmKeyEvent::new(KeyCode::Char('w'), KmKeyMods::CTRL);
    let lt = KmKeyEvent::new(KeyCode::Char('<'), KmKeyMods::NONE);
    let chord = Chord(vec![ctrl_w, lt]);

    // children() must show `<` reachable from `<C-w>` prefix.
    let kids = app.app_keymap.children(Mode::Normal, &Chord(vec![ctrl_w]));
    assert!(
        kids.iter().any(|(k, _)| *k == lt),
        "<C-w><lt> binding must register; kids: {kids:?}"
    );

    // Drive the chord and assert it matches ResizeWidth(-1).
    let r1 = app
        .app_keymap
        .feed(Mode::Normal, ctrl_w, std::time::Instant::now());
    assert!(matches!(r1, KeyResolve::Pending | KeyResolve::Ambiguous));
    let r2 = app
        .app_keymap
        .feed(Mode::Normal, lt, std::time::Instant::now());
    match r2 {
        KeyResolve::Match(binding) => {
            assert!(matches!(
                binding.action,
                crate::keymap_actions::AppAction::ResizeWidth(-1)
            ));
        }
        other => panic!("expected Match(ResizeWidth(-1)), got {other:?}"),
    }
    // Silence unused warning if Chord is exported but not used in current build flags.
    let _ = chord;
}

#[test]
fn ctrl_w_h_focuses_left() {
    let mut app = App::new(None, false, None, None).unwrap();
    // After :vsp, focus is on the new left window.
    app.dispatch_ex("vsp");
    let left_win = app.focused_window();

    // Can't go further left — no-op.
    app.focus_left();
    assert_eq!(
        app.focused_window(),
        left_win,
        "focus_left from leftmost must be a no-op"
    );

    // Move right first, then come back left.
    app.focus_right();
    let right_win = app.focused_window();
    assert_ne!(left_win, right_win, "focus must have moved right");

    app.focus_left();
    assert_eq!(
        app.focused_window(),
        left_win,
        "focus_left must return to left window"
    );
}

#[test]
fn ctrl_w_l_focuses_right() {
    let mut app = App::new(None, false, None, None).unwrap();
    // After :vsp, focus is on the left (new) window.
    app.dispatch_ex("vsp");
    let left_win = app.focused_window();

    // Move right.
    app.focus_right();
    let right_win = app.focused_window();
    assert_ne!(left_win, right_win, "focus must have moved right");

    // Can't go further right — no-op.
    app.focus_right();
    assert_eq!(
        app.focused_window(),
        right_win,
        "focus_right from rightmost must be a no-op"
    );
}

#[test]
fn ctrl_w_w_cycles_next() {
    let mut app = App::new(None, false, None, None).unwrap();
    // Create two windows via :sp.
    app.dispatch_ex("sp");
    let leaves = app.layout().leaves();
    assert_eq!(leaves.len(), 2);

    // From the current focused window, next should cycle.
    let initial = app.focused_window();
    app.focus_next();
    let after_one = app.focused_window();
    assert_ne!(initial, after_one, "focus_next must move focus");
    app.focus_next();
    let after_two = app.focused_window();
    // With 2 windows, two focus_next should bring us back.
    assert_eq!(after_two, initial, "two focus_next calls must wrap around");
}

#[test]
fn ctrl_w_shift_w_cycles_previous() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");

    let initial = app.focused_window();
    app.focus_previous();
    let after_one = app.focused_window();
    assert_ne!(initial, after_one, "focus_previous must move focus");
    app.focus_previous();
    let after_two = app.focused_window();
    assert_eq!(
        after_two, initial,
        "two focus_previous calls must wrap around"
    );
}

// ── Phase 3: window resize tests ─────────────────────────────────────────────

/// Inject a Rect into the innermost Split node that contains `id`.
/// Used instead of running a full render so resize methods have a
/// populated `last_rect` to work from.
#[test]
fn resize_height_grows_focused_window() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    // ratio starts at 0.5. Inject a 40-row rect so delta=2 is meaningful.
    let rect = ratatui::layout::Rect {
        x: 0,
        y: 0,
        width: 80,
        height: 40,
    };
    let fw = app.focused_window();
    inject_split_rect(app.layout_mut(), fw, rect);

    let ratio_before = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    app.resize_height(2);

    let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    // focused is in `a` (top), growing by 2 rows of 40 increases ratio.
    assert!(
        ratio_after > ratio_before,
        "ratio should grow when resizing focused (top) window: before={ratio_before} after={ratio_after}"
    );
}

#[test]
fn resize_height_clamps_at_minimum() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    let rect = ratatui::layout::Rect {
        x: 0,
        y: 0,
        width: 80,
        height: 10,
    };
    let fw = app.focused_window();
    inject_split_rect(app.layout_mut(), fw, rect);

    // Try to shrink by far more than available — should clamp, not underflow.
    app.resize_height(-1000);

    let ratio = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };
    // ratio must be at least 0.01 (our clamp) and sibling must have ≥ 1 row.
    assert!(ratio >= 0.01, "ratio must be >= 0.01 after clamp: {ratio}");
    assert!(
        ratio < 1.0,
        "ratio must be < 1.0 (sibling needs at least 1 row): {ratio}"
    );
}

#[test]
fn resize_width_grows_focused_window() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("vsp");
    let rect = ratatui::layout::Rect {
        x: 0,
        y: 0,
        width: 80,
        height: 24,
    };
    let fw = app.focused_window();
    inject_split_rect(app.layout_mut(), fw, rect);

    let ratio_before = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    app.resize_width(4);

    let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    // focused is in `a` (left), growing by 4 columns of 80 increases ratio.
    assert!(
        ratio_after > ratio_before,
        "ratio should grow when resizing focused (left) window: before={ratio_before} after={ratio_after}"
    );
}

#[test]
fn equalize_layout_resets_uneven_splits() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");

    // Manually skew the ratio.
    if let window::LayoutTree::Split { ratio, .. } = app.layout_mut() {
        *ratio = 0.3;
    }

    app.equalize_layout();

    let ratio = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };
    assert!(
        (ratio - 0.5).abs() < 1e-5,
        "equalize should reset ratio to 0.5, got {ratio}"
    );
}

#[test]
fn maximize_height_collapses_siblings() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    let rect = ratatui::layout::Rect {
        x: 0,
        y: 0,
        width: 80,
        height: 24,
    };
    let fw = app.focused_window();
    inject_split_rect(app.layout_mut(), fw, rect);

    app.maximize_height();

    let ratio = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };
    // Focused is in `a` (top). Maximized means ratio ≈ (height-1)/height = 23/24.
    let expected = 23.0_f32 / 24.0;
    assert!(
        (ratio - expected).abs() < 0.05,
        "maximize_height should set ratio near {expected}, got {ratio}"
    );
}

#[test]
fn ctrl_w_plus_grows_focused() {
    // Ctrl-w '+' resolves through the keymap trie to AppAction::ResizeHeight(1);
    // exercise the underlying resize_height(1) call here without a real terminal.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    let rect = ratatui::layout::Rect {
        x: 0,
        y: 0,
        width: 80,
        height: 40,
    };
    let fw = app.focused_window();
    inject_split_rect(app.layout_mut(), fw, rect);

    let ratio_before = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };

    // This is what event_loop.rs dispatches for Ctrl-w '+'.
    app.resize_height(1);

    let ratio_after = if let window::LayoutTree::Split { ratio, .. } = app.layout() {
        *ratio
    } else {
        panic!("expected Split");
    };
    assert!(
        ratio_after > ratio_before,
        "Ctrl-w + must grow the focused window: before={ratio_before} after={ratio_after}"
    );
}

// ── Phase 4: :only / swap / :new / :q redirect tests ──────────────────────────

#[test]
fn only_drops_other_windows() {
    let mut app = App::new(None, false, None, None).unwrap();
    // Create two extra windows: sp twice gives us 3 total.
    app.dispatch_ex("sp");
    app.dispatch_ex("sp");
    assert_eq!(
        app.layout().leaves().len(),
        3,
        "expected 3 windows before :only"
    );

    // Focus is on the most recently created window.
    let focused = app.focused_window();
    app.dispatch_ex("only");

    // Layout must collapse to a single leaf.
    assert_eq!(
        app.layout().leaves(),
        vec![focused],
        "only focused leaf should remain"
    );
    // All other window slots must be None.
    let open_count = app.windows.iter().filter(|w| w.is_some()).count();
    assert_eq!(open_count, 1, "exactly one window must remain open");
    // The remaining open window is the focused one.
    assert!(
        app.windows[focused].is_some(),
        "focused window must still be open"
    );
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("only"), "expected 'only' status, got: {msg}");
}

#[test]
fn only_no_op_with_single_window() {
    let mut app = App::new(None, false, None, None).unwrap();
    let focused = app.focused_window();
    app.dispatch_ex("only");
    // Still one window, still the same focused window.
    assert_eq!(app.layout().leaves(), vec![focused]);
    assert_eq!(app.windows.iter().filter(|w| w.is_some()).count(), 1);
}

#[test]
fn new_creates_horizontal_split_empty_buffer() {
    let mut app = App::new(None, false, None, None).unwrap();
    let original_win = app.focused_window();

    app.dispatch_ex("new");

    // Two windows now exist.
    assert_eq!(
        app.windows.iter().filter(|w| w.is_some()).count(),
        2,
        "expected 2 open windows after :new"
    );
    assert_eq!(app.layout().leaves().len(), 2, "layout must have 2 leaves");

    // Focus moved to the new window.
    let new_win = app.focused_window();
    assert_ne!(new_win, original_win, "focus must have moved to new window");

    // The new window points at an unnamed empty slot.
    let new_slot_idx = app.windows[new_win].as_ref().unwrap().slot;
    assert!(
        app.slots[new_slot_idx].filename.is_none(),
        ":new window must point to an unnamed slot"
    );

    // The layout is a horizontal split (new window on top, original below).
    let below = app.layout().neighbor_below(new_win);
    assert_eq!(
        below,
        Some(original_win),
        "original window must be below the new one"
    );

    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("new"), "expected 'new' status, got: {msg}");
}

#[test]
fn ctrl_w_o_invokes_only() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    assert_eq!(app.layout().leaves().len(), 2);

    let focused = app.focused_window();
    app.only_focused_window();

    assert_eq!(app.layout().leaves(), vec![focused]);
    assert_eq!(app.windows.iter().filter(|w| w.is_some()).count(), 1);
}

#[test]
fn ctrl_w_x_swaps_with_sibling() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");

    // After :sp, layout is: hsplit(new_win, original_win).
    // leaves() order should be [new_win, original_win].
    let leaves_before = app.layout().leaves();
    assert_eq!(leaves_before.len(), 2);

    app.swap_with_sibling();

    let leaves_after = app.layout().leaves();
    // The two leaves should be swapped in pre-order.
    assert_eq!(
        leaves_after,
        vec![leaves_before[1], leaves_before[0]],
        "swap_with_sibling must reverse the leaf order"
    );

    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("swap"), "expected 'swap' status, got: {msg}");
}

#[test]
fn ctrl_w_n_creates_horizontal_empty_split() {
    let mut app = App::new(None, false, None, None).unwrap();
    let original_win = app.focused_window();

    // Simulate Ctrl-w n by calling dispatch_ex("new") — same path.
    app.dispatch_ex("new");

    assert_eq!(app.layout().leaves().len(), 2);
    let new_win = app.focused_window();
    assert_ne!(new_win, original_win);

    // New window is on top (a-side), original is below.
    let below = app.layout().neighbor_below(new_win);
    assert_eq!(below, Some(original_win));
}

#[test]
fn ctrl_w_q_closes_window_when_multiple() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    assert_eq!(app.layout().leaves().len(), 2);

    let focused_before = app.focused_window();

    // Simulate Ctrl-w q behavior.
    if app.layout().leaves().len() > 1 {
        app.close_focused_window();
    } else {
        app.exit_requested = true;
    }

    assert!(
        !app.exit_requested,
        "Ctrl-w q must not quit with multiple windows"
    );
    assert!(
        app.windows[focused_before].is_none(),
        "focused window must be closed"
    );
    assert_eq!(app.layout().leaves().len(), 1, "layout must collapse");
}

#[test]
fn ctrl_w_q_quits_when_last() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.layout().leaves().len(), 1);

    // Simulate Ctrl-w q behavior.
    if app.layout().leaves().len() > 1 {
        app.close_focused_window();
    } else {
        app.exit_requested = true;
    }

    assert!(app.exit_requested, "Ctrl-w q must quit when last window");
}

#[test]
fn colon_q_closes_window_when_multiple() {
    // Vim parity: :q with multiple windows closes the focused window,
    // not the application.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    assert_eq!(app.layout().leaves().len(), 2);
    let focused_before = app.focused_window();

    app.dispatch_ex("q");

    assert!(
        !app.exit_requested,
        ":q must not quit with multiple windows"
    );
    assert!(
        app.windows[focused_before].is_none(),
        "focused window must be closed by :q"
    );
    assert_eq!(app.layout().leaves().len(), 1, "layout must collapse to 1");
}

#[test]
fn colon_q_quits_when_last() {
    // :q with a single window and clean buffer should exit the app.
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.layout().leaves().len(), 1);
    assert!(!app.active().dirty);

    app.dispatch_ex("q");

    assert!(app.exit_requested, ":q on last window must exit");
}

// ── Phase tab: vim-style tab tests ──────────────────────────────────────────

#[test]
fn tabnew_creates_second_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.tabs.len(), 1);
    assert_eq!(app.active_tab, 0);
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2, "tabnew must create a second tab");
    assert_eq!(app.active_tab, 1, "active_tab must advance to the new tab");
}

#[test]
fn tabnew_no_arg_uses_empty_buffer() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2);
    // New tab's focused window must point at an unnamed empty slot.
    let tab = &app.tabs[app.active_tab];
    let slot_idx = app.windows[tab.focused_window].as_ref().unwrap().slot;
    assert!(
        app.slots[slot_idx].filename.is_none(),
        "tabnew with no arg must use an unnamed buffer"
    );
    let lines = rope_to_lines_vec(&app.slots[slot_idx].buffer().rope());
    assert!(
        lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()),
        "tabnew buffer must be empty"
    );
}

#[test]
fn tabnext_wraps_at_end() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 3);
    // active = 2 (last)
    assert_eq!(app.active_tab, 2);
    // tabnext should wrap to 0
    app.dispatch_ex("tabnext");
    assert_eq!(app.active_tab, 0, "tabnext must wrap to the first tab");
}

// ── Phase 4a: host-registry tabnext tests ───────────────────────────────────

#[test]
fn colon_tabnext_via_host_registry() {
    // Two-tab setup: verify tabnext advances active_tab via host registry.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.active_tab, 1);
    // Go back to 0 so we can advance forward.
    app.active_tab = 0;
    app.dispatch_ex("tabnext");
    assert_eq!(app.active_tab, 1, "tabnext must advance active_tab");
}

#[test]
fn colon_tabn_alias_via_host_registry() {
    // Same test using the `tabn` alias.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.active_tab, 1);
    app.active_tab = 0;
    app.dispatch_ex("tabn");
    assert_eq!(app.active_tab, 1, "tabn alias must advance active_tab");
}

#[test]
fn tabprev_wraps_at_start() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 3);
    // Go back to tab 0 first.
    app.dispatch_ex("tabnext");
    assert_eq!(app.active_tab, 0);
    // tabprev from 0 should wrap to 2 (last).
    app.dispatch_ex("tabprev");
    assert_eq!(app.active_tab, 2, "tabprev must wrap to the last tab");
}

#[test]
fn tabclose_removes_current_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2);
    assert_eq!(app.active_tab, 1);
    app.dispatch_ex("tabclose");
    assert_eq!(app.tabs.len(), 1, "tabclose must remove the current tab");
    assert_eq!(app.active_tab, 0, "active_tab must fall back to 0");
}

#[test]
fn tabclose_prunes_window_folds_and_editors_for_split_tab() {
    // Close a tab that has an internal split — both window ids it owned
    // must be dropped from window_folds and window_editors, not just from
    // `windows`. Otherwise the dead ids keep pinning the old slot's rope
    // Arc, and (in the worst case) get silently reused as fresh window ids
    // pick up stale fold state.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew"); // tab 1 (active) — populates window_folds for its window.
    app.dispatch_ex("sp"); // split tab 1 into two windows; new one inherits folds.
    assert_eq!(app.tabs.len(), 2);
    assert_eq!(app.active_tab, 1);
    let closing_wids = app.tabs[1].layout.leaves();
    assert_eq!(closing_wids.len(), 2, "setup: tab 1 must have a split");
    assert!(
        closing_wids
            .iter()
            .all(|w| app.window_folds.contains_key(w)),
        "setup: both split windows must have a folds entry"
    );
    assert!(
        closing_wids
            .iter()
            .all(|w| app.window_editors.contains_key(w)),
        "setup: both split windows must have a view editor"
    );

    app.dispatch_ex("tabclose");
    assert_eq!(app.tabs.len(), 1, "tabclose must remove tab 1");

    for wid in &closing_wids {
        assert!(
            !app.window_folds.contains_key(wid),
            "tabclose must prune window_folds for closed window {wid:?}"
        );
        assert!(
            !app.window_editors.contains_key(wid),
            "tabclose must prune window_editors for closed window {wid:?}"
        );
    }
}

#[test]
fn tabclose_last_tab_errors() {
    let mut app = App::new(None, false, None, None).unwrap();
    assert_eq!(app.tabs.len(), 1);
    app.dispatch_ex("tabclose");
    // Must refuse — only one tab.
    assert_eq!(app.tabs.len(), 1, "tabclose must not close the last tab");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("E444"), "expected E444 error, got: {msg}");
}

#[test]
fn gt_switches_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2);
    app.dispatch_ex("tabprev");
    assert_eq!(app.active_tab, 0);
    drive_key(&mut app, key(KeyCode::Char('g')));
    drive_key(&mut app, key(KeyCode::Char('t')));
    assert_eq!(app.active_tab, 1, "gt must advance to the next tab");
}

#[test]
fn gt_switches_tab_backward() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2);
    assert_eq!(app.active_tab, 1);
    drive_key(&mut app, key(KeyCode::Char('g')));
    drive_key(
        &mut app,
        crossterm::event::KeyEvent::new(KeyCode::Char('T'), crossterm::event::KeyModifiers::SHIFT),
    );
    assert_eq!(app.active_tab, 0, "gT must switch to the previous tab");
}

#[test]
fn gt_with_explicit_count_is_absolute() {
    // `:h gt` — `{count}gt` goes to tab page {count} (1-indexed, ABSOLUTE),
    // unlike bare `gt` which goes to the next tab (relative, with wrap).
    // Pre-fix, `{count}gt` ran `count` repetitions of relative tabnext:
    // from tab 1 (index 0) with 3 tabs, `2gt` would land on tab 3 (index 2)
    // instead of tab 2 (index 1).
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 3, "setup: need 3 tabs");
    app.dispatch_ex("tabfirst");
    assert_eq!(app.active_tab, 0);

    app.pending_count.try_accumulate('2');
    drive_key(&mut app, key(KeyCode::Char('g')));
    drive_key(&mut app, key(KeyCode::Char('t')));
    assert_eq!(
        app.active_tab, 1,
        "2gt must land on tab page 2 (index 1), absolute"
    );

    // `1gt` (explicit count=1) must jump to tab page 1, even though the
    // count value alone is indistinguishable from a bare `gt`'s default.
    app.pending_count.try_accumulate('1');
    drive_key(&mut app, key(KeyCode::Char('g')));
    drive_key(&mut app, key(KeyCode::Char('t')));
    assert_eq!(
        app.active_tab, 0,
        "1gt must land on tab page 1 (index 0), absolute — not act like bare gt"
    );
}

#[test]
fn each_tab_keeps_independent_layout() {
    let mut app = App::new(None, false, None, None).unwrap();
    // Tab 0: split into 2 windows.
    app.dispatch_ex("sp");
    let tab0_leaves = app.tabs[0].layout.leaves().len();
    assert_eq!(tab0_leaves, 2, "tab 0 must have 2 leaves after :sp");

    // Open tab 1 (fresh — single leaf).
    app.dispatch_ex("tabnew");
    assert_eq!(app.active_tab, 1);
    let tab1_leaves = app.tabs[1].layout.leaves().len();
    assert_eq!(tab1_leaves, 1, "tab 1 must start with 1 leaf");

    // Switch back to tab 0 and verify its layout is still 2 leaves.
    app.dispatch_ex("tabprev");
    assert_eq!(app.active_tab, 0);
    let tab0_leaves_after = app.tabs[0].layout.leaves().len();
    assert_eq!(
        tab0_leaves_after, 2,
        "tab 0 layout must be preserved after switching tabs"
    );
}

// ── Phase 2 tab tests ────────────────────────────────────────────────────────

#[test]
fn tabfirst_jumps_to_first() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.active_tab, 2);
    app.dispatch_ex("tabfirst");
    assert_eq!(app.active_tab, 0, "tabfirst must jump to tab 0");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("tab 1/"),
        "expected 'tab 1/N' status, got: {msg}"
    );
}

#[test]
fn tabfirst_noop_when_already_first() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabfirst");
    assert_eq!(app.active_tab, 0);
    // Should still produce a status message (not an error).
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("tab 1/"),
        "no-op must still report position: {msg}"
    );
}

#[test]
fn tabrewind_and_tabr_are_aliases_for_tabfirst() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.active_tab, 2);
    app.dispatch_ex("tabrewind");
    assert_eq!(app.active_tab, 0);
    app.dispatch_ex("tabnext");
    app.dispatch_ex("tabr");
    assert_eq!(app.active_tab, 0);
}

#[test]
fn tablast_jumps_to_last() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    // Go back to tab 0.
    app.dispatch_ex("tabfirst");
    assert_eq!(app.active_tab, 0);
    app.dispatch_ex("tablast");
    assert_eq!(app.active_tab, 2, "tablast must jump to the last tab");
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(
        msg.contains("tab 3/3"),
        "expected 'tab 3/3' status, got: {msg}"
    );
}

#[test]
fn tablast_noop_when_already_last() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    // active_tab is already 1 (last).
    app.dispatch_ex("tablast");
    assert_eq!(app.active_tab, 1);
    let msg = app.bus.last_body_or_empty().to_string();
    assert!(msg.contains("tab 2/2"), "no-op must report position: {msg}");
}

#[test]
fn tabonly_drops_other_tabs() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 3);
    // Window ids that belong to the tabs about to be closed (0 and 1).
    let closing_wids: Vec<_> = app.tabs[0]
        .layout
        .leaves()
        .into_iter()
        .chain(app.tabs[1].layout.leaves())
        .collect();
    assert!(
        closing_wids
            .iter()
            .any(|w| app.window_folds.contains_key(w)),
        "setup: at least one closing window must have a folds entry"
    );
    // Stay on tab 2. Run tabonly — should reduce to 1 tab.
    app.dispatch_ex("tabonly");
    assert_eq!(app.tabs.len(), 1, "tabonly must close all other tabs");
    assert_eq!(app.active_tab, 0, "active_tab must be reset to 0");
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(msg, "tabonly");
    for wid in &closing_wids {
        assert!(
            !app.window_folds.contains_key(wid),
            "tabonly must prune window_folds for closed window {wid:?}"
        );
        assert!(
            !app.window_editors.contains_key(wid),
            "tabonly must prune window_editors for closed window {wid:?}"
        );
    }
}

#[test]
fn tabonly_no_op_with_single_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabonly");
    assert_eq!(app.tabs.len(), 1, "tabonly on single tab must stay at 1");
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(msg, "tabonly", "must report success even as no-op");
}

#[test]
fn tabo_is_alias_for_tabonly() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 3);
    app.dispatch_ex("tabo");
    assert_eq!(app.tabs.len(), 1);
}

// ── close_tabs_to_right / close_tabs_to_left ─────────────────────────────────

#[test]
fn close_tabs_to_right_leaves_active_and_earlier() {
    // 4 tabs: 0, 1, 2, 3. Active = 2. After: tabs 0,1,2 remain; active still 2.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew"); // tab 1
    app.dispatch_ex("tabnew"); // tab 2
    app.dispatch_ex("tabnew"); // tab 3 (active)
    assert_eq!(app.tabs.len(), 4);
    // Navigate back to tab 2.
    app.dispatch_ex("tabprev");
    assert_eq!(app.active_tab, 2);
    let closing_wid = app.tabs[3].layout.leaves()[0];
    assert!(
        app.window_folds.contains_key(&closing_wid),
        "setup: closing window must have a folds entry"
    );
    app.close_tabs_to_right();
    assert_eq!(app.tabs.len(), 3, "expected 3 tabs remaining (0, 1, 2)");
    assert_eq!(app.active_tab, 2, "active_tab must stay at 2");
    assert!(
        !app.window_folds.contains_key(&closing_wid),
        "close_tabs_to_right must prune window_folds for closed window {closing_wid:?}"
    );
    assert!(
        !app.window_editors.contains_key(&closing_wid),
        "close_tabs_to_right must prune window_editors for closed window {closing_wid:?}"
    );
}

#[test]
fn close_tabs_to_left_shifts_active_to_zero() {
    // 4 tabs: 0, 1, 2, 3. Active = 2. After: tabs 2,3 remain; active = 0.
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew"); // tab 1
    app.dispatch_ex("tabnew"); // tab 2
    app.dispatch_ex("tabnew"); // tab 3 (active)
    // Navigate back to tab 2.
    app.dispatch_ex("tabprev");
    assert_eq!(app.active_tab, 2);
    let closing_wids: Vec<_> = app.tabs[0]
        .layout
        .leaves()
        .into_iter()
        .chain(app.tabs[1].layout.leaves())
        .collect();
    assert!(
        closing_wids
            .iter()
            .any(|w| app.window_folds.contains_key(w)),
        "setup: at least one closing window must have a folds entry"
    );
    app.close_tabs_to_left();
    assert_eq!(
        app.tabs.len(),
        2,
        "expected 2 tabs remaining (originally 2, 3)"
    );
    assert_eq!(app.active_tab, 0, "active_tab must shift to 0");
    for wid in &closing_wids {
        assert!(
            !app.window_folds.contains_key(wid),
            "close_tabs_to_left must prune window_folds for closed window {wid:?}"
        );
        assert!(
            !app.window_editors.contains_key(wid),
            "close_tabs_to_left must prune window_editors for closed window {wid:?}"
        );
    }
}

#[test]
fn close_tabs_to_right_noop_on_last_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2);
    assert_eq!(app.active_tab, 1); // last tab
    app.close_tabs_to_right();
    assert_eq!(app.tabs.len(), 2, "no-op when already last");
    assert_eq!(app.active_tab, 1);
}

#[test]
fn close_tabs_to_left_noop_on_first_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    assert_eq!(app.tabs.len(), 2);
    // Go back to tab 0.
    app.dispatch_ex("tabprev");
    assert_eq!(app.active_tab, 0); // first tab
    app.close_tabs_to_left();
    assert_eq!(app.tabs.len(), 2, "no-op when already first");
    assert_eq!(app.active_tab, 0);
}

#[test]
fn tabmove_no_arg_moves_to_end() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    // Go to tab 0.
    app.dispatch_ex("tabfirst");
    assert_eq!(app.active_tab, 0);
    // tabmove with no arg: move tab 0 to the end.
    app.dispatch_ex("tabmove");
    assert_eq!(app.active_tab, 2, "tab should now be at position 2 (end)");
    let msg = app.bus.last_body_or_empty().to_string();
    assert_eq!(msg, "tabmove");
}

#[test]
fn tabmove_to_position_zero() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    // active is tab 2, move it to position 0.
    app.dispatch_ex("tabmove 0");
    assert_eq!(app.active_tab, 0, "tab should now be at position 0");
}

#[test]
fn tabmove_relative_plus_one() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    // Go to tab 0.
    app.dispatch_ex("tabfirst");
    assert_eq!(app.active_tab, 0);
    // Move tab 0 forward by 1.
    app.dispatch_ex("tabmove +1");
    assert_eq!(app.active_tab, 1, "tab should now be at position 1");
}

#[test]
fn tabmove_relative_minus_one() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    assert_eq!(app.active_tab, 2);
    // Move tab 2 back by 1.
    app.dispatch_ex("tabmove -1");
    assert_eq!(app.active_tab, 1, "tab should now be at position 1");
}

#[test]
fn tabmove_clamps_out_of_range() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    // 2 tabs total. Try to move tab 1 to position 99.
    app.dispatch_ex("tabmove 99");
    assert_eq!(app.active_tab, 1, "out-of-range must clamp to last");
    // Try to move to negative (via -99).
    app.dispatch_ex("tabmove -99");
    assert_eq!(app.active_tab, 0, "large negative must clamp to 0");
}

#[test]
fn tabs_listing_marks_active_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("tabnew");
    app.dispatch_ex("tabnew");
    // Active tab is 2.
    app.dispatch_ex("tabs");
    let popup_content = app
        .info_popup
        .as_ref()
        .map(|p| p.content.clone())
        .unwrap_or_default();
    // Should contain 3 "Tab page N" entries.
    assert!(popup_content.contains("Tab page 1"), "missing Tab page 1");
    assert!(popup_content.contains("Tab page 2"), "missing Tab page 2");
    assert!(popup_content.contains("Tab page 3"), "missing Tab page 3");
    // The active tab (3) must have '>'; others must have ' '.
    let lines: Vec<&str> = popup_content.lines().collect();
    // Lines alternate: "Tab page N", "<marker> <name>".
    // tab1 marker is lines[1], tab2 marker is lines[3], tab3 marker is lines[5].
    assert!(
        lines[1].starts_with("  ") || lines[1].starts_with(' '),
        "tab 1 must be inactive"
    );
    assert!(
        lines[5].starts_with("> "),
        "tab 3 (active) must show '>': {:?}",
        lines[5]
    );
}

#[test]
fn move_window_to_new_tab_creates_new_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    // Create a horizontal split so tab 0 has 2 windows.
    app.dispatch_ex("sp");
    assert_eq!(app.tabs[0].layout.leaves().len(), 2);
    let focused_before = app.focused_window();

    app.move_window_to_new_tab()
        .expect("should succeed with 2 windows");
    // A new tab should have been created.
    assert_eq!(app.tabs.len(), 2, "must create a second tab");
    assert_eq!(app.active_tab, 1, "must switch to the new tab");
    // The new tab must contain only the moved window.
    assert_eq!(app.tabs[1].layout.leaves(), vec![focused_before]);
    assert_eq!(app.tabs[1].focused_window, focused_before);
    // The old tab must now have only 1 window.
    assert_eq!(app.tabs[0].layout.leaves().len(), 1);
}

#[test]
fn move_window_to_new_tab_errors_when_only_window() {
    let mut app = App::new(None, false, None, None).unwrap();
    // Only one window — should fail.
    let result = app.move_window_to_new_tab();
    assert!(result.is_err(), "must error when only one window in tab");
    let msg = result.unwrap_err();
    assert!(msg.contains("E1"), "expected E1 error, got: {msg}");
    // No tab should be created.
    assert_eq!(app.tabs.len(), 1);
}

#[test]
fn ctrl_w_t_moves_window_to_new_tab() {
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    assert_eq!(app.tabs.len(), 1);

    drive_key(&mut app, ctrl_key('w'));
    drive_key(
        &mut app,
        crossterm::event::KeyEvent::new(KeyCode::Char('T'), crossterm::event::KeyModifiers::SHIFT),
    );

    assert_eq!(app.tabs.len(), 2, "Ctrl-w T must create a new tab");
}

// ── Docks (#63: window-management refactor) ───────────────────────────────
//
// The left dock (currently only the file explorer) is a pinned, fixed-size
// leaf of its tab's `LayoutTree` — see `app/dock.rs`. These tests pin the
// guarantees that come with that: tree ops that take a pinned set leave it
// alone, `<C-w>` navigation reaches it through ordinary tree adjacency,
// closing it collapses only its own split, and its width persists.

#[test]
fn dock_excluded_from_equalize_and_swap() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp"); // 2 tree windows
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab]
        .left_dock
        .as_ref()
        .expect("dock open")
        .win_id;
    assert_eq!(
        app.focused_window(),
        dock_win,
        "opening the explorer focuses its dock window"
    );

    // Equalize touches the tree only; must not close/move the dock, and
    // must not panic on a focused window that isn't a tree leaf at all.
    app.dispatch_action(AppAction::EqualizeLayout, 1);
    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "equalize must not close the dock"
    );
    assert_eq!(
        app.regular_leaf_count(),
        2,
        "equalize must not touch the regular window count"
    );

    // Swap: the dock is pinned — no-op, no panic, focus unmoved.
    app.dispatch_action(AppAction::SwapWithSibling, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_some());
    assert_eq!(
        app.focused_window(),
        dock_win,
        "swap must not move focus off the dock"
    );
}

#[test]
fn only_focused_window_on_dock_is_a_noop() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    app.dispatch_ex("sp");
    let leaves_before = app.regular_leaf_count();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    assert_eq!(app.focused_window(), dock_win);

    app.dispatch_action(AppAction::OnlyFocusedWindow, 1);

    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "only must leave the dock open"
    );
    assert_eq!(
        app.regular_leaf_count(),
        leaves_before,
        "`:only` from a special window must not collapse the regular \
         splits behind it (vim's behaviour)"
    );
}

#[test]
fn only_focused_window_from_tree_leaves_dock_open_and_collapses_tree() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    // Move focus back into the tree before `:only`.
    app.focus_right();
    let tree_win = app.focused_window();
    assert!(app.layout().contains(tree_win));

    app.dispatch_action(AppAction::OnlyFocusedWindow, 1);

    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "the dock must stay open across `:only`"
    );
    // The dock is pinned, so `only` keeps it (and its enclosing fixed
    // split) alongside the kept window and drops everything else.
    assert_eq!(
        app.layout().leaves(),
        vec![dock_win, tree_win],
        "`:only` must collapse the regular windows to the focused one, \
         leaving the pinned dock in place"
    );
}

#[test]
fn ctrl_w_h_l_crosses_frame_boundary_with_dock_open() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    let regular_win = app.focused_window();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    assert_eq!(app.focused_window(), dock_win);

    // C-w l: leave the dock, land back in the main area.
    app.focus_right();
    assert_eq!(
        app.focused_window(),
        regular_win,
        "C-w l from the dock must land in the main area"
    );

    // C-w h: re-enter the dock from the tree's leftmost (only) window.
    app.focus_left();
    assert_eq!(
        app.focused_window(),
        dock_win,
        "C-w h from the leftmost tree window must focus the dock"
    );

    // C-w h again: already at the dock, nothing further left — no-op.
    app.focus_left();
    assert_eq!(app.focused_window(), dock_win);
}

#[test]
fn ctrl_w_w_cycle_includes_the_dock() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    let regular_win = app.focused_window();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    assert_eq!(app.focused_window(), dock_win);

    app.focus_next();
    assert_eq!(
        app.focused_window(),
        regular_win,
        "C-w w from the dock must reach the regular window"
    );
    app.focus_next();
    assert_eq!(
        app.focused_window(),
        dock_win,
        "C-w w must wrap back around to the dock"
    );
}

#[test]
fn close_focused_window_on_dock_closes_the_dock_not_the_tree() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    let tree_leaves_before = app.regular_leaf_count();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_some());

    app.dispatch_action(AppAction::CloseFocusedWindow, 1);

    assert!(
        app.tabs[app.active_tab].left_dock.is_none(),
        "CloseFocusedWindow on the dock must close the dock"
    );
    assert!(app.tabs[app.active_tab].explorer.is_none());
    assert_eq!(
        app.regular_leaf_count(),
        tree_leaves_before,
        "only the dock's own split may collapse — the regular windows \
         must be untouched"
    );
}

#[test]
fn split_while_dock_focused_reroutes_instead_of_splitting_the_dock() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    let leaves_before = app.regular_leaf_count();

    app.dispatch_ex("sp");

    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "the dock must be untouched"
    );
    assert_eq!(
        app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id,
        dock_win
    );
    assert!(
        app.regular_leaf_count() > leaves_before,
        "the split must have happened in a regular window, not on the dock"
    );
    assert_ne!(
        app.focused_window(),
        dock_win,
        "focus after the split must be a regular window, not the dock"
    );
}

#[test]
fn move_window_to_new_tab_on_dock_is_a_clean_error_not_a_panic() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let tabs_before = app.tabs.len();

    app.dispatch_action(AppAction::MoveWindowToNewTab, 1);

    assert_eq!(
        app.tabs.len(),
        tabs_before,
        "docks can't be moved to a new tab"
    );
    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "the dock must remain open"
    );
}

/// `<C-w>T` on the last REGULAR window must refuse while a dock is open:
/// moving it to a new tab strands the dock as this tab's only window, which
/// is exactly the loss `:q` / `<C-w>c` / `<C-w>q` refuse. All four ask one
/// function (`detach_focused_leaf`, #63 Phase 5), so they cannot drift apart
/// — only the wording of the refusal differs (E1 here, E444 there).
#[test]
fn move_window_to_new_tab_refuses_the_last_regular_window_with_a_dock_open() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let editor = app
        .layout()
        .leaves()
        .into_iter()
        .find(|&id| !app.is_dock_window(id))
        .expect("one regular window");
    app.set_focused_window(editor);
    assert_eq!(app.regular_leaf_count(), 1, "setup: one editor + one dock");
    let tabs_before = app.tabs.len();

    let err = app
        .move_window_to_new_tab()
        .expect_err("moving the last regular window out must be refused");

    assert_eq!(err, "E1: only one window in this tab");
    assert_eq!(app.tabs.len(), tabs_before, "no tab may have been created");
    assert!(
        app.layout().contains(editor),
        "the refused window must stay in this tab"
    );
    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "the dock must remain open"
    );
}

#[test]
fn explorer_still_opens_files_into_main_area_not_the_dock() {
    use crate::keymap_actions::AppAction;
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(tmp.path().join("target.txt"), "hi").unwrap();
    let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    assert_eq!(app.focused_window(), dock_win);

    // Row 0 = root (the tmp dir), row 1 = target.txt (its only entry).
    // Activating a file node (the explorer's own "open" action — pressing
    // <CR>/`o` on a node — as opposed to typing `:e` while the dock
    // happens to be focused, a separate, pre-existing, already-tested edge
    // case) must reroute focus off the dock first.
    app.set_explorer_window_cursor(1, 0, None);
    app.dispatch_action(AppAction::ExplorerActivate, 1);

    assert_ne!(
        app.focused_window(),
        dock_win,
        "opening a file from the tree must move focus off the dock"
    );
    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "the dock itself must remain open, untouched"
    );
    assert!(
        app.layout().contains(app.focused_window()),
        "the opened file's window must be a real tree leaf"
    );
}

/// Docks are per-tab (#63 Phase 3), matching vim: a new tab page starts with
/// no explorer and no quickfix window, and switching back restores the tab
/// that has one — including its focus.
#[test]
fn docks_are_per_tab_and_survive_a_tab_round_trip() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    assert_eq!(app.focused_window(), dock_win);

    app.dispatch_ex("tabnew");
    assert!(
        app.tabs[app.active_tab].left_dock.is_none(),
        "a new tab starts without the explorer — docks are per-tab"
    );
    assert!(
        !app.layout().contains(dock_win),
        "tab 0's explorer leaf must not appear in tab 1's tree"
    );
    assert!(
        app.tabs[0].left_dock.is_some(),
        "opening the explorer in tab 1 must not have disturbed tab 0"
    );
    assert_ne!(
        app.focused_window(),
        dock_win,
        "the new tab gets its own regular window focused, not the dock"
    );

    // Switching back to tab 0 (whose remembered focus was the dock, still
    // open) must restore it.
    app.switch_tab(0);
    assert_eq!(
        app.focused_window(),
        dock_win,
        "switching back to the tab that had the dock focused must restore it"
    );
}

/// Opening the explorer in tab 1 must leave tab 0's explorer entirely alone —
/// two independent dock windows, two independent panes.
#[test]
fn explorer_in_a_second_tab_does_not_touch_the_first() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_0 = app.tabs[0].left_dock.as_ref().unwrap().win_id;

    app.dispatch_ex("tabnew");
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_1 = app.tabs[1].left_dock.as_ref().unwrap().win_id;

    assert_ne!(dock_0, dock_1, "each tab gets its own explorer window");
    assert!(app.tabs[0].layout.contains(dock_0));
    assert!(app.tabs[1].layout.contains(dock_1));
    assert!(
        app.tabs[0].explorer.is_some() && app.tabs[1].explorer.is_some(),
        "each tab owns its own explorer pane state"
    );

    // Closing tab 1's explorer must not close tab 0's.
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[1].left_dock.is_none());
    assert!(
        app.tabs[0].left_dock.is_some(),
        "closing the explorer in tab 1 must not close tab 0's"
    );
    assert!(app.tabs[0].layout.contains(dock_0));
}

/// Closing a tab disposes of that tab's docks with the rest of its windows —
/// window entry AND scratch slot, or the leaked quickfix slot would resurface
/// as a fake user buffer.
#[test]
fn tabclose_disposes_the_tabs_docks_and_their_slots() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    let slots_before = app.slots.len();

    app.dispatch_ex("tabnew");
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[1].left_dock.as_ref().unwrap().win_id;
    open_bottom_dock(&mut app);
    let qf_win = app.tabs[1].bottom_dock.as_ref().unwrap().win_id;

    app.dispatch_ex("tabclose");

    assert_eq!(app.tabs.len(), 1);
    assert!(app.windows[dock_win].is_none(), "explorer window disposed");
    assert!(app.windows[qf_win].is_none(), "quickfix window disposed");
    assert_eq!(
        app.slots.len(),
        slots_before + 1,
        "both dock scratch slots must be gone (only `:tabnew`'s own empty \
         buffer remains)"
    );
}

#[test]
fn switch_tab_falls_back_when_remembered_focus_window_no_longer_exists() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    app.dispatch_ex("tabnew");
    // Close tab 0's dock while tab 1 is active — its window is now gone.
    app.switch_tab(0);
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    app.switch_tab(1);
    assert!(app.tabs[0].left_dock.is_none());
    assert!(app.windows[dock_win].is_none());

    // Force tab 0's remembered focus onto the now-closed id. In practice
    // `teardown_left_dock` repairs the owning tab's focus at close time, so
    // this exact staleness shouldn't occur for docks specifically — this pins
    // `switch_tab`'s fallback as a general safety net for ANY dangling
    // remembered-focus id, which is what #63's design doc asks for.
    app.tabs[0].focused_window = dock_win;

    app.switch_tab(0);

    let fw = app.focused_window();
    assert!(
        app.windows[fw].is_some(),
        "must fall back to an open window instead of a closed one"
    );
    assert_ne!(fw, dock_win);
}

#[test]
fn dock_resize_ctrl_w_gt_persists_width_to_config_file() {
    use crate::keymap_actions::AppAction;
    let dir = tempfile::tempdir().unwrap();
    let cfg_path = dir.path().join("config.toml");
    let mut app = App::new(None, false, None, None)
        .unwrap()
        .with_config_path(cfg_path.clone());
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_some());
    let before = app.config.explorer.width;

    app.dispatch_action(AppAction::ResizeWidth(2), 1);

    assert_eq!(
        app.config.explorer.width,
        before + 2,
        "in-memory width must update immediately"
    );
    let text = std::fs::read_to_string(&cfg_path).expect("config file must be created");
    assert!(text.contains("[explorer]"));
    assert!(text.contains(&format!("width = {}", app.config.explorer.width)));
}

#[test]
fn dock_resize_without_config_path_does_not_panic_or_write_anything() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let before = app.config.explorer.width;

    app.dispatch_action(AppAction::ResizeWidth(2), 1);

    assert_eq!(app.config.explorer.width, before + 2);
}

// ── The `explorer.open` startup preference (#63 Phase C) ──────────────────

/// Toggling the dock is session-only. `explorer.open` is what the user wants
/// the NEXT session to start as, so a toggle must not touch it — otherwise
/// whichever toggle happened to be last before quitting silently rewrote it.
#[test]
fn toggle_explorer_does_not_persist_open_state_to_config_file() {
    use crate::keymap_actions::AppAction;
    let dir = tempfile::tempdir().unwrap();
    let cfg_path = dir.path().join("config.toml");
    let mut app = App::new(None, false, None, None)
        .unwrap()
        .with_config_path(cfg_path.clone());

    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_some());
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_none());

    // The write path creates the file on demand, so "no file" and "a file
    // without the key" are both correct — neither can happen if a toggle wrote.
    let text = std::fs::read_to_string(&cfg_path).unwrap_or_default();
    assert!(
        !text.contains("open ="),
        "toggling must not write explorer.open; got:\n{text}"
    );
}

/// `:set explorer.open=…` is the only writer, and it applies the value now as
/// well as persisting it — the same "apply + write back" every other `:set`
/// does.
#[test]
fn set_explorer_open_applies_and_persists() {
    let dir = tempfile::tempdir().unwrap();
    let cfg_path = dir.path().join("config.toml");
    let mut app = App::new(None, false, None, None)
        .unwrap()
        .with_config_path(cfg_path.clone());

    app.dispatch_ex("set explorer.open=true");
    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "`:set explorer.open=true` must open the dock now, not only next launch"
    );
    assert!(app.config.explorer.open);
    let text = std::fs::read_to_string(&cfg_path).expect("config file must be created");
    assert!(text.contains("[explorer]"));
    assert!(
        text.contains("open = true"),
        "must persist open = true; got:\n{text}"
    );

    app.dispatch_ex("set explorer.open=false");
    assert!(
        app.tabs[app.active_tab].left_dock.is_none(),
        "`:set explorer.open=false` must close the dock"
    );
    assert!(!app.config.explorer.open);
    let text = std::fs::read_to_string(&cfg_path).unwrap();
    assert!(
        text.contains("open = false"),
        "must persist open = false; got:\n{text}"
    );
}

/// Setting the value it already holds writes the key without toggling the dock
/// — the guard that stops `:set explorer.open=true` on an open dock closing it.
#[test]
fn set_explorer_open_to_the_current_state_leaves_the_dock_alone() {
    use crate::keymap_actions::AppAction;
    let dir = tempfile::tempdir().unwrap();
    let cfg_path = dir.path().join("config.toml");
    let mut app = App::new(None, false, None, None)
        .unwrap()
        .with_config_path(cfg_path.clone());

    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_some());

    app.dispatch_ex("set explorer.open=true");

    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "the dock was already open; `=true` must not toggle it shut"
    );
    let text = std::fs::read_to_string(&cfg_path).expect("config file must be created");
    assert!(text.contains("open = true"), "got:\n{text}");
}

#[test]
fn toggle_explorer_without_config_path_does_not_panic_or_write_anything() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();

    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_some());
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_none());
}

#[test]
fn restore_dock_state_reopens_explorer_when_config_says_open() {
    let mut cfg = hjkl_app::config::Config::default();
    cfg.explorer.open = true;
    let mut app = App::new(None, false, None, None).unwrap().with_config(cfg);

    assert!(
        app.tabs[app.active_tab].left_dock.is_none(),
        "with_config alone must not open the dock"
    );

    app.restore_dock_state_from_config();

    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "restore must reopen the explorer through the normal toggle_explorer path"
    );
    assert!(app.tabs[app.active_tab].explorer.is_some());
}

#[test]
fn restore_dock_state_is_noop_when_config_says_closed() {
    // Default config has explorer.open = false.
    let mut app = App::new(None, false, None, None).unwrap();

    app.restore_dock_state_from_config();

    assert!(app.tabs[app.active_tab].left_dock.is_none());
    assert!(app.tabs[app.active_tab].explorer.is_none());
}

#[test]
fn restore_dock_state_does_not_double_toggle_when_already_open() {
    use crate::keymap_actions::AppAction;
    let mut cfg = hjkl_app::config::Config::default();
    cfg.explorer.open = true;
    let mut app = App::new(None, false, None, None).unwrap().with_config(cfg);
    // Simulate the explorer already being open (e.g. a caller invoking
    // restore twice, or opening it interactively before restore runs).
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    assert!(app.tabs[app.active_tab].left_dock.is_some());

    app.restore_dock_state_from_config();

    assert!(
        app.tabs[app.active_tab].left_dock.is_some(),
        "restore must not close an already-open explorer (it only opens \
         when explorer.is_none())"
    );
}

#[test]
fn dock_resize_clamps_to_minimum_width() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);

    app.dispatch_action(AppAction::ResizeWidth(-1000), 1);

    assert_eq!(
        app.config.explorer.width,
        crate::app::dock::DOCK_MIN_WIDTH,
        "width must clamp at the minimum rather than going to 0 or negative"
    );
}

// ── Bottom dock (#63 Phase B: quickfix/location-list as a real dock) ──────
//
// The bottom dock reuses the exact same global-dock mechanism as the left
// explorer dock (see `app/dock.rs`), so these tests mirror the `dock_*`
// tests above one-for-one, just against `app.tabs[app.active_tab].bottom_dock` / `:copen`.

/// Open the bottom dock with one quickfix entry, no real files needed since
/// these tests never jump.
fn open_bottom_dock(app: &mut App) -> window::WindowId {
    app.quickfix.set(vec![hjkl_quickfix::QfEntry {
        path: std::path::PathBuf::from("x.rs"),
        row: 0,
        col: 0,
        kind: hjkl_quickfix::QfKind::Grep,
        message: "hit".into(),
    }]);
    app.handle_quickfix_command(hjkl_ex::QfCommand::Open);
    app.tabs[app.active_tab]
        .bottom_dock
        .as_ref()
        .expect("bottom dock must be open")
        .win_id
}

#[test]
fn bottom_dock_excluded_from_equalize_and_swap() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp"); // 2 tree windows
    let dock_win = open_bottom_dock(&mut app);
    assert_eq!(
        app.focused_window(),
        dock_win,
        ":copen focuses the new dock window"
    );

    app.dispatch_action(AppAction::EqualizeLayout, 1);
    assert!(
        app.tabs[app.active_tab].bottom_dock.is_some(),
        "equalize must not close the dock"
    );
    assert_eq!(app.regular_leaf_count(), 2);

    app.dispatch_action(AppAction::SwapWithSibling, 1);
    assert!(app.tabs[app.active_tab].bottom_dock.is_some());
    assert_eq!(
        app.focused_window(),
        dock_win,
        "swap must not move focus off the dock"
    );
}

#[test]
fn only_focused_window_on_bottom_dock_is_a_noop() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    app.dispatch_ex("sp");
    let leaves_before = app.regular_leaf_count();
    open_bottom_dock(&mut app);

    app.dispatch_action(AppAction::OnlyFocusedWindow, 1);

    assert!(
        app.tabs[app.active_tab].bottom_dock.is_some(),
        "only must leave the dock open"
    );
    assert_eq!(app.regular_leaf_count(), leaves_before);
}

#[test]
fn close_focused_window_on_bottom_dock_closes_the_dock_not_the_tree() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("sp");
    let tree_leaves_before = app.regular_leaf_count();
    open_bottom_dock(&mut app);

    app.dispatch_action(AppAction::CloseFocusedWindow, 1);

    assert!(
        app.tabs[app.active_tab].bottom_dock.is_none(),
        "CloseFocusedWindow on the bottom dock must close the dock"
    );
    assert_eq!(app.regular_leaf_count(), tree_leaves_before);
}

#[test]
fn ctrl_w_j_k_crosses_frame_boundary_with_bottom_dock_open() {
    let mut app = App::new(None, false, None, None).unwrap();
    let regular_win = app.focused_window();
    let dock_win = open_bottom_dock(&mut app);
    assert_eq!(app.focused_window(), dock_win);

    // C-w k: leave the dock, land back in the main area.
    app.focus_above();
    assert_eq!(
        app.focused_window(),
        regular_win,
        "C-w k from the bottom dock must land in the main area"
    );

    // C-w j: re-enter the dock (no tree neighbour below the only window).
    app.focus_below();
    assert_eq!(
        app.focused_window(),
        dock_win,
        "C-w j from the bottommost tree window must focus the bottom dock"
    );

    // C-w j again: already at the dock, nothing further below — no-op.
    app.focus_below();
    assert_eq!(app.focused_window(), dock_win);
}

/// Per the approved frame diagram: the bottom dock spans only the MAIN
/// AREA's width, below the tree — NOT below the left dock too. So `<C-w>j`
/// from the explorer must NOT reach it; `<C-w>l` from the explorer re-enters
/// the main area as usual (#63 Phase A behaviour, unaffected by Phase B).
#[test]
fn ctrl_w_j_from_explorer_does_not_reach_bottom_dock() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    let regular_win = app.focused_window();
    open_bottom_dock(&mut app);
    // Re-focus the regular window, then open the explorer too.
    app.set_focused_window(regular_win);
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let explorer_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    assert_eq!(app.focused_window(), explorer_win);

    app.focus_below();
    assert_eq!(
        app.focused_window(),
        explorer_win,
        "C-w j from the explorer must NOT reach the bottom dock"
    );

    app.focus_right();
    assert_eq!(
        app.focused_window(),
        regular_win,
        "C-w l from the explorer must enter the main area"
    );
}

/// Every window-creating path must split a REGULAR window, never a dock —
/// otherwise the new window lands inside the explorer's fixed column, at the
/// explorer's configured width. `q:` is the one that reaches this without an
/// explicit `:sp`, since the explorer buffer takes normal-mode keys.
#[test]
fn cmdline_window_from_the_focused_explorer_does_not_split_the_dock() {
    use crate::app::CmdLineKind;
    use crate::keymap_actions::AppAction;

    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    let dock_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    assert_eq!(app.focused_window(), dock_win);

    app.open_cmdline_window(CmdLineKind::Ex, None);

    let cmdline_win = app
        .cmdline_win
        .as_ref()
        .expect("cmdline window open")
        .win_id;
    assert_ne!(cmdline_win, dock_win);
    // The explorer must still be the root split's first child — the shape
    // `install_bottom_dock` keys the quickfix dock's placement off.
    match app.layout() {
        crate::app::window::LayoutTree::Split { a, .. } => assert!(
            matches!(a.as_ref(), crate::app::window::LayoutTree::Leaf(l) if *l == dock_win),
            "the explorer must still be the root split's `a` leaf, not a \
             split containing the cmdline window"
        ),
        other => panic!("expected a root split, got {other:?}"),
    }
}

/// The geometry behind the two navigation tests above, asserted directly:
/// the explorer is the full-height leftmost column at exactly
/// `config.explorer.width` cells (the config value is the RENDERED width —
/// the separator's cell is found on top of it, not carved out of it), and
/// the bottom dock starts to its RIGHT rather than spanning beneath it.
#[test]
fn dock_rects_put_the_explorer_left_and_the_bottom_dock_right_of_it() {
    use crate::app::window::LayoutRect;
    use crate::keymap_actions::AppAction;

    let mut app = App::new(None, false, None, None).unwrap();
    app.last_frame_rect = Some(ratatui::layout::Rect::new(0, 0, 80, 40));
    let regular_win = app.focused_window();
    app.dispatch_action(AppAction::ToggleExplorer, 1);
    app.set_focused_window(regular_win);
    let qf_win = open_bottom_dock(&mut app);
    let explorer_win = app.tabs[app.active_tab].left_dock.as_ref().unwrap().win_id;
    app.sync_dock_fixed_sizes();

    let area = LayoutRect::new(0, 0, 80, 38);
    let rects = app.layout().window_rects(area);
    let rect_of = |id| rects.iter().find(|(w, _)| *w == id).unwrap().1;

    let width = app.config.explorer.width;
    let height = app.config.panel.height;

    let ex = rect_of(explorer_win);
    assert_eq!((ex.x, ex.w), (0, width), "explorer renders config width");
    assert_eq!(ex.h, area.h, "explorer spans the full height");

    let qf = rect_of(qf_win);
    assert_eq!(
        qf.x,
        width + 1,
        "the bottom dock must start right of the explorer's separator, \
         never beneath the explorer"
    );
    assert_eq!(qf.w, area.w - width - 1);
    assert_eq!(qf.h, height, "bottom dock renders config height");
    assert_eq!(
        qf.y + qf.h,
        area.y + area.h,
        "the bottom dock is flush with the bottom of the frame"
    );

    let main = rect_of(regular_win);
    assert_eq!((main.x, main.w), (width + 1, area.w - width - 1));
    assert_eq!(
        main.h,
        area.h - height - 1,
        "the main window gives up exactly the dock's height plus its separator"
    );
}

#[test]
fn bottom_dock_resize_ctrl_w_plus_persists_height_to_config_file() {
    use crate::keymap_actions::AppAction;
    let dir = tempfile::tempdir().unwrap();
    let cfg_path = dir.path().join("config.toml");
    let mut app = App::new(None, false, None, None)
        .unwrap()
        .with_config_path(cfg_path.clone());
    // Simulate a rendered frame (as the real binary always has by the time a
    // user can press a key) — otherwise `resize_dock_height_by`'s terminal-
    // height fallback (24) clamps at half (12), which coincidentally equals
    // the bundled default `panel.height`, leaving zero headroom to grow.
    app.last_frame_rect = Some(ratatui::layout::Rect::new(0, 0, 80, 40));
    open_bottom_dock(&mut app);
    let before = app.config.panel.height;

    app.dispatch_action(AppAction::ResizeHeight(2), 1);

    assert_eq!(
        app.config.panel.height,
        before + 2,
        "in-memory height must update immediately"
    );
    let text = std::fs::read_to_string(&cfg_path).expect("config file must be created");
    assert!(text.contains("[panel]"));
    assert!(text.contains(&format!("height = {}", app.config.panel.height)));
}

#[test]
fn bottom_dock_resize_clamps_to_minimum_height() {
    use crate::keymap_actions::AppAction;
    let mut app = App::new(None, false, None, None).unwrap();
    open_bottom_dock(&mut app);

    app.dispatch_action(AppAction::ResizeHeight(-1000), 1);

    assert_eq!(
        app.config.panel.height,
        crate::app::dock::DOCK_MIN_HEIGHT,
        "height must clamp at the minimum rather than going to 0 or negative"
    );
}

/// Three-window layout `LEFT | (TOP-RIGHT / BOTTOM-RIGHT)`: `<C-h>` from the
/// BOTTOM-right pane must return to the left pane, not just from the top one.
/// The bottom pane's parent is the horizontal split, so the left neighbour is
/// only reachable by ascending past it to the enclosing vertical split.
#[test]
fn ctrl_h_from_bottom_right_pane_returns_to_left_pane() {
    use crate::app::NavDir;

    let mut app = App::new(None, false, None, None).unwrap();
    app.dispatch_ex("vsp");
    let left = app.focused_window();
    app.dispatch_tmux_navigate(NavDir::Right);
    let right = app.focused_window();
    assert_ne!(right, left, ":vsp + <C-l> must reach the other pane");

    // Split the right pane; focus lands in one of its halves.
    app.dispatch_ex("sp");
    app.dispatch_tmux_navigate(NavDir::Down);
    let bottom_right = app.focused_window();

    app.dispatch_tmux_navigate(NavDir::Left);

    assert_eq!(
        app.focused_window(),
        left,
        "<C-h> from the bottom-right pane must focus the left pane (was {bottom_right})"
    );
}
