//! Keystroke-driven integration tests for the TUI.
//!
//! `Harness` builds an `App` together with mock collaborators
//! (`MockMessageSender`, `MockApprovalDecider`, `EmptyMailbox`) once
//! at construction time so subsequent dispatches spy through the
//! same instances. T-079 sub-tickets layer real coverage on top of
//! the shorthands defined here — keep this file ergonomic.
//!
//! `dispatch_key` / `dispatch_key_mods` mirror the inline
//! `dispatch(app, ev)` shape from `app.rs` unit tests, lifted into
//! the integration layer through the now-public `app::handle_event`.
//!
//! Snapshots intentionally aren't asserted here — those live in
//! `tests/snapshots.rs`. This file pins state transitions; the
//! snapshot file pins what they render to.
//!
//! ## Convention
//!
//! Set up state via direct method calls (`h.app.dismiss_splash()`,
//! `h.app.replace_team(...)`, `h.app.select_next()`); exercise the
//! verb under test via `dispatch_key` so the production
//! `handle_event` routing is what's actually being asserted. That
//! keeps the keystroke surface area narrow and the test signal
//! sharp — a regression in `handle_event` shows up as a single
//! failing dispatch, not a wall of setup that has to be untangled.
//!
//! ## Adding a test
//!
//! Negative shape (state-transition only):
//!
//! ```ignore
//! let mut h = Harness::new();
//! h.app.replace_team(fixture_team("t", vec![synth_agent("t:m", AgentState::Running, 0, 0)]));
//! h.app.dismiss_splash();
//! h.dispatch_key(KeyCode::Char('@'));        // open DM compose
//! assert_eq!(h.app.stage, Stage::ComposeModal);
//! assert!(h.sender.dm_calls.lock().unwrap().is_empty());
//! ```
//!
//! Affirmative shape (recorder captured the call):
//!
//! ```ignore
//! // …open compose, type body, fire send chord…
//! let calls = h.sender.dm_calls.lock().unwrap();
//! assert_eq!(calls.len(), 1, "exactly one send fired");
//! assert_eq!(calls[0].0, "t:m", "DM target is the focused agent");
//! assert_eq!(calls[0].1, "hello", "captured body matches what was typed");
//! ```

use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use team_core::supervisor::AgentState;
use teamctl_ui::app::{self, render_to_buffer, App, Stage};
use teamctl_ui::approvals::test_support::MockApprovalDecider;
use teamctl_ui::approvals::{Approval, Decision};
use teamctl_ui::compose::test_support::MockMessageSender;
use teamctl_ui::data::{AgentInfo, TeamSnapshot};
use teamctl_ui::keysender::test_support::MockKeySender;
use teamctl_ui::keysender::ScrollDirection;
use teamctl_ui::mailbox::test_support::MockMailboxSource;
use teamctl_ui::triptych::{MainLayout, Pane};

/// Harness binds an `App` to its mock collaborators so every
/// `dispatch_key` reaches the same recorders. Construct with
/// `Harness::new()`; seed a team via `app.replace_team(...)` when
/// the test needs roster state. The mailbox source is a recorder —
/// `MockMailboxSource::default()` returns empty rows on every
/// query, but tests that exercise mailbox traffic can seed
/// `h.mailbox.inbox_rows` (etc.) before driving a refresh and then
/// inspect `h.mailbox.inbox_calls.lock()` to verify the right filter
/// was queried with the right (agent_id, after_id) pair.
pub struct Harness {
    pub app: App,
    pub sender: MockMessageSender,
    pub decider: MockApprovalDecider,
    pub mailbox: MockMailboxSource,
    pub key_sender: MockKeySender,
}

impl Harness {
    /// Build a fresh App + default mocks. `NO_COLOR` is set so any
    /// downstream snapshot capture stays monochrome (matches the
    /// `tests/snapshots.rs` convention).
    pub fn new() -> Self {
        std::env::set_var("NO_COLOR", "1");
        Self {
            app: App::new(),
            sender: MockMessageSender::default(),
            decider: MockApprovalDecider::default(),
            mailbox: MockMailboxSource::default(),
            key_sender: MockKeySender::default(),
        }
    }

    /// Dispatch a single key (no modifiers) through the production
    /// `handle_event` routing, with the harness's mocks captured.
    pub fn dispatch_key(&mut self, code: KeyCode) {
        self.dispatch_key_mods(code, KeyModifiers::NONE);
    }

    /// Dispatch a single key with modifiers — for `Ctrl+W`,
    /// `Shift+Tab`, etc.
    pub fn dispatch_key_mods(&mut self, code: KeyCode, modifiers: KeyModifiers) {
        let ev = Event::Key(KeyEvent {
            code,
            modifiers,
            kind: KeyEventKind::Press,
            state: KeyEventState::NONE,
        });
        app::handle_event(
            &mut self.app,
            ev,
            &self.decider,
            &self.sender,
            &self.mailbox,
            &self.key_sender,
        );
    }

    /// Dispatch one mouse-wheel tick — T-158. Column/row are fixed
    /// to (0, 0) because the routing logic keys on `app.focused_pane`,
    /// not the click position.
    pub fn dispatch_mouse(&mut self, kind: crossterm::event::MouseEventKind) {
        let ev = Event::Mouse(crossterm::event::MouseEvent {
            kind,
            column: 0,
            row: 0,
            modifiers: KeyModifiers::NONE,
        });
        app::handle_event(
            &mut self.app,
            ev,
            &self.decider,
            &self.sender,
            &self.mailbox,
            &self.key_sender,
        );
    }
}

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

/// Build an `AgentInfo` row from a `project:agent` id string —
/// lifted from `tests/snapshots.rs::synth_agent` so test authors
/// don't have to assemble the struct by hand.
pub fn synth_agent(id: &str, state: AgentState, unread: u32, pending: u32) -> AgentInfo {
    let (project, agent) = id.split_once(':').unwrap_or(("p", id));
    AgentInfo {
        id: id.into(),
        agent: agent.into(),
        project: project.into(),
        tmux_session: format!("t-{}-{}", project, agent),
        state,
        unread_mail: unread,
        pending_approvals: pending,
        is_manager: false,
        display_name: None,
        rate_limit_resets_at: None,
        last_activity_at: None,
        reports_to: None,
    }
}

/// Build a `TeamSnapshot` with a fixture root path. Lifted from
/// `tests/snapshots.rs::fixture_team`.
pub fn fixture_team(team_name: &str, agents: Vec<AgentInfo>) -> TeamSnapshot {
    TeamSnapshot {
        root: std::path::PathBuf::from("/fixture"),
        team_name: team_name.into(),
        agents,
        channels: Vec::new(),
    }
}

// ── Demonstrator tests ──────────────────────────────────────────

#[test]
fn splash_dismisses_on_any_key() {
    // Splash is the boot stage; any keypress drops to Triptych. The
    // production handler routes every Splash-stage key through
    // `dismiss_splash()` regardless of code, so a single space is
    // enough to pin the contract.
    let mut h = Harness::new();
    assert_eq!(h.app.stage, Stage::Splash);
    h.dispatch_key(KeyCode::Char(' '));
    assert_eq!(h.app.stage, Stage::Triptych);
}

#[test]
fn tab_cycles_focus_through_panes() {
    // Tab walks Agents → Detail → Mailbox → Agents uniformly. The
    // wrap back to Agents is load-bearing — it's the bug T-074
    // shipped a fix for, and the integration test layer needs to
    // catch any future regression.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    assert_eq!(h.app.focused_pane, Pane::Roster);

    h.dispatch_key(KeyCode::Tab);
    assert_eq!(h.app.focused_pane, Pane::Detail);

    h.dispatch_key(KeyCode::Tab);
    assert_eq!(h.app.focused_pane, Pane::Mailbox);

    h.dispatch_key(KeyCode::Tab);
    assert_eq!(
        h.app.focused_pane,
        Pane::Roster,
        "Tab from Mailbox wraps back to Agents"
    );
}

#[test]
fn compose_modal_opens_via_at_key_without_sending() {
    // Wires the MockMessageSender end-to-end: a fixture team is
    // seeded, the operator picks an agent, `@` opens DM compose
    // pointed at that agent. Crucially, opening the modal does NOT
    // send — the recorder must still be empty. Subsequent T-079-B
    // tests will type a body + Enter and assert that the same
    // recorder captures the call.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![
            synth_agent("writing:manager", AgentState::Running, 0, 0),
            synth_agent("writing:dev1", AgentState::Running, 0, 0),
        ],
    ));
    h.app.dismiss_splash();
    // Cursor lands on the first agent after replace_team; advance
    // once so the DM target is dev1, not manager.
    h.app.select_next();

    h.dispatch_key(KeyCode::Char('@'));

    assert_eq!(h.app.stage, Stage::ComposeModal);
    assert!(
        h.app.compose_target.is_some(),
        "compose_target seeded by `@`"
    );
    assert!(
        h.sender.dm_calls.lock().unwrap().is_empty(),
        "opening the modal must not trigger a send"
    );
    assert!(
        h.sender.broadcast_calls.lock().unwrap().is_empty(),
        "opening the modal must not trigger a broadcast"
    );
}

// ── T-079-B: DM compose flow coverage ───────────────────────────

/// Type a body into an open compose modal, one character at a time
/// through the public dispatcher. Each char goes through
/// `handle_event` → `editor.apply_key`, exactly mirroring what the
/// run loop sees from a real terminal.
fn type_body(h: &mut Harness, body: &str) {
    for c in body.chars() {
        h.dispatch_key(KeyCode::Char(c));
    }
}

/// Set up the canonical DM-flow fixture: a two-agent team with the
/// roster cursor parked on `dev1` and the splash dismissed. Returns
/// the harness so each test can drive the verb under test.
fn dm_compose_setup() -> Harness {
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![
            synth_agent("writing:manager", AgentState::Running, 0, 0),
            synth_agent("writing:dev1", AgentState::Running, 0, 0),
        ],
    ));
    h.app.dismiss_splash();
    h.app.select_next(); // park on dev1, not manager
    h
}

#[test]
fn dm_compose_sends_via_send_chord_with_focused_target_and_typed_body() {
    // The project-owner-reported affirmative path: open DM compose
    // for the focused agent, type a body, fire the send chord. The
    // mock sender must capture exactly one DM with the right
    // recipient and body. The send chord we drive here is the one a
    // real terminal can deliver — the historical `Ctrl+Enter` is
    // unreachable on standard terminals (xterm / Terminal.app /
    // tmux strip the Ctrl modifier on Enter), so the affirmative
    // path the operator actually uses is `Alt+Enter`.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    assert_eq!(h.app.stage, Stage::ComposeModal);

    type_body(&mut h, "ready for review");
    h.dispatch_key_mods(KeyCode::Enter, KeyModifiers::ALT);

    let calls = h.sender.dm_calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "exactly one DM should fire");
    assert_eq!(calls[0].0, "writing:dev1", "DM target is the focused agent");
    assert_eq!(
        calls[0].1, "ready for review",
        "body matches what was typed"
    );
    assert_eq!(
        h.app.stage,
        Stage::Triptych,
        "successful send closes the modal"
    );
    assert!(
        h.app.compose_target.is_none(),
        "compose target cleared on close"
    );
}

#[test]
fn dm_compose_blank_body_surfaces_error_and_does_not_send() {
    // Hitting send with no body must NOT fire the sender — the
    // modal stays open with `compose_error` populated so the
    // operator sees why nothing went out.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    h.dispatch_key_mods(KeyCode::Enter, KeyModifiers::ALT);

    assert!(
        h.sender.dm_calls.lock().unwrap().is_empty(),
        "blank body must not reach the sender"
    );
    assert_eq!(
        h.app.stage,
        Stage::ComposeModal,
        "modal stays open on blank-body error"
    );
    assert!(
        h.app
            .compose_error
            .as_deref()
            .is_some_and(|e| e.contains("empty")),
        "compose_error should explain the no-send: got {:?}",
        h.app.compose_error
    );
}

#[test]
fn dm_compose_esc_esc_cancels_without_sending() {
    // Esc-Esc closes the modal without invoking the sender. The
    // editor consumes the first Esc to leave Insert mode, the
    // second Esc to fire EditorAction::Cancel.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    type_body(&mut h, "draft");

    h.dispatch_key(KeyCode::Esc);
    h.dispatch_key(KeyCode::Esc);

    assert!(
        h.sender.dm_calls.lock().unwrap().is_empty(),
        "Esc-Esc must not fire the sender"
    );
    assert_eq!(h.app.stage, Stage::Triptych, "Esc-Esc closes the modal");
    assert!(
        h.app.compose_target.is_none(),
        "compose target cleared on cancel"
    );
}

#[test]
fn dm_compose_multi_line_body_is_sent_with_embedded_newline() {
    // Plain Enter inserts a newline (multi-line composition); the
    // send chord then ships the joined body. The captured payload
    // must preserve the embedded `\n` so downstream rendering
    // doesn't collapse the operator's intent.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    type_body(&mut h, "line one");
    h.dispatch_key(KeyCode::Enter); // newline within Insert mode
    type_body(&mut h, "line two");
    h.dispatch_key_mods(KeyCode::Enter, KeyModifiers::ALT);

    let calls = h.sender.dm_calls.lock().unwrap();
    assert_eq!(calls.len(), 1, "multi-line send fires exactly once");
    assert_eq!(
        calls[0].1, "line one\nline two",
        "newline preserved in body"
    );
}

#[test]
fn dm_compose_target_follows_roster_selection_after_cancel() {
    // Cancel mid-compose, advance the roster cursor, reopen
    // compose. The new target must be the freshly-focused agent —
    // a stale target would silently DM the wrong person.
    let mut h = dm_compose_setup();
    // First open: cursor parked on dev1, cancel.
    h.dispatch_key(KeyCode::Char('@'));
    h.dispatch_key(KeyCode::Esc);
    h.dispatch_key(KeyCode::Esc);
    assert_eq!(h.app.stage, Stage::Triptych);

    // Advance to manager (wraps from dev1 → manager via select_next).
    h.app.select_next();
    h.dispatch_key(KeyCode::Char('@'));
    type_body(&mut h, "ping");
    h.dispatch_key_mods(KeyCode::Enter, KeyModifiers::ALT);

    let calls = h.sender.dm_calls.lock().unwrap();
    assert_eq!(calls.len(), 1);
    assert_eq!(
        calls[0].0, "writing:manager",
        "DM follows the new roster selection, not the prior cancelled target"
    );
}

// ── Layout-switch coverage (T-079-C) ────────────────────────────
//
// Affirmative paths, return paths, cross-layout transitions, edge
// cases (focused pane, modal stages). The render-side assertions
// catch the "state flips but the view doesn't follow" failure mode
// — the project-owner-reported bug shape.

fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String {
    let area = buf.area();
    let mut out = String::with_capacity((area.width as usize + 1) * area.height as usize);
    for y in 0..area.height {
        for x in 0..area.width {
            let cell = &buf[(area.x + x, area.y + y)];
            out.push_str(cell.symbol());
        }
        out.push('\n');
    }
    out
}

#[test]
fn ctrl_w_switches_triptych_to_wall() {
    let mut h = Harness::new();
    h.app.dismiss_splash();
    assert_eq!(h.app.layout, MainLayout::Triptych);

    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);

    assert_eq!(h.app.layout, MainLayout::Wall);
}

#[test]
fn ctrl_w_returns_wall_to_triptych() {
    let mut h = Harness::new();
    h.app.dismiss_splash();
    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
    assert_eq!(h.app.layout, MainLayout::Wall);

    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);

    assert_eq!(h.app.layout, MainLayout::Triptych);
}

#[test]
fn ctrl_m_switches_triptych_to_mailbox_first() {
    let mut h = Harness::new();
    h.app.dismiss_splash();
    assert_eq!(h.app.layout, MainLayout::Triptych);

    h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);

    assert_eq!(h.app.layout, MainLayout::MailboxFirst);
}

#[test]
fn ctrl_m_returns_mailbox_first_to_triptych() {
    let mut h = Harness::new();
    h.app.dismiss_splash();
    h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
    assert_eq!(h.app.layout, MainLayout::MailboxFirst);

    h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);

    assert_eq!(h.app.layout, MainLayout::Triptych);
}

#[test]
fn ctrl_m_from_wall_switches_to_mailbox_first() {
    // Cross-layout: operator in Wall hits Ctrl+M, expects to land
    // in MailboxFirst directly without a Triptych pit-stop.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
    assert_eq!(h.app.layout, MainLayout::Wall);

    h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);

    assert_eq!(h.app.layout, MainLayout::MailboxFirst);
}

#[test]
fn ctrl_w_from_mailbox_first_switches_to_wall() {
    let mut h = Harness::new();
    h.app.dismiss_splash();
    h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
    assert_eq!(h.app.layout, MainLayout::MailboxFirst);

    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);

    assert_eq!(h.app.layout, MainLayout::Wall);
}

#[test]
fn ctrl_w_with_mailbox_pane_focused_still_switches_layout() {
    // Layout-switch must work regardless of which pane has focus —
    // Ctrl+W is a layout-level chord, not a pane-level one.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    h.dispatch_key(KeyCode::Tab);
    h.dispatch_key(KeyCode::Tab);
    assert_eq!(h.app.focused_pane, Pane::Mailbox);

    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);

    assert_eq!(h.app.layout, MainLayout::Wall);
}

#[test]
fn compose_modal_blocks_layout_switch() {
    // The compose modal owns input — Ctrl+W must NOT bypass the
    // editor and flip the underlying main-view layout. Operator
    // would see a confused state ("modal is up but layout
    // changed") otherwise.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
    ));
    h.app.dismiss_splash();
    h.dispatch_key(KeyCode::Char('@'));
    assert_eq!(h.app.stage, Stage::ComposeModal);

    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);

    assert_eq!(
        h.app.layout,
        MainLayout::Triptych,
        "compose modal owns input — layout must not flip underneath"
    );
}

#[test]
fn quit_confirm_overlay_blocks_layout_switch() {
    let mut h = Harness::new();
    h.app.dismiss_splash();
    h.dispatch_key(KeyCode::Char('q'));
    assert_eq!(h.app.stage, Stage::QuitConfirm);

    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);

    assert_eq!(
        h.app.layout,
        MainLayout::Triptych,
        "quit-confirm overlay must not be bypassed by layout chord"
    );
}

#[test]
fn rendered_buffer_reflects_wall_after_ctrl_w() {
    // The named bug shape: state flips but the rendered view
    // doesn't follow. Triptych shows AGENTS + MAILBOX pane titles;
    // Wall is a tile grid with no such pane chrome. If `app.layout`
    // is Wall but `render_to_buffer` still emits AGENTS/MAILBOX,
    // the user sees "switching layouts doesn't work."
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![
            synth_agent("writing:manager", AgentState::Running, 0, 0),
            synth_agent("writing:dev1", AgentState::Running, 0, 0),
        ],
    ));
    h.app.dismiss_splash();

    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
    assert_eq!(h.app.layout, MainLayout::Wall);

    let s = buffer_to_string(&render_to_buffer(&h.app, 120, 30));
    assert!(
        !s.contains("AGENTS"),
        "Wall buffer must not render the Triptych AGENTS pane title:\n{s}"
    );
    assert!(
        !s.contains("MAILBOX"),
        "Wall buffer must not render the Triptych MAILBOX pane title:\n{s}"
    );
}

#[test]
fn rendered_buffer_reflects_mailbox_first_after_ctrl_m() {
    // Same shape for the MailboxFirst layout — the failure mode
    // covered here is the project-owner-reported "switching
    // layouts doesn't work" surface for Ctrl+M.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
    ));
    h.app.dismiss_splash();

    h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
    assert_eq!(h.app.layout, MainLayout::MailboxFirst);

    let s = buffer_to_string(&render_to_buffer(&h.app, 120, 30));
    assert!(
        !s.contains("AGENTS"),
        "MailboxFirst buffer must not render the Triptych AGENTS pane title:\n{s}"
    );
    assert!(
        !s.contains("DETAIL"),
        "MailboxFirst buffer must not render the Triptych DETAIL pane title:\n{s}"
    );
}

#[test]
fn ctrl_shift_w_still_toggles_wall_layout() {
    // The project-owner-reported "switching layouts doesn't work"
    // surface. With CapsLock on, or with Shift held alongside
    // Ctrl, crossterm reports `KeyCode::Char('W')` (uppercase)
    // with `CONTROL` (and possibly `SHIFT`) modifiers. The current
    // routing only matches lowercase `Char('w')`, so the chord
    // dies silently and the operator sees a no-op. Layout chord
    // must accept either casing — the user's intent is "Ctrl
    // plus the W key," not "the lowercase glyph 'w'."
    let mut h = Harness::new();
    h.app.dismiss_splash();

    h.dispatch_key_mods(
        KeyCode::Char('W'),
        KeyModifiers::CONTROL | KeyModifiers::SHIFT,
    );

    assert_eq!(h.app.layout, MainLayout::Wall);
}

#[test]
fn ctrl_shift_m_still_toggles_mailbox_first_layout() {
    // Mirror of the Ctrl+W case for Ctrl+M — same root cause,
    // same fix surface.
    let mut h = Harness::new();
    h.app.dismiss_splash();

    h.dispatch_key_mods(
        KeyCode::Char('M'),
        KeyModifiers::CONTROL | KeyModifiers::SHIFT,
    );

    assert_eq!(h.app.layout, MainLayout::MailboxFirst);
}

#[test]
fn ctrl_w_with_detail_split_open_arms_chord_not_layout() {
    // Documented PR-UI-7 behaviour pinned: when there's at least
    // one detail split, Ctrl+W arms the close-split chord prefix
    // rather than toggling the Wall layout. The chord follows
    // with `q` (close focused split) or `o` (close others).
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
    ));
    h.app.dismiss_splash();
    h.dispatch_key_mods(KeyCode::Char('|'), KeyModifiers::CONTROL);
    assert_eq!(h.app.detail_splits.len(), 1);

    h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);

    assert_eq!(
        h.app.pending_chord,
        Some(KeyCode::Char('w')),
        "Ctrl+W with splits arms the chord prefix"
    );
    assert_eq!(
        h.app.layout,
        MainLayout::Triptych,
        "Ctrl+W with splits must not flip layout (chord wins)"
    );
}

// ── Approvals modal coverage (T-079-D) ──────────────────────────
//
// Drive: `a` to enter when there's a pending row, j/k (or arrows)
// to navigate, `y` / `Y` approve, `Shift+N` deny (asymmetric chord
// is load-bearing — see T-074 bug 4 fix at app::handle_event), Esc
// or `q` exit. Asserts target the `MockApprovalDecider`'s captured
// (id, kind, note) tuple so the routing reaches the right row.

/// Build an `Approval` with stable defaults — tests vary the id /
/// action / summary fields and pin everything else.
fn approval(id: i64, action: &str, summary: &str) -> Approval {
    Approval {
        id,
        project_id: "writing".into(),
        agent_id: "writing:manager".into(),
        action: action.into(),
        summary: summary.into(),
        payload_json: String::new(),
    }
}

/// Seed a fixture team + N pending approvals + dismiss splash.
/// Returns the harness so each test drives the verb under test.
fn approvals_setup(approvals: Vec<Approval>) -> Harness {
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
    ));
    h.app.dismiss_splash();
    h.app.replace_approvals(approvals);
    h
}

#[test]
fn a_key_enters_approvals_modal_when_pending_non_empty() {
    let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);

    h.dispatch_key(KeyCode::Char('a'));

    assert_eq!(h.app.stage, Stage::ApprovalsModal);
    assert_eq!(h.app.selected_approval, 0);
}

#[test]
fn a_key_is_no_op_when_no_pending_approvals() {
    // `enter_approvals_modal` early-returns on empty queue (app.rs
    // L428). Without a pending row there's nothing to triage, so
    // the chord is silent rather than opening an empty modal.
    let mut h = approvals_setup(vec![]);

    h.dispatch_key(KeyCode::Char('a'));

    assert_eq!(h.app.stage, Stage::Triptych);
}

#[test]
fn y_approves_focused_row_via_decider() {
    // Affirmative path: open modal, hit `y`, the decider receives
    // (focused-row-id, Approve, ""). Modal auto-closes because the
    // queue is now empty.
    let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
    h.dispatch_key(KeyCode::Char('a'));

    h.dispatch_key(KeyCode::Char('y'));

    let calls = h.decider.calls.lock().unwrap();
    assert_eq!(calls.len(), 1);
    assert_eq!(calls[0], (7, Decision::Approve, String::new()));
    assert_eq!(
        h.app.stage,
        Stage::Triptych,
        "queue emptied — modal auto-closes"
    );
}

#[test]
fn capital_y_also_approves_focused_row() {
    // Approve accepts both `y` and `Y` — matches the QuitConfirm
    // loose convention. Pin both casings so the chord stays
    // discoverable for CapsLock/Shift operators.
    let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
    h.dispatch_key(KeyCode::Char('a'));

    h.dispatch_key_mods(KeyCode::Char('Y'), KeyModifiers::SHIFT);

    let calls = h.decider.calls.lock().unwrap();
    assert_eq!(calls.len(), 1);
    assert_eq!(calls[0].1, Decision::Approve);
}

#[test]
fn shift_n_denies_focused_row_via_decider() {
    // Deny is the destructive side — Shift-gated (`N` only) per
    // T-074 bug 4 fix. Drives the asymmetric-chord contract.
    let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
    h.dispatch_key(KeyCode::Char('a'));

    h.dispatch_key_mods(KeyCode::Char('N'), KeyModifiers::SHIFT);

    let calls = h.decider.calls.lock().unwrap();
    assert_eq!(calls.len(), 1);
    assert_eq!(calls[0], (7, Decision::Deny, String::new()));
}

#[test]
fn lowercase_n_does_not_deny_destructive_chord_is_shift_gated() {
    // Counter-test of the asymmetric chord: stray lowercase `n` in
    // the modal must not fire deny. Operator hammering keys can't
    // accidentally drop an approval on the floor.
    let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
    h.dispatch_key(KeyCode::Char('a'));

    h.dispatch_key(KeyCode::Char('n'));

    assert!(
        h.decider.calls.lock().unwrap().is_empty(),
        "lowercase n must not reach the decider"
    );
    assert_eq!(
        h.app.stage,
        Stage::ApprovalsModal,
        "modal stays open on no-op key"
    );
}

#[test]
fn j_advances_focused_approval_and_k_retreats() {
    let mut h = approvals_setup(vec![
        approval(7, "publish", "first"),
        approval(8, "deploy", "second"),
        approval(9, "merge", "third"),
    ]);
    h.dispatch_key(KeyCode::Char('a'));
    assert_eq!(h.app.selected_approval, 0);

    h.dispatch_key(KeyCode::Char('j'));
    assert_eq!(h.app.selected_approval, 1);

    h.dispatch_key(KeyCode::Char('j'));
    assert_eq!(h.app.selected_approval, 2);

    h.dispatch_key(KeyCode::Char('k'));
    assert_eq!(h.app.selected_approval, 1);
}

#[test]
fn down_and_up_arrows_navigate_like_j_and_k() {
    // Arrows mirror j/k for non-vim operators. Both shapes route
    // through the same cycle_approval_next/prev — pin parity.
    let mut h = approvals_setup(vec![
        approval(7, "publish", "first"),
        approval(8, "deploy", "second"),
    ]);
    h.dispatch_key(KeyCode::Char('a'));

    h.dispatch_key(KeyCode::Down);
    assert_eq!(h.app.selected_approval, 1);

    h.dispatch_key(KeyCode::Up);
    assert_eq!(h.app.selected_approval, 0);
}

#[test]
fn j_wraps_at_end_of_queue() {
    let mut h = approvals_setup(vec![
        approval(7, "publish", "first"),
        approval(8, "deploy", "second"),
    ]);
    h.dispatch_key(KeyCode::Char('a'));
    h.dispatch_key(KeyCode::Char('j'));
    assert_eq!(h.app.selected_approval, 1);

    h.dispatch_key(KeyCode::Char('j'));

    assert_eq!(
        h.app.selected_approval, 0,
        "j from last row wraps back to first"
    );
}

#[test]
fn k_wraps_at_start_of_queue() {
    let mut h = approvals_setup(vec![
        approval(7, "publish", "first"),
        approval(8, "deploy", "second"),
    ]);
    h.dispatch_key(KeyCode::Char('a'));
    assert_eq!(h.app.selected_approval, 0);

    h.dispatch_key(KeyCode::Char('k'));

    assert_eq!(h.app.selected_approval, 1, "k from first row wraps to last");
}

#[test]
fn esc_closes_approvals_modal_without_decision() {
    let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
    h.dispatch_key(KeyCode::Char('a'));

    h.dispatch_key(KeyCode::Esc);

    assert_eq!(h.app.stage, Stage::Triptych);
    assert!(h.decider.calls.lock().unwrap().is_empty());
    assert_eq!(h.app.pending_approvals.len(), 1, "queue intact after Esc");
}

#[test]
fn q_closes_approvals_modal_without_decision() {
    // `q` is the alternative exit chord (matches QuitConfirm/Help
    // overlay convention). No quit-confirm overlay should surface
    // — the modal owns input.
    let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
    h.dispatch_key(KeyCode::Char('a'));

    h.dispatch_key(KeyCode::Char('q'));

    assert_eq!(h.app.stage, Stage::Triptych);
    assert!(h.decider.calls.lock().unwrap().is_empty());
}

#[test]
fn navigate_then_approve_routes_correct_row_id_to_decider() {
    // The end-to-end test the ticket flags: 3-row queue, navigate
    // to the middle row, approve. Decider must receive the middle
    // id (8), not the first (7) or last (9). Catches "selected_
    // approval drifts but apply_decision uses index 0" regressions.
    let mut h = approvals_setup(vec![
        approval(7, "publish", "first"),
        approval(8, "deploy", "second"),
        approval(9, "merge", "third"),
    ]);
    h.dispatch_key(KeyCode::Char('a'));
    h.dispatch_key(KeyCode::Char('j'));
    assert_eq!(h.app.selected_approval, 1);

    h.dispatch_key(KeyCode::Char('y'));

    let calls = h.decider.calls.lock().unwrap();
    assert_eq!(calls.len(), 1);
    assert_eq!(
        calls[0].0, 8,
        "decider received the focused-row id (middle), not the head"
    );
    assert_eq!(calls[0].1, Decision::Approve);
    assert_eq!(
        h.app.pending_approvals.len(),
        2,
        "approved row removed from local queue optimistically"
    );
    assert_eq!(
        h.app.stage,
        Stage::ApprovalsModal,
        "modal stays open while queue still has rows"
    );
}

#[test]
fn deny_followed_by_approve_routes_each_to_distinct_rows() {
    // Two-step end-to-end: Shift-N denies the head row (id 7),
    // selected_approval clamps onto the new head (id 8), `y` then
    // approves it. Decider must show one Deny on 7 and one Approve
    // on 8 — pins the optimistic-removal-and-clamp path through
    // `apply_decision`.
    let mut h = approvals_setup(vec![
        approval(7, "publish", "first"),
        approval(8, "deploy", "second"),
    ]);
    h.dispatch_key(KeyCode::Char('a'));

    h.dispatch_key_mods(KeyCode::Char('N'), KeyModifiers::SHIFT);
    h.dispatch_key(KeyCode::Char('y'));

    let calls = h.decider.calls.lock().unwrap();
    assert_eq!(calls.len(), 2);
    assert_eq!(calls[0], (7, Decision::Deny, String::new()));
    assert_eq!(calls[1], (8, Decision::Approve, String::new()));
    assert_eq!(
        h.app.stage,
        Stage::Triptych,
        "queue empty after both decisions"
    );
}

// ── Mailbox + tab coverage (T-079-E) ────────────────────────────
//
// Mailbox tab interactions sit on `[` / `]` (gated by the Mailbox
// pane having focus), Tab cycles pane focus only (T-074 bug 6 was
// the Tab-cycles-tabs shape that stranded operators), agent-switch
// resets every per-tab buffer, and `refresh_mailbox` fans out to
// the three filter shapes (`inbox` / `channel_feed` / `wire`) with
// the right `(agent_id, after_id)` / `(project_id, after_id)`
// pairs. Recorder assertions use the harness's `MockMailboxSource`
// fields directly.

use teamctl_ui::app::refresh_mailbox;
use teamctl_ui::mailbox::{MailboxTab, MessageRow};

fn mb_row(id: i64, sender: &str, recipient: &str, text: &str) -> MessageRow {
    MessageRow {
        id,
        sender: sender.into(),
        recipient: recipient.into(),
        text: text.into(),
        sent_at: 0.0,
    }
}

#[test]
fn arrow_right_cycles_mailbox_tabs_forward_when_mailbox_focused() {
    // T-124 + T-122: `→` cycles Inbox → Sent → Channel → Wire →
    // Inbox once the mailbox pane has focus. The forward walker
    // must wrap.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    // Walk Tab focus to Mailbox.
    h.dispatch_key(KeyCode::Tab);
    h.dispatch_key(KeyCode::Tab);
    assert_eq!(h.app.focused_pane, Pane::Mailbox);
    assert_eq!(h.app.mailbox_tab, MailboxTab::Inbox);

    h.dispatch_key(KeyCode::Right);
    assert_eq!(h.app.mailbox_tab, MailboxTab::Sent);

    h.dispatch_key(KeyCode::Right);
    assert_eq!(h.app.mailbox_tab, MailboxTab::Channel);

    h.dispatch_key(KeyCode::Right);
    assert_eq!(h.app.mailbox_tab, MailboxTab::Wire);

    h.dispatch_key(KeyCode::Right);
    assert_eq!(
        h.app.mailbox_tab,
        MailboxTab::Inbox,
        "`→` from Wire wraps to Inbox"
    );
}

#[test]
fn arrow_left_cycles_mailbox_tabs_backward_when_mailbox_focused() {
    // T-124 + T-122: `←` walks the other direction: Inbox → Wire →
    // Channel → Sent → Inbox.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    h.dispatch_key(KeyCode::Tab);
    h.dispatch_key(KeyCode::Tab);
    assert_eq!(h.app.focused_pane, Pane::Mailbox);

    h.dispatch_key(KeyCode::Left);
    assert_eq!(h.app.mailbox_tab, MailboxTab::Wire);

    h.dispatch_key(KeyCode::Left);
    assert_eq!(h.app.mailbox_tab, MailboxTab::Channel);

    h.dispatch_key(KeyCode::Left);
    assert_eq!(h.app.mailbox_tab, MailboxTab::Sent);

    h.dispatch_key(KeyCode::Left);
    assert_eq!(
        h.app.mailbox_tab,
        MailboxTab::Inbox,
        "`←` from Sent wraps back to Inbox"
    );
}

#[test]
fn arrow_keys_are_no_op_when_mailbox_not_focused() {
    // Gating on `focused_pane == Mailbox` is load-bearing — without
    // it, an operator hammering arrow keys while scrolling the
    // roster would see surprise tab switches. Pin the no-op.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    assert_eq!(h.app.focused_pane, Pane::Roster);

    h.dispatch_key(KeyCode::Right);
    h.dispatch_key(KeyCode::Left);

    assert_eq!(
        h.app.mailbox_tab,
        MailboxTab::Inbox,
        "←/→ must not cycle tabs from Roster focus"
    );
}

#[test]
fn brackets_no_longer_cycle_mailbox_tabs_after_t124() {
    // T-124 regression: `[` / `]` were the previous mailbox-tab
    // walker. Hard-swap means they're fully inert in the mailbox
    // pane now. Pin the no-op so the prior chord can't get
    // quietly re-introduced.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    h.dispatch_key(KeyCode::Tab);
    h.dispatch_key(KeyCode::Tab);
    assert_eq!(h.app.focused_pane, Pane::Mailbox);
    let initial = h.app.mailbox_tab;

    h.dispatch_key(KeyCode::Char(']'));
    h.dispatch_key(KeyCode::Char('['));

    assert_eq!(
        h.app.mailbox_tab, initial,
        "`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
    );
}

#[test]
fn tab_into_mailbox_preserves_active_tab() {
    // Tabbing pane focus INTO the mailbox must not silently reset
    // `mailbox_tab` — operators returning to a tab they were on
    // would otherwise lose place. Seed `mailbox_tab` to Channel
    // before tabbing in, assert it survives.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    // Walk into Mailbox, advance to Channel, walk back out.
    h.dispatch_key(KeyCode::Tab);
    h.dispatch_key(KeyCode::Tab);
    h.dispatch_key(KeyCode::Right);
    h.dispatch_key(KeyCode::Right);
    assert_eq!(h.app.focused_pane, Pane::Mailbox);
    assert_eq!(h.app.mailbox_tab, MailboxTab::Channel);
    h.dispatch_key(KeyCode::Tab); // back to Roster
    assert_eq!(h.app.focused_pane, Pane::Roster);

    // Now tab back into Mailbox via Detail.
    h.dispatch_key(KeyCode::Tab);
    h.dispatch_key(KeyCode::Tab);
    assert_eq!(h.app.focused_pane, Pane::Mailbox);
    assert_eq!(
        h.app.mailbox_tab,
        MailboxTab::Channel,
        "active mailbox tab survives the Tab-walk away and back"
    );
}

#[test]
fn switching_focused_agent_resets_mailbox_buffers() {
    // The mailbox `inbox_after` / `channel_after` / `wire_after`
    // cursors are per-focused-agent. Switching agents without a
    // reset would skip historical rows for the new agent (next
    // refresh would query `id > <prior agent's high water>` and
    // miss everything older). `select_next` must `mailbox.reset()`
    // on agent change.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![
            synth_agent("writing:manager", AgentState::Running, 0, 0),
            synth_agent("writing:dev1", AgentState::Running, 0, 0),
        ],
    ));
    h.app.dismiss_splash();
    // Seed inbox buffer + cursor as if a refresh had already
    // pulled rows for the manager.
    h.app.mailbox.extend(
        MailboxTab::Inbox,
        vec![mb_row(7, "writing:dev1", "writing:manager", "ping")],
    );
    assert_eq!(h.app.mailbox.inbox.len(), 1);
    assert_eq!(h.app.mailbox.inbox_after, 7);

    h.app.select_next(); // manager → dev1

    assert!(
        h.app.mailbox.inbox.is_empty(),
        "mailbox buffer cleared on agent switch"
    );
    assert_eq!(
        h.app.mailbox.inbox_after, 0,
        "inbox cursor reset to 0 on agent switch"
    );
}

#[test]
fn refresh_mailbox_fans_out_to_three_filters_with_recorded_args() {
    // `refresh_mailbox` queries `inbox(agent_id, after)`,
    // `channel_feed(agent_id, after)`, `wire(project_id, after)` —
    // three calls, three filter shapes. The recorder verifies the
    // right filter is asked the right thing.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
    ));
    h.app.dismiss_splash();

    refresh_mailbox(&mut h.app, &h.mailbox);

    assert_eq!(
        *h.mailbox.inbox_calls.lock().unwrap(),
        vec![("writing:manager".into(), 0_i64)],
        "inbox queried for the focused agent's id"
    );
    assert_eq!(
        *h.mailbox.channel_calls.lock().unwrap(),
        vec![("writing:manager".into(), 0_i64)],
        "channel_feed queried for the focused agent's id (membership-scoped server-side)"
    );
    assert_eq!(
        *h.mailbox.wire_calls.lock().unwrap(),
        vec![("writing".into(), 0_i64)],
        "wire queried for the project id, NOT the agent id"
    );
}

#[test]
fn refresh_mailbox_with_seeded_rows_extends_buffers_and_advances_cursors() {
    // Seed canned rows into the recorder; `refresh_mailbox` should
    // fold them into `app.mailbox.<tab>` and bump the matching
    // `<tab>_after` cursor to the batch's high-water id.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
    ));
    h.app.dismiss_splash();
    h.mailbox.inbox_rows = vec![
        mb_row(11, "writing:dev1", "writing:manager", "ready"),
        mb_row(12, "writing:dev2", "writing:manager", "merged"),
    ];
    h.mailbox.wire_rows = vec![mb_row(20, "user:cli", "channel:writing:all", "release cut")];

    refresh_mailbox(&mut h.app, &h.mailbox);

    assert_eq!(h.app.mailbox.inbox.len(), 2);
    assert_eq!(h.app.mailbox.inbox_after, 12);
    assert_eq!(h.app.mailbox.wire.len(), 1);
    assert_eq!(h.app.mailbox.wire_after, 20);
    // Channel returned no rows → its buffer + cursor stay empty.
    assert!(h.app.mailbox.channel.is_empty());
    assert_eq!(h.app.mailbox.channel_after, 0);
}

#[test]
fn refresh_mailbox_no_op_when_no_agent_focused() {
    // With an empty roster, no agent is focused; `refresh_mailbox`
    // must early-out without fanning out queries — there's no
    // agent_id to filter on. The recorder's call vectors stay
    // empty.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    assert!(h.app.selected_agent.is_none());

    refresh_mailbox(&mut h.app, &h.mailbox);

    assert!(h.mailbox.inbox_calls.lock().unwrap().is_empty());
    assert!(h.mailbox.channel_calls.lock().unwrap().is_empty());
    assert!(h.mailbox.wire_calls.lock().unwrap().is_empty());
}

// ── Ctrl+letter chord casing-fold sweep (T-082) ─────────────────
//
// Same root cause as T-079-C: with CapsLock or Shift+Ctrl,
// crossterm reports `KeyCode::Char('X')` (uppercase) and the
// chord arm dies silently if it only matches one casing. T-082
// sweeps the remaining Ctrl+letter chord arms — Ctrl+H/J/K/L
// (split-cycle) and Ctrl+Q (close-focused-split).

fn harness_with_two_splits() -> Harness {
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "writing",
        vec![
            synth_agent("writing:manager", AgentState::Running, 0, 0),
            synth_agent("writing:dev1", AgentState::Running, 0, 0),
        ],
    ));
    h.app.dismiss_splash();
    h.app.add_detail_split_vertical();
    h.app.add_detail_split_vertical();
    assert_eq!(h.app.detail_splits.len(), 2);
    h
}

#[test]
fn ctrl_h_lowercase_cycles_split_prev() {
    let mut h = harness_with_two_splits();
    h.app.selected_split = 0;

    h.dispatch_key_mods(KeyCode::Char('h'), KeyModifiers::CONTROL);

    assert_eq!(h.app.selected_split, 1);
}

#[test]
fn ctrl_shift_h_cycles_split_prev() {
    // CapsLock or Shift+Ctrl reports `Char('H')` — must still cycle.
    let mut h = harness_with_two_splits();
    h.app.selected_split = 0;

    h.dispatch_key_mods(
        KeyCode::Char('H'),
        KeyModifiers::CONTROL | KeyModifiers::SHIFT,
    );

    assert_eq!(h.app.selected_split, 1);
}

#[test]
fn ctrl_k_lowercase_cycles_split_prev() {
    let mut h = harness_with_two_splits();
    h.app.selected_split = 0;

    h.dispatch_key_mods(KeyCode::Char('k'), KeyModifiers::CONTROL);

    assert_eq!(h.app.selected_split, 1);
}

#[test]
fn ctrl_shift_k_cycles_split_prev() {
    let mut h = harness_with_two_splits();
    h.app.selected_split = 0;

    h.dispatch_key_mods(
        KeyCode::Char('K'),
        KeyModifiers::CONTROL | KeyModifiers::SHIFT,
    );

    assert_eq!(h.app.selected_split, 1);
}

#[test]
fn ctrl_l_lowercase_cycles_split_next() {
    let mut h = harness_with_two_splits();
    h.app.selected_split = 0;

    h.dispatch_key_mods(KeyCode::Char('l'), KeyModifiers::CONTROL);

    assert_eq!(h.app.selected_split, 1);
}

#[test]
fn ctrl_shift_l_cycles_split_next() {
    let mut h = harness_with_two_splits();
    h.app.selected_split = 0;

    h.dispatch_key_mods(
        KeyCode::Char('L'),
        KeyModifiers::CONTROL | KeyModifiers::SHIFT,
    );

    assert_eq!(h.app.selected_split, 1);
}

#[test]
fn ctrl_j_lowercase_cycles_split_next() {
    let mut h = harness_with_two_splits();
    h.app.selected_split = 0;

    h.dispatch_key_mods(KeyCode::Char('j'), KeyModifiers::CONTROL);

    assert_eq!(h.app.selected_split, 1);
}

#[test]
fn ctrl_shift_j_cycles_split_next() {
    let mut h = harness_with_two_splits();
    h.app.selected_split = 0;

    h.dispatch_key_mods(
        KeyCode::Char('J'),
        KeyModifiers::CONTROL | KeyModifiers::SHIFT,
    );

    assert_eq!(h.app.selected_split, 1);
}

#[test]
fn ctrl_q_uppercase_closes_focused_split() {
    let mut h = harness_with_two_splits();

    h.dispatch_key_mods(
        KeyCode::Char('Q'),
        KeyModifiers::CONTROL | KeyModifiers::SHIFT,
    );

    assert_eq!(h.app.detail_splits.len(), 1);
}

#[test]
fn ctrl_q_lowercase_closes_focused_split() {
    // Plain Ctrl+q without Shift — operator without CapsLock typing
    // the natural unshifted form. Must still close the focused split.
    let mut h = harness_with_two_splits();

    h.dispatch_key_mods(KeyCode::Char('q'), KeyModifiers::CONTROL);

    assert_eq!(h.app.detail_splits.len(), 1);
}

// ── T-32: compose-modal Tab attach overlay ──────────────────────

#[test]
fn tab_in_compose_modal_opens_attach_input_overlay() {
    // Tab is unused by the editor today; intercepting it inside the
    // modal opens the path-input overlay without bleeding a literal
    // Tab character into the body.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@')); // open compose
    type_body(&mut h, "before-tab");

    h.dispatch_key(KeyCode::Tab);

    assert!(
        h.app.compose_attach_input_open,
        "Tab opens the attach-input overlay"
    );
    assert!(
        h.app.compose_attach_buffer.is_empty(),
        "buffer starts empty"
    );
    assert_eq!(
        h.app.compose_editor.body(),
        "before-tab",
        "Tab does not reach the editor"
    );
}

#[test]
fn typing_in_attach_overlay_fills_buffer_not_editor() {
    // While the overlay is open keypresses go to the buffer; the
    // underlying editor body stays exactly as the operator left it
    // before pressing Tab. This is the property that lets Esc
    // restore the editor with no edits.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    type_body(&mut h, "draft body");
    h.dispatch_key(KeyCode::Tab);

    for c in "/tmp/file.md".chars() {
        h.dispatch_key(KeyCode::Char(c));
    }

    assert_eq!(h.app.compose_attach_buffer, "/tmp/file.md");
    assert_eq!(h.app.compose_editor.body(), "draft body");
}

#[test]
fn enter_in_attach_overlay_appends_marker_and_closes_overlay() {
    // Affirmative path: operator confirms a path; the editor body
    // gains a `📎 attachment: <path>` line on its own row, the
    // overlay closes, the buffer is cleared.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    type_body(&mut h, "see attached");
    h.dispatch_key(KeyCode::Tab);
    for c in "/home/alice/notes.md".chars() {
        h.dispatch_key(KeyCode::Char(c));
    }
    h.dispatch_key(KeyCode::Enter);

    assert!(!h.app.compose_attach_input_open, "overlay closes on Enter");
    assert_eq!(h.app.compose_attach_buffer, "");
    let body = h.app.compose_editor.body();
    assert!(
        body.contains("see attached"),
        "original body preserved: {body}"
    );
    assert!(
        body.contains("📎 attachment: /home/alice/notes.md"),
        "marker appended: {body}"
    );
    // Marker is on its own line, not concatenated with body text.
    assert!(
        body.lines()
            .any(|l| l.trim() == "📎 attachment: /home/alice/notes.md"),
        "marker on its own line: {body:?}"
    );
}

#[test]
fn esc_in_attach_overlay_cancels_back_to_editor() {
    // Modal-vs-overlay symmetry: Esc here dismisses the overlay,
    // not the whole compose modal. Mirrors the picker-overlay
    // behaviour PR-UI-6 established.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    type_body(&mut h, "draft");
    h.dispatch_key(KeyCode::Tab);
    for c in "/tmp/x".chars() {
        h.dispatch_key(KeyCode::Char(c));
    }

    h.dispatch_key(KeyCode::Esc);

    assert!(!h.app.compose_attach_input_open, "overlay dismissed");
    assert_eq!(h.app.compose_attach_buffer, "", "buffer cleared on cancel");
    assert_eq!(h.app.stage, Stage::ComposeModal, "compose modal still open");
    assert!(
        !h.app.compose_editor.body().contains("📎"),
        "no marker appended on cancel"
    );
}

#[test]
fn enter_with_empty_buffer_closes_overlay_without_appending() {
    // Tab → Enter (no path typed) is harmless: overlay closes and
    // the editor body is untouched. Avoids a stray empty-marker
    // line if the operator changes their mind.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    type_body(&mut h, "body");
    h.dispatch_key(KeyCode::Tab);

    h.dispatch_key(KeyCode::Enter);

    assert!(!h.app.compose_attach_input_open);
    assert_eq!(h.app.compose_editor.body(), "body");
    assert!(
        !h.app.compose_editor.body().contains("📎"),
        "no marker on empty confirm"
    );
}

#[test]
fn backspace_in_attach_overlay_pops_buffer() {
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    h.dispatch_key(KeyCode::Tab);
    for c in "/tmp/abc".chars() {
        h.dispatch_key(KeyCode::Char(c));
    }

    h.dispatch_key(KeyCode::Backspace);
    h.dispatch_key(KeyCode::Backspace);

    assert_eq!(h.app.compose_attach_buffer, "/tmp/a");
}

#[test]
fn multiple_attach_confirms_append_separate_marker_lines() {
    // Tab → confirm → Tab → confirm produces two `📎 attachment:`
    // lines on their own rows. Pinning current behaviour so a
    // future refactor can't accidentally clobber the first marker.
    // T-32b's broker parser will key on the per-line prefix; the
    // wire format must support multiple attachments per body.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    type_body(&mut h, "two files");

    h.dispatch_key(KeyCode::Tab);
    for c in "/home/alice/a.md".chars() {
        h.dispatch_key(KeyCode::Char(c));
    }
    h.dispatch_key(KeyCode::Enter);

    h.dispatch_key(KeyCode::Tab);
    for c in "/home/alice/b.md".chars() {
        h.dispatch_key(KeyCode::Char(c));
    }
    h.dispatch_key(KeyCode::Enter);

    let body = h.app.compose_editor.body();
    let marker_lines: Vec<&str> = body
        .lines()
        .filter(|l| l.trim_start().starts_with("📎 attachment: "))
        .collect();
    assert_eq!(
        marker_lines.len(),
        2,
        "two confirms produce two markers: {body:?}"
    );
    assert!(
        marker_lines.iter().any(|l| l.contains("a.md")),
        "first marker present: {body:?}"
    );
    assert!(
        marker_lines.iter().any(|l| l.contains("b.md")),
        "second marker present: {body:?}"
    );
}

#[test]
fn closing_compose_modal_clears_attach_overlay_state() {
    // A cancelled draft must not survive a close-and-reopen of the
    // modal — leftover state would surprise the operator on the
    // next Tab.
    let mut h = dm_compose_setup();
    h.dispatch_key(KeyCode::Char('@'));
    h.dispatch_key(KeyCode::Tab);
    for c in "/tmp/leak".chars() {
        h.dispatch_key(KeyCode::Char(c));
    }

    // Esc the overlay first, then double-Esc the modal to close.
    h.dispatch_key(KeyCode::Esc);
    h.dispatch_key(KeyCode::Esc);
    h.dispatch_key(KeyCode::Esc);

    assert_ne!(h.app.stage, Stage::ComposeModal, "compose modal closed");
    // Reopen and confirm clean state.
    h.app.select_next();
    h.dispatch_key(KeyCode::Char('@'));
    assert!(!h.app.compose_attach_input_open);
    assert_eq!(h.app.compose_attach_buffer, "");
}

// ── T-158 mouse-scroll routing ─────────────────────────────────────

#[test]
fn mouse_wheel_in_detail_pane_forwards_scroll_to_focused_agent_session() {
    // The load-bearing path: with Detail focused and an agent
    // selected, every wheel tick lands as a `scroll(session, dir)`
    // call on the KeySender so tmux walks the agent's pane history.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "t",
        vec![synth_agent("t:m", AgentState::Running, 0, 0)],
    ));
    h.app.dismiss_splash();
    h.dispatch_key(KeyCode::Tab); // Roster → Detail
    assert_eq!(h.app.focused_pane, Pane::Detail);

    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);

    let calls = h.key_sender.scroll_calls.lock().unwrap();
    assert_eq!(calls.len(), 3, "every wheel tick forwards exactly once");
    let session = "t-t-m"; // synth_agent's `format!("t-{project}-{agent}")`
    assert_eq!(
        *calls,
        vec![
            (session.into(), ScrollDirection::Up),
            (session.into(), ScrollDirection::Up),
            (session.into(), ScrollDirection::Down),
        ]
    );
    // Keystroke channel must remain silent — scroll goes through
    // its own KeySender entry point.
    assert!(h.key_sender.calls.lock().unwrap().is_empty());
}

#[test]
fn mouse_wheel_in_detail_pane_is_silent_when_no_agent_selected() {
    // Empty team → no `focused_session()`. Wheel ticks must not
    // panic and must not produce phantom forwards.
    let mut h = Harness::new();
    h.app.dismiss_splash();
    assert_eq!(h.app.focused_pane, Pane::Roster);
    h.dispatch_key(KeyCode::Tab); // → Detail
    assert!(h.app.selected_agent_id().is_none());

    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);

    assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
}

#[test]
fn mouse_wheel_in_roster_steps_agent_selection() {
    // Roster wheel = `j` / `k`. Three agents, start at index 0,
    // ScrollDown advances to 1, ScrollUp returns to 0.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "t",
        vec![
            synth_agent("t:a", AgentState::Running, 0, 0),
            synth_agent("t:b", AgentState::Running, 0, 0),
            synth_agent("t:c", AgentState::Running, 0, 0),
        ],
    ));
    h.app.dismiss_splash();
    assert_eq!(h.app.focused_pane, Pane::Roster);
    assert_eq!(h.app.selected_agent_id().as_deref(), Some("t:a"));

    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
    assert_eq!(h.app.selected_agent_id().as_deref(), Some("t:b"));

    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
    assert_eq!(h.app.selected_agent_id().as_deref(), Some("t:c"));

    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
    assert_eq!(h.app.selected_agent_id().as_deref(), Some("t:b"));

    // Roster routing must not leak into the KeySender channels.
    assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
    assert!(h.key_sender.calls.lock().unwrap().is_empty());
}

#[test]
fn mouse_wheel_in_mailbox_is_a_noop_for_v1() {
    // T-131 will wire mailbox row-cursor scrolling. v1 ships the
    // routing scaffold only — Mailbox-focused wheel ticks must not
    // forward to KeySender (that's Detail's lane) and must not
    // accidentally cycle the tab (left/right arrows own that).
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "t",
        vec![synth_agent("t:m", AgentState::Running, 0, 0)],
    ));
    h.app.dismiss_splash();
    h.dispatch_key(KeyCode::Tab); // → Detail
    h.dispatch_key(KeyCode::Tab); // → Mailbox
    assert_eq!(h.app.focused_pane, Pane::Mailbox);
    let tab_before = h.app.mailbox_tab;

    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);

    assert_eq!(
        h.app.mailbox_tab, tab_before,
        "mailbox wheel must not cycle tabs in v1 — arrows own that"
    );
    assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
    assert!(h.key_sender.calls.lock().unwrap().is_empty());
}

#[test]
fn mouse_wheel_in_non_triptych_stages_is_silent() {
    // Modal overlays own the screen. Wheel ticks fired into Splash,
    // ApprovalsModal, ComposeModal, etc. must not route past — a
    // surprise scroll routed into the agent during a compose modal
    // would be a real UX bug.
    let mut h = Harness::new();
    h.app.replace_team(fixture_team(
        "t",
        vec![synth_agent("t:m", AgentState::Running, 0, 0)],
    ));
    assert_eq!(h.app.stage, Stage::Splash);

    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
    assert_eq!(
        h.app.stage,
        Stage::Splash,
        "wheel must not dismiss the splash stage"
    );
    assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());

    // ComposeModal — open it, fire wheel, assert silent.
    h.app.dismiss_splash();
    h.dispatch_key(KeyCode::Char('@'));
    assert_eq!(h.app.stage, Stage::ComposeModal);
    h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
    assert_eq!(h.app.stage, Stage::ComposeModal, "modal stays open");
    assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
}
