// crates/atomcode-tuix/src/input/reader.rs
use std::sync::mpsc::{self as stdmpsc, TryRecvError};
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::event::{DisableFocusChange, EnableFocusChange};
use crossterm::execute;
use tokio::sync::mpsc;

use super::InputEvent;

/// Burst-aggregation poll timeout — how long the burst detector waits
/// for the next event before deciding the burst is over. Picked per-OS
/// because terminal stdin delivery cadence differs:
///
/// - **Windows** (PowerShell / Windows Terminal / conhost): bracketed
///   paste payloads and char-by-char fallback delivery arrive in
///   chunked stdin batches with 5-12 ms gaps between chunks; the old
///   2 ms window split one logical Ctrl+V into 5-10 `[Pasted #N]`
///   placeholders. 15 ms swallows those gaps without meaningfully
///   extending per-keystroke typing latency — still well below the
///   ~20 ms human perception floor.
/// - **macOS / Linux**: bracketed paste arrives as one event in
///   practice; the timeout only matters for the rare no-bracketed-paste
///   fallback. 4 ms covers occasional 2-3 ms gaps seen on slow SSH
///   sessions without adding perceptible typing lag.
///
/// Prior art: DeepSeek-TUI's `paste_burst.rs` ships Windows 60 ms /
/// Unix 8 ms, but those depend on a two-stage state machine
/// (short pending window → long active window) that gates the long
/// timeout behind "burst confirmed". atomcode currently runs the
/// timeout on *every* keystroke, so we keep both values comfortably
/// below human perception. A follow-up will add the state machine,
/// after which 60 ms can be safely adopted on Windows.
#[cfg(target_os = "windows")]
const BURST_POLL_TIMEOUT_MS: u64 = 15;
#[cfg(not(target_os = "windows"))]
const BURST_POLL_TIMEOUT_MS: u64 = 4;

/// If a Key event could plausibly be part of a paste burst, return the
/// character it contributes. Enter maps to `\n`, Tab to `\t`, Char(c) to
/// itself. Modifier-carrying keys (Ctrl/Alt) and non-Press kinds are
/// excluded — those are commands, not pasted content.
fn paste_candidate_char(ev: &Event) -> Option<char> {
    let Event::Key(KeyEvent {
        kind,
        code,
        modifiers,
        ..
    }) = ev
    else {
        return None;
    };
    if *kind != KeyEventKind::Press {
        return None;
    }
    // Shift is fine (Shift+letter on paste of uppercase). Anything else
    // means the user is issuing a command.
    let allowed = KeyModifiers::SHIFT | KeyModifiers::NONE;
    if !(modifiers.difference(allowed).is_empty()) {
        return None;
    }
    match code {
        KeyCode::Char(c) => Some(*c),
        // Shift+Enter is "insert newline", a user command — never a
        // paste-burst char. Real pasted newlines arrive as Event::Paste
        // (bracketed paste) or as plain Enter with NO modifier (conhost
        // char-by-char). If we let Shift+Enter in here, the single-event
        // else-branch at the bottom reconstructs KeyEvent with NONE
        // modifiers and classify then collapses it to Submit.
        KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => None,
        KeyCode::Enter => Some('\n'),
        KeyCode::Tab => Some('\t'),
        _ => None,
    }
}

/// True when an aggregated `paste_candidate_char` burst should be treated
/// as a real `InputEvent::Paste` rather than emitted as individual key
/// events. Conjuncted conditions:
///
/// 1. **At least 2 chars** — singletons are normal typing.
/// 2. **Contains `\n`** — the unambiguous "this is multi-line content"
///    signal. Bursts of plain printable chars (someone typing fast) get
///    handled per-key just fine without aggregation.
/// 3. **At least one non-whitespace char** — distinguishes a real paste
///    from buffered Enter/Tab keystrokes left in the tty input queue at
///    startup. Without this guard, two Enters mashed by the user before
///    atomcode took over the terminal (e.g. while waiting for a slow
///    `cargo build` to finish) get aggregated into `Paste("\n\n")` and
///    inserted as text — the input box opens with two pre-typed blank
///    lines. Genuine pastes containing only whitespace + newlines are
///    vanishingly rare; falling back to per-key submission of those bursts
///    is the right trade-off.
/// 4. **Avg ≥ 2 non-newline chars per line** when the burst is 3+ lines.
///    Defends against the JediTerm IME commit storm reported on Windows:
///    every Pinyin candidate selection emitted `<char> + Enter` in rapid
///    succession (within the 2ms aggregation window), producing a burst
///    like `[首, \n, 页, \n, 中, \n, …]`. Old heuristic accepted that as
///    a paste, leaving the buffer with `\n` between every CJK char and
///    the input row showing `首↵页↵中↵…`. Genuine multi-line pastes
///    always have lines with text; IME bursts have exactly 1 text char
///    per line. Threshold scoped to 3+ lines so a legitimate 2-line
///    paste with two single-char lines (rare but possible) still flows
///    through the paste path.
fn is_paste_burst(chars: &[char]) -> bool {
    if chars.len() < 2 {
        return false;
    }
    let mut has_enter = false;
    let mut has_text_char = false;
    let mut newline_count = 0usize;
    for &c in chars {
        if c == '\n' {
            has_enter = true;
            newline_count += 1;
        }
        if !c.is_whitespace() {
            has_text_char = true;
        }
    }
    if !has_enter || !has_text_char {
        return false;
    }
    let line_count = newline_count + 1;
    let non_newline_count = chars.len() - newline_count;
    if line_count >= 3 && non_newline_count <= line_count {
        // Mean ≤ 1 char per line. JediTerm IME pattern, not a paste.
        return false;
    }
    true
}

/// Lifecycle commands for the reader thread. Sent from the event loop
/// whenever an external process (OAuth browser flow, `/shell`, etc.)
/// needs stdin/stdout in cooked mode without our reader racing for bytes.
#[derive(Debug)]
pub enum ReaderCommand {
    /// Stop calling `event::poll` / `event::read`. The reader blocks on
    /// its command channel until Resume arrives. Sends a single `()` on
    /// `ack` once it's confirmed idle, so the caller can safely take
    /// over stdin without a race.
    Pause,
    /// Resume normal event dispatch. No ack — the next keystroke is
    /// the ack.
    Resume,
    /// Exit the thread. Idempotent; dropping the sender also triggers exit.
    Shutdown,
}

/// Control handle returned from `spawn`. Owns the join handle + the
/// command channel; dropping the handle shuts the reader down cleanly.
pub struct ReaderHandle {
    join: Option<std::thread::JoinHandle<()>>,
    cmd_tx: stdmpsc::Sender<(ReaderCommand, Option<stdmpsc::Sender<()>>)>,
    focus_tracking_enabled: bool,
}

impl ReaderHandle {
    /// Pause + wait for ack. After this returns, the reader is guaranteed
    /// to NOT be inside `event::poll` / `event::read`, so the caller can
    /// disable raw mode and hand stdin to a child process without the
    /// reader stealing bytes.
    ///
    /// Returns early (Ok) if the reader already exited — callers should
    /// treat that as "nothing to pause" rather than an error.
    pub fn pause_blocking(&self) -> std::io::Result<()> {
        let (ack_tx, ack_rx) = stdmpsc::channel();
        if self
            .cmd_tx
            .send((ReaderCommand::Pause, Some(ack_tx)))
            .is_err()
        {
            return Ok(()); // reader already gone
        }
        // Bounded wait — if the reader is stuck inside `event::poll` we
        // still ACK within the 100ms poll timeout.
        match ack_rx.recv_timeout(Duration::from_secs(2)) {
            Ok(()) => Ok(()),
            Err(_) => Err(std::io::Error::new(
                std::io::ErrorKind::TimedOut,
                "reader thread did not ack Pause within 2s",
            )),
        }
    }

    /// Resume from Pause. Fire-and-forget — the next keystroke the user
    /// presses becomes the implicit ack.
    pub fn resume(&self) {
        let _ = self.cmd_tx.send((ReaderCommand::Resume, None));
    }
}

impl Drop for ReaderHandle {
    fn drop(&mut self) {
        let _ = self.cmd_tx.send((ReaderCommand::Shutdown, None));
        if self.focus_tracking_enabled {
            let _ = execute!(std::io::stdout(), DisableFocusChange);
            atomcode_core::notify::set_terminal_focus_state(None);
        }
        // Let the thread finish on its own — we don't join here because
        // the reader may be blocked inside `event::poll` for up to 100ms
        // and we'd rather not stall caller shutdown.
        if let Some(join) = self.join.take() {
            drop(join);
        }
    }
}

/// Spawn a blocking OS thread that reads crossterm events and forwards them
/// over `tx`. Returns a `ReaderHandle` for lifecycle control (Pause /
/// Resume / Shutdown). The thread exits when:
/// - the `ReaderHandle` is dropped (Shutdown sent),
/// - `tx` is closed (send returns Err),
/// - or a fatal crossterm read error fires.
pub fn spawn(tx: mpsc::UnboundedSender<InputEvent>) -> ReaderHandle {
    let focus_tracking_enabled = terminal_supports_focus_tracking();
    if focus_tracking_enabled {
        let _ = execute!(std::io::stdout(), EnableFocusChange);
        atomcode_core::notify::set_terminal_focus_state(Some(true));
    }
    let (cmd_tx, cmd_rx) = stdmpsc::channel::<(ReaderCommand, Option<stdmpsc::Sender<()>>)>();
    let join = std::thread::spawn(move || run(tx, cmd_rx));
    ReaderHandle {
        join: Some(join),
        cmd_tx,
        focus_tracking_enabled,
    }
}

fn terminal_supports_focus_tracking() -> bool {
    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
    let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default();
    term_program == "iTerm.app"
        || term_program.eq_ignore_ascii_case("iTerm2")
        || lc_terminal.eq_ignore_ascii_case("iTerm2")
}

/// Decide what the reader loop should do next, given the `event::poll`
/// result and whether the input channel is still alive. Extracted from
/// `run` so the four-way classification can be unit-tested without
/// spinning up a real TTY.
#[derive(Debug, PartialEq, Eq)]
enum PollAction {
    /// `poll` said "event available" — proceed to `event::read`.
    Read,
    /// No event in this tick and channel still open — loop again.
    Continue,
    /// No event and the input channel was dropped — exit the thread.
    Exit,
    /// `poll` returned `Err` — treat as a transient glitch (Windows
    /// crossterm has been seen to fail `poll`/`read` during terminal
    /// resize). Sleep briefly and loop. Critically, this is NOT
    /// `Exit` — returning here would kill the reader thread and
    /// collapse the event loop (`input_rx` closes → `maybe = None`
    /// → break), which is the "atomcode exits when I resize on
    /// Windows" bug.
    Sleep,
}

fn classify_poll(res: std::io::Result<bool>, tx_closed: bool) -> PollAction {
    match res {
        Ok(true) => PollAction::Read,
        Ok(false) if tx_closed => PollAction::Exit,
        Ok(false) => PollAction::Continue,
        Err(_) => PollAction::Sleep,
    }
}

/// Minimum gap between two modifier+Enter Press events to count them as
/// distinct user actions. Anything closer is treated as OS key autorepeat
/// leaking through as Press events (happens on terminals that advertise
/// CSI u support but don't implement `REPORT_EVENT_TYPES`, so crossterm
/// can't tag autorepeat as `KeyEventKind::Repeat`).
///
/// 40 ms sits between OS autorepeat cadence (~30 ms on macOS / Linux) and
/// the fastest humans can actually chord Shift+Enter twice (~100+ ms).
/// Scoped to Enter-with-modifiers only — plain-key autorepeat (Backspace,
/// arrows) remains useful and is left untouched.
const MODIFIER_ENTER_DEDUP: Duration = Duration::from_millis(40);

fn run(
    tx: mpsc::UnboundedSender<InputEvent>,
    cmd_rx: stdmpsc::Receiver<(ReaderCommand, Option<stdmpsc::Sender<()>>)>,
) {
    let mut paused = false;
    // Last accepted (modifiers, timestamp) for a modifier+Enter Press.
    // Used to drop autorepeat duplicates that slip past the terminal
    // protocol's Repeat filtering.
    let mut last_mod_enter: Option<(KeyModifiers, std::time::Instant)> = None;
    loop {
        // If paused, block on the command channel — no poll, no read, so
        // the child process owns stdin cleanly. Only Resume / Shutdown
        // exit the paused state.
        if paused {
            match cmd_rx.recv() {
                Ok((ReaderCommand::Resume, _)) => {
                    paused = false;
                }
                Ok((ReaderCommand::Shutdown, _)) | Err(_) => return,
                Ok((ReaderCommand::Pause, ack)) => {
                    // Already paused — just re-ack so the caller unblocks.
                    if let Some(ack) = ack {
                        let _ = ack.send(());
                    }
                }
            }
            continue;
        }

        // Non-blocking drain of any pending command before each poll.
        // Multiple Pause requests can coalesce here.
        match cmd_rx.try_recv() {
            Ok((ReaderCommand::Pause, ack)) => {
                paused = true;
                if let Some(ack) = ack {
                    let _ = ack.send(());
                }
                continue;
            }
            Ok((ReaderCommand::Resume, _)) => {
                // Already running — ignore.
            }
            Ok((ReaderCommand::Shutdown, _)) => return,
            Err(TryRecvError::Disconnected) => return,
            Err(TryRecvError::Empty) => {}
        }

        match classify_poll(event::poll(Duration::from_millis(100)), tx.is_closed()) {
            PollAction::Read => {}
            PollAction::Continue => continue,
            PollAction::Exit => return,
            PollAction::Sleep => {
                std::thread::sleep(Duration::from_millis(50));
                continue;
            }
        }
        let ev = match event::read() {
            Ok(e) => e,
            Err(_) => {
                std::thread::sleep(Duration::from_millis(50));
                continue;
            }
        };

        // Autorepeat dedup for modifier+Enter. iTerm2's current CSI u
        // implementation (3.5+/3.6) disambiguates Shift+Enter modifiers
        // correctly but doesn't honour `REPORT_EVENT_TYPES`, so a held
        // Shift+Enter emits N Press events at OS autorepeat cadence and
        // the input box inserts N newlines for one physical keystroke.
        // Drop same-modifier repeats that arrive within the dedup window.
        if let Event::Key(k) = &ev {
            if k.kind == KeyEventKind::Press && k.code == KeyCode::Enter && !k.modifiers.is_empty()
            {
                let now = std::time::Instant::now();
                if let Some((last_mods, last_at)) = last_mod_enter {
                    if last_mods == k.modifiers
                        && now.duration_since(last_at) < MODIFIER_ENTER_DEDUP
                    {
                        crate::tuix_trace!("RD", "dedup mod+Enter {:?}", k.modifiers);
                        last_mod_enter = Some((k.modifiers, now));
                        continue;
                    }
                }
                last_mod_enter = Some((k.modifiers, now));
            }
        }

        // Paste-burst detection for terminals without bracketed paste
        // (Windows conhost, some PowerShell setups). When a user pastes
        // multi-line text there, crossterm emits each character as an
        // individual `Event::Key` — including embedded Enters, which
        // individually trigger submit and produced "many queued
        // submits". Real bracketed paste lands here as `Event::Paste`
        // and this block is a no-op.
        //
        // Heuristic: if this event is a printable char / Enter / Tab
        // AND more events are ALREADY queued (peek with 0-timeout
        // poll), we're almost certainly inside a paste burst — real
        // typing has human-scale gaps so the queue is empty on peek.
        // Aggregate consecutive paste-candidate events and emit one
        // synthetic `InputEvent::Paste`. Only triggers when the burst
        // contains an Enter (the unambiguous "this is multi-line
        // pasted text, not typing" signal); burst of chars without
        // Enter falls through to the normal per-key path — it looks
        // the same to the user either way and keeps the heuristic
        // conservative.
        if let Some(c0) = paste_candidate_char(&ev) {
            let mut chars = vec![c0];
            let mut trailing: Option<Event> = None;
            const BATCH_CAP: usize = 8192;
            while chars.len() < BATCH_CAP {
                // Bridge the transient gap a terminal takes to translate
                // each console record into an Event. A paste arriving as
                // chunked stdin batches gets split into per-record events
                // and a strict `poll(0)` would miss the burst signature.
                // See `BURST_POLL_TIMEOUT_MS` for per-OS rationale.
                match event::poll(Duration::from_millis(BURST_POLL_TIMEOUT_MS)) {
                    Ok(true) => {}
                    _ => break,
                }
                let nxt = match event::read() {
                    Ok(e) => e,
                    Err(_) => break,
                };
                // Windows crossterm in raw mode emits Press + Release
                // (and Repeat on autorepeat). Release/Repeat interleaved
                // with the paste burst used to kill aggregation — the
                // very next event after 'A' Press is 'A' Release, which
                // `paste_candidate_char` rejects, so we'd break out with
                // chars=[A] and never see the rest of the burst. Skip
                // non-Press Key events silently so the burst detector
                // walks through to the next printable-char Press.
                if let Event::Key(k) = &nxt {
                    if k.kind != KeyEventKind::Press {
                        continue;
                    }
                }
                match paste_candidate_char(&nxt) {
                    Some(c) => {
                        chars.push(c);
                    }
                    None => {
                        trailing = Some(nxt);
                        break;
                    }
                }
            }
            if is_paste_burst(&chars) {
                let text: String = chars.into_iter().collect();
                crate::tuix_trace!("RD", "paste-burst synth len={}", text.len());
                if tx.send(InputEvent::Paste(text)).is_err() {
                    return;
                }
            } else {
                // Not a clear paste signature — emit originals per-key.
                // We only kept chars, so reconstruct KeyEvents. The
                // first event we read is `ev`; subsequent ones we
                // discarded in favour of `chars`. Rebuild from chars
                // using a minimal KeyEvent (no modifiers) — this path
                // fires in the rare case where events piled up but
                // there was no Enter, i.e. fast typing or single-line
                // paste. Both look the same on screen, so a synthetic
                // reconstruction is faithful to user intent.
                for c in chars {
                    let code = match c {
                        '\n' => KeyCode::Enter,
                        '\t' => KeyCode::Tab,
                        other => KeyCode::Char(other),
                    };
                    let k = KeyEvent::new(code, KeyModifiers::NONE);
                    if tx.send(InputEvent::Key(k)).is_err() {
                        return;
                    }
                }
            }
            // Dispatch whatever non-paste event broke the burst.
            if let Some(ev) = trailing {
                let msg = match ev {
                    Event::Key(k) => {
                        crate::tuix_trace!("RD", "key {:?} {:?}", k.kind, k.code);
                        InputEvent::Key(k)
                    }
                    Event::Paste(p) => InputEvent::Paste(p),
                    Event::Resize(w, h) => InputEvent::Resize(w, h),
                    Event::Mouse(m) => match mouse_input_event(m) {
                        Some(ev) => ev,
                        None => continue,
                    },
                    Event::FocusGained => {
                        atomcode_core::notify::set_terminal_focus_state(Some(true));
                        continue;
                    }
                    Event::FocusLost => {
                        atomcode_core::notify::set_terminal_focus_state(Some(false));
                        continue;
                    }
                };
                if tx.send(msg).is_err() {
                    return;
                }
            }
            continue;
        }

        let msg = match ev {
            Event::Key(k) => {
                crate::tuix_trace!("RD", "key {:?} {:?}", k.kind, k.code);
                InputEvent::Key(k)
            }
            Event::Paste(p) => {
                crate::tuix_trace!("RD", "paste len={}", p.len());
                InputEvent::Paste(p)
            }
            Event::Resize(w, h) => {
                crate::tuix_trace!("RD", "resize {}x{}", w, h);
                InputEvent::Resize(w, h)
            }
            Event::Mouse(m) => match mouse_input_event(m) {
                Some(ev) => ev,
                None => continue,
            },
            Event::FocusGained => {
                atomcode_core::notify::set_terminal_focus_state(Some(true));
                continue;
            }
            Event::FocusLost => {
                atomcode_core::notify::set_terminal_focus_state(Some(false));
                continue;
            }
        };
        if tx.send(msg).is_err() {
            return;
        }
    }
}

fn mouse_input_event(m: crossterm::event::MouseEvent) -> Option<InputEvent> {
    // Trace EVERY arrival, regardless of kind. The kind-specific arms
    // below only log scroll/down/drag/up; on Windows conhost a wheel
    // tick can arrive as `Moved` or another variant we silently drop,
    // and without this top-of-function trace there's no way to tell
    // "no mouse events arriving" from "events arriving but ignored".
    crate::tuix_trace!("RD", "mouse kind={:?} col={} row={}", m.kind, m.column, m.row);
    match m.kind {
        crossterm::event::MouseEventKind::ScrollUp => {
            crate::tuix_trace!("RD", "mouse scroll up");
            Some(InputEvent::MouseScroll(-3))
        }
        crossterm::event::MouseEventKind::ScrollDown => {
            crate::tuix_trace!("RD", "mouse scroll down");
            Some(InputEvent::MouseScroll(3))
        }
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Pause/Resume round trip without touching crossterm — feeds commands
    /// directly into the `run` worker via an in-memory channel pair. This
    /// exercises the paused-state ACK path that the OAuth flow depends on
    /// without needing a real TTY.
    #[test]
    fn pause_acks_then_resume_wakes() {
        let (tx, _rx) = mpsc::unbounded_channel::<InputEvent>();
        let (cmd_tx, cmd_rx) = stdmpsc::channel();
        let worker = std::thread::spawn(move || run(tx, cmd_rx));

        // Send Pause and wait for ack.
        let (ack_tx, ack_rx) = stdmpsc::channel();
        cmd_tx
            .send((ReaderCommand::Pause, Some(ack_tx)))
            .expect("send pause");
        ack_rx
            .recv_timeout(Duration::from_secs(2))
            .expect("pause ACK arrives within 2s");

        // Resend Pause — already paused, the worker must still ACK so
        // callers don't deadlock on a re-entrant pause.
        let (ack_tx2, ack_rx2) = stdmpsc::channel();
        cmd_tx
            .send((ReaderCommand::Pause, Some(ack_tx2)))
            .expect("send second pause");
        ack_rx2
            .recv_timeout(Duration::from_secs(2))
            .expect("re-entrant pause also ACKs");

        // Resume — should unblock the worker's recv loop.
        cmd_tx
            .send((ReaderCommand::Resume, None))
            .expect("send resume");

        // Shutdown so the thread exits and the test doesn't leak.
        cmd_tx
            .send((ReaderCommand::Shutdown, None))
            .expect("send shutdown");
        worker.join().expect("worker thread joins cleanly");
    }

    /// `MODIFIER_ENTER_DEDUP` must sit above OS autorepeat cadence but
    /// well below any realistic human chord rate. macOS / Linux autorepeat
    /// ticks every ~30 ms; the next intentional Shift+Enter can't physically
    /// happen faster than ~100 ms. 40 ms lands cleanly between the two.
    #[test]
    fn modifier_enter_dedup_window_brackets_autorepeat_but_not_humans() {
        let win = MODIFIER_ENTER_DEDUP.as_millis() as u64;
        assert!(
            win > 30,
            "dedup window {}ms must exceed typical OS autorepeat (30ms) \
             so autorepeat duplicates are caught",
            win
        );
        assert!(
            win < 80,
            "dedup window {}ms must stay below fastest realistic human \
             chord repeat (~100ms) so intentional Shift+Enter×2 still works",
            win
        );
    }

    /// `BURST_POLL_TIMEOUT_MS` must sit above terminal stdin chunking
    /// cadence (so paste chunks merge into one burst) but stay well
    /// below the ~20 ms human input-perception floor (so single
    /// keystrokes don't accrue per-key lag). The old 2 ms value was
    /// too tight on Windows — PowerShell stdin delivery has 5-12 ms
    /// gaps that fragmented one logical Ctrl+V into 5-10 [Pasted #N]
    /// placeholders.
    #[test]
    fn burst_poll_timeout_within_safe_envelope() {
        let t = BURST_POLL_TIMEOUT_MS;
        assert!(
            t >= 4,
            "{}ms must be >= 4ms — at least the Unix baseline so 2-3ms \
             SSH delivery gaps don't fragment pastes",
            t
        );
        assert!(
            t < 20,
            "{}ms must stay under the ~20ms human input-perception \
             floor so per-keystroke typing latency stays invisible",
            t
        );
    }

    /// Lock the Windows-specific tuned value. PowerShell / Windows
    /// Terminal stdin chunks have 5-12 ms gaps; if a future edit
    /// collapses Windows to the Unix baseline (4 ms), the original
    /// Ctrl+V fragmentation bug returns.
    #[cfg(target_os = "windows")]
    #[test]
    fn burst_poll_timeout_windows_is_tuned_value() {
        assert_eq!(
            BURST_POLL_TIMEOUT_MS, 15,
            "Windows requires the tuned 15ms value, not the Unix baseline"
        );
    }

    /// Lock the Unix baseline. 15 ms is the Windows-tuned value and
    /// would add unnecessary per-keystroke lag for the macOS/Linux
    /// majority where bracketed paste delivers as a single event.
    #[cfg(not(target_os = "windows"))]
    #[test]
    fn burst_poll_timeout_unix_is_baseline() {
        assert_eq!(
            BURST_POLL_TIMEOUT_MS, 4,
            "non-Windows targets use the 4ms baseline, not the Windows 15ms"
        );
    }

    /// Shift+Enter must NOT qualify as a paste-burst char. If it did,
    /// the single-event else-branch of the burst path reconstructs the
    /// KeyEvent with `KeyModifiers::NONE`, stripping SHIFT, and
    /// `key_action::classify` collapses the result to `Submit` instead
    /// of `InsertNewline` — i.e. Shift+Enter silently sends the message.
    #[test]
    fn paste_candidate_rejects_shift_enter() {
        let ev = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
        assert_eq!(
            paste_candidate_char(&ev),
            None,
            "Shift+Enter is a command (InsertNewline), not paste content"
        );
    }

    /// Plain Enter must still flow through the paste-burst path so
    /// multi-line pastes on terminals without bracketed paste (Windows
    /// conhost) still aggregate into a single Paste event.
    #[test]
    fn paste_candidate_accepts_plain_enter() {
        let ev = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(paste_candidate_char(&ev), Some('\n'));
    }

    /// Regression: two Enters left in the tty input queue at startup
    /// (e.g. user mashed Enter while waiting for `cargo build` to
    /// finish before atomcode took over) used to aggregate into a
    /// synthetic `Paste("\n\n")` and insert two blank lines into the
    /// input box on launch. Pure-newline bursts must NOT count as paste.
    #[test]
    fn pure_newline_burst_is_not_paste() {
        assert!(!is_paste_burst(&['\n', '\n']));
        assert!(!is_paste_burst(&['\n', '\n', '\n']));
    }

    /// Whitespace-only bursts (newline + space, newline + tab) likewise
    /// fail the "real content" test — same root cause as the buffered-
    /// Enter case, just with adjacent whitespace instead.
    #[test]
    fn whitespace_only_burst_is_not_paste() {
        assert!(!is_paste_burst(&[' ', '\n']));
        assert!(!is_paste_burst(&['\t', '\n']));
        assert!(!is_paste_burst(&['\n', ' ', '\t', '\n']));
    }

    /// Real multi-line paste (text + embedded newline) must still be
    /// recognised — that's the entire reason the burst path exists for
    /// terminals without bracketed paste.
    #[test]
    fn text_with_newline_burst_is_paste() {
        assert!(is_paste_burst(&['h', 'i', '\n']));
        assert!(is_paste_burst(&['\n', 'h', 'i']));
        assert!(is_paste_burst(&['l', 'i', 'n', 'e', '1', '\n', 'l', 'i', 'n', 'e', '2']));
    }

    /// Bursts without any newline fall through to per-key handling
    /// regardless of length — just fast typing, not a paste signal.
    #[test]
    fn no_newline_burst_is_not_paste() {
        assert!(!is_paste_burst(&['a', 'b', 'c', 'd']));
    }

    /// Regression: JediTerm IME on Windows commits each Pinyin candidate
    /// as `<char> + Enter`, producing bursts of single-char-per-line.
    /// Old heuristic accepted these as pastes; the buffer ended up with
    /// `\n` between every CJK char and the input row showed `首↵页↵中↵…`.
    /// New rule: 3+ lines averaging ≤1 non-newline char per line is the
    /// IME pattern, not a paste.
    #[test]
    fn ime_commit_storm_is_not_paste() {
        // Real-world reproduction from the user screenshot: typing
        // `首页中的` via IME emits `首 \n 页 \n 中 \n 的 \n`.
        assert!(!is_paste_burst(&['首', '\n', '页', '\n', '中', '\n', '的', '\n']));
        // Bare CJK without trailing newline — same shape, also rejected.
        assert!(!is_paste_burst(&['首', '\n', '页', '\n', '中']));
        // ASCII char-per-line bursts also caught (rare keyboard
        // remapping but same root cause — phantom Enter between chars).
        assert!(!is_paste_burst(&['a', '\n', 'b', '\n', 'c', '\n']));
    }

    /// 2-line pastes with two short lines must still flow through the
    /// paste path — the IME-rejection threshold is gated on 3+ lines so
    /// legitimate short pastes aren't caught as collateral.
    #[test]
    fn two_line_short_paste_still_recognised() {
        assert!(is_paste_burst(&['a', '\n', 'b']));
    }

    /// Multi-line paste with substantial text per line stays a paste
    /// even when CJK is involved — char-per-line check counts NON-newline
    /// chars, so `你好世界 \n 再见` (7 non-newline + 1 newline = 2 lines,
    /// avg 3.5/line) sails through.
    #[test]
    fn cjk_multi_line_paste_still_recognised() {
        assert!(is_paste_burst(&['你', '好', '世', '界', '\n', '再', '见']));
    }

    /// Singleton "bursts" are never pastes; aggregation requires ≥ 2.
    #[test]
    fn singleton_burst_is_not_paste() {
        assert!(!is_paste_burst(&['\n']));
        assert!(!is_paste_burst(&['x']));
        assert!(!is_paste_burst(&[]));
    }

    /// Regression for the Windows-resize crash. `crossterm::event::poll`
    /// has been observed to return `Err` during terminal resize on
    /// Windows; the original loop `return`'d on Err, which killed the
    /// reader thread and collapsed the event loop ("atomcode exits
    /// when I resize on Windows"). `classify_poll` must classify
    /// `Err` as `Sleep` (loop again after a short delay), never `Exit`.
    #[test]
    fn classify_poll_err_is_sleep_not_exit() {
        // Real error construction — ErrorKind doesn't matter, the
        // classifier treats all Err the same.
        let boom = std::io::Error::new(std::io::ErrorKind::Other, "resize glitch");
        assert_eq!(classify_poll(Err(boom), false), PollAction::Sleep);
        let boom = std::io::Error::new(std::io::ErrorKind::Other, "another glitch");
        assert_eq!(
            classify_poll(Err(boom), true),
            PollAction::Sleep,
            "Err must NOT be Exit even when tx is closed — exit path \
             is only for clean shutdown via Ok(false) + closed tx"
        );
    }

    /// The three `Ok` branches must classify exactly one action each,
    /// and `Ok(false)` splits on `tx_closed` (the only place the
    /// reader self-terminates in the happy path).
    #[test]
    fn classify_poll_ok_branches() {
        assert_eq!(classify_poll(Ok(true), false), PollAction::Read);
        assert_eq!(
            classify_poll(Ok(true), true),
            PollAction::Read,
            "Ok(true) always reads — caller will notice tx closed on send"
        );
        assert_eq!(classify_poll(Ok(false), false), PollAction::Continue);
        assert_eq!(classify_poll(Ok(false), true), PollAction::Exit);
    }

    /// Dropping the sender side must terminate the worker even while paused.
    /// Without this the event-loop shutdown path would leak the thread on
    /// any session that ever called Pause.
    #[test]
    fn paused_worker_exits_on_sender_drop() {
        let (tx, _rx) = mpsc::unbounded_channel::<InputEvent>();
        let (cmd_tx, cmd_rx) = stdmpsc::channel();
        let worker = std::thread::spawn(move || run(tx, cmd_rx));

        let (ack_tx, ack_rx) = stdmpsc::channel();
        cmd_tx
            .send((ReaderCommand::Pause, Some(ack_tx)))
            .expect("send pause");
        ack_rx
            .recv_timeout(Duration::from_secs(2))
            .expect("pause ACK");

        drop(cmd_tx); // Err on next recv → exit
        worker
            .join()
            .expect("paused worker joins after sender drop");
    }
}