use std::io;
use std::sync::{Arc, Condvar, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};

use portable_pty::{CommandBuilder, PtySize, native_pty_system};

use crate::types::{AppState, Pane, Node, LayoutKind, Window};
use crate::tree::{replace_leaf_with_split, active_pane_mut, kill_leaf};
use crate::format::hostname_cached;

/// Sentinel value for cursor_shape: means "no DECSCUSR received from child yet".
/// When ConPTY passthrough mode is unavailable, DECSCUSR sequences from child
/// processes are consumed by ConPTY and never forwarded.  Using this sentinel
/// lets the rendering code skip emitting any cursor-shape override, so the
/// real terminal keeps its user-configured default cursor.
pub const CURSOR_SHAPE_UNSET: u8 = 255;

/// Send a preemptive cursor-position report (\x1b[1;1R) to the ConPTY input pipe.
///
/// Windows ConPTY sends a Device Status Report (\x1b[6n]) during initialization
/// and **blocks** until the host responds with a cursor-position report.  In
/// portable-pty ≤0.2 this was handled internally, but 0.9+ exposes raw handles
/// and the host must respond.  Writing the response preemptively (before the
/// reader thread even starts) is safe because the data sits in the pipe buffer
/// and ConPTY reads it when ready.
pub fn conpty_preemptive_dsr_response(writer: &mut dyn std::io::Write) {
    let _ = writer.write_all(b"\x1b[1;1R");
    let _ = writer.flush();
}

/// Cached resolved shell path to avoid repeated `which::which()` PATH scans.
/// Resolved once on first use, reused for all subsequent pane spawns.
static CACHED_SHELL_PATH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();

/// Get the cached shell path, resolving via `which` only on first call.
pub fn cached_shell() -> Option<&'static str> {
    CACHED_SHELL_PATH.get_or_init(|| {
        which::which("pwsh").ok()
            .or_else(|| which::which("powershell").ok())
            .or_else(|| which::which("cmd").ok())
            .map(|p| p.to_string_lossy().into_owned())
    }).as_deref()
}

/// Determine the default shell name for window naming (like tmux shows "bash", "zsh").
fn default_shell_name(command: Option<&str>, configured_shell: Option<&str>) -> String {
    if let Some(cmd) = command {
        // Extract the program name from the command string (space-aware)
        let (prog, _) = resolve_shell_program(cmd);
        std::path::Path::new(&prog)
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or(cmd)
            .to_string()
    } else if let Some(shell) = configured_shell {
        // Use configured default-shell name (space-aware)
        let (prog, _) = resolve_shell_program(shell);
        std::path::Path::new(&prog)
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or(shell)
            .to_string()
    } else {
        // Default shell — use cached resolved path
        cached_shell()
            .and_then(|p| std::path::Path::new(p).file_stem().map(|s| s.to_string_lossy().into_owned()))
            .unwrap_or_else(|| "shell".into())
    }
}

pub fn create_window(pty_system: &dyn portable_pty::PtySystem, app: &mut AppState, command: Option<&str>, start_dir: Option<&str>) -> io::Result<()> {
    // ── Fast path: use pre-spawned warm pane when creating a default shell ──
    // The warm pane has its shell already loaded (~470ms for pwsh), so the
    // prompt appears instantly — matching wezterm's "instant tab" feel.
    if command.is_none() && start_dir.is_none() && app.warm_pane.is_some() {
        let wp = app.warm_pane.take().unwrap();
        // Resize to current terminal dimensions if they changed since pre-spawn
        let area = app.last_window_area;
        let rows = if area.height > 1 { area.height } else { 30 }.max(MIN_PANE_DIM);
        let cols = if area.width > 1 { area.width } else { 120 }.max(MIN_PANE_DIM);
        if rows != wp.rows || cols != wp.cols {
            let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
            wp.master.resize(size).ok();
            // Resize the vt100 parser too — otherwise it stays at the
            // old warm-pane dimensions while last_rows/last_cols are
            // set to the new size, causing resize_all_panes to skip
            // it (dimensions already match) and the parser to render
            // rows/cols beyond its grid as blank spaces.
            if let Ok(mut parser) = wp.term.lock() {
                parser.screen_mut().set_size(rows, cols);
            }
        }
        let epoch = std::time::Instant::now() - Duration::from_secs(2);
        let configured_shell = if app.default_shell.is_empty() { None } else { Some(app.default_shell.as_str()) };
        let pane = Pane { master: wp.master, writer: wp.writer, child: wp.child, term: wp.term, last_rows: rows, last_cols: cols, id: wp.pane_id, title: hostname_cached(), title_locked: false, child_pid: wp.child_pid, data_version: wp.data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape: wp.cursor_shape, bell_pending: wp.bell_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring: wp.output_ring };
        let win_name = default_shell_name(None, configured_shell);
        let initial_pane_id = wp.pane_id;
        app.windows.push(Window { root: Node::Leaf(pane), active_path: vec![], name: win_name, id: app.next_win_id, activity_flag: false, bell_flag: false, silence_flag: false, last_output_time: std::time::Instant::now(), last_seen_version: 0, manual_rename: false, layout_index: 0, pane_mru: vec![initial_pane_id], zoom_saved: None, linked_from: None });
        app.next_win_id += 1;
        app.active_idx = app.windows.len() - 1;
        return Ok(());
    }
    // ── Normal path: spawn a new ConPTY + shell synchronously ──
    // Use actual terminal size if known, otherwise fall back to defaults
    let area = app.last_window_area;
    let rows = if area.height > 1 { area.height } else { 30 }.max(MIN_PANE_DIM);
    let cols = if area.width > 1 { area.width } else { 120 }.max(MIN_PANE_DIM);
    let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
    let pair = pty_system
        .openpty(size)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("openpty error: {e}")))?;

    // When no explicit command is given, use the configured default-shell
    // (from `set -g default-shell` / `default-command`).
    // Expand format variables like #{pane_current_path} at spawn time (#111).
    let expanded_shell = crate::format::expand_format(&app.default_shell, app);
    let mut shell_cmd = if command.is_some() {
        build_command(command, app.env_shim, app.allow_predictions)
    } else if !expanded_shell.is_empty() {
        build_default_shell(&expanded_shell, app.env_shim, app.allow_predictions)
    } else {
        build_command(None, app.env_shim, app.allow_predictions)
    };
    // Override CWD if -c start_dir was specified
    if let Some(dir) = start_dir {
        shell_cmd.cwd(std::path::Path::new(dir));
    }
    set_tmux_env(&mut shell_cmd, app.next_pane_id, app.control_port, app.socket_name.as_deref(), &app.session_name, app.claude_code_fix_tty, app.claude_code_force_interactive);
    apply_user_environment(&mut shell_cmd, &app.environment);
    let child = pair
        .slave
        .spawn_command(shell_cmd)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("spawn shell error: {e}")))?;
    // On Windows ConPTY the slave handle MUST be closed after spawning so the
    // child owns the sole reference to the console input pipe.  Leaving it open
    // causes "The handle is invalid" IOExceptions inside the child process.
    drop(pair.slave);

    let scrollback = app.history_limit as u32;
    let term: Arc<Mutex<vt100::Parser>> = Arc::new(Mutex::new(vt100::Parser::new(size.rows, size.cols, scrollback as usize)));
    let term_reader = term.clone();
    let data_version = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
    let dv_writer = data_version.clone();
    let cursor_shape = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(CURSOR_SHAPE_UNSET));
    let cs_writer = cursor_shape.clone();
    let bell_pending = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let bell_writer = bell_pending.clone();
    let reader = pair
        .master
        .try_clone_reader()
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("clone reader error: {e}")))?;

    let output_ring = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<u8>::new()));
    spawn_reader_thread(reader, term_reader, dv_writer, cs_writer, bell_writer, output_ring.clone());

    let configured_shell = if app.default_shell.is_empty() { None } else { Some(app.default_shell.as_str()) };
    let child_pid = crate::platform::mouse_inject::get_child_pid(&*child);
    let mut pty_writer = pair.master.take_writer()
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("take writer error: {e}")))?;
    conpty_preemptive_dsr_response(&mut *pty_writer);
    let epoch = std::time::Instant::now() - Duration::from_secs(2);
    let pane_id = app.next_pane_id;
    let pane = Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring };
    app.next_pane_id += 1;
    let win_name = command.map(|c| default_shell_name(Some(c), None)).unwrap_or_else(|| default_shell_name(None, configured_shell));
    app.windows.push(Window { root: Node::Leaf(pane), active_path: vec![], name: win_name, id: app.next_win_id, activity_flag: false, bell_flag: false, silence_flag: false, last_output_time: std::time::Instant::now(), last_seen_version: 0, manual_rename: false, layout_index: 0, pane_mru: vec![pane_id], zoom_saved: None, linked_from: None });
    app.next_win_id += 1;
    app.active_idx = app.windows.len() - 1;
    Ok(())
}

/// Pre-spawn a shell in the background so the next `new-window` (default shell,
/// no custom command) can transplant it instantly.  The returned `WarmPane` has
/// its reader thread already running — by the time the user creates a new window
/// (typically 500ms+), pwsh will have fully loaded its profile and the prompt
/// is ready.
pub fn spawn_warm_pane(pty_system: &dyn portable_pty::PtySystem, app: &mut AppState) -> io::Result<crate::types::WarmPane> {
    if !app.warm_enabled {
        return Err(io::Error::new(io::ErrorKind::Other, "warm panes disabled"));
    }
    let area = app.last_window_area;
    let rows = if area.height > 1 { area.height } else { 30 }.max(MIN_PANE_DIM);
    let cols = if area.width > 1 { area.width } else { 120 }.max(MIN_PANE_DIM);
    let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
    let pair = pty_system
        .openpty(size)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("openpty error: {e}")))?;
    // Expand format variables like #{pane_current_path} at spawn time (#111).
    let expanded_shell = crate::format::expand_format(&app.default_shell, app);
    let mut shell_cmd = if !expanded_shell.is_empty() {
        build_default_shell(&expanded_shell, app.env_shim, app.allow_predictions)
    } else {
        build_command(None, app.env_shim, app.allow_predictions)
    };
    let pane_id = app.next_pane_id;
    app.next_pane_id += 1;
    set_tmux_env(&mut shell_cmd, pane_id, app.control_port, app.socket_name.as_deref(), &app.session_name, app.claude_code_fix_tty, app.claude_code_force_interactive);
    apply_user_environment(&mut shell_cmd, &app.environment);
    let child = pair.slave
        .spawn_command(shell_cmd)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("spawn shell error: {e}")))?;
    drop(pair.slave);
    let scrollback = app.history_limit as u32;
    let term: Arc<Mutex<vt100::Parser>> = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, scrollback as usize)));
    let term_reader = term.clone();
    let data_version = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
    let dv_writer = data_version.clone();
    let cursor_shape = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(CURSOR_SHAPE_UNSET));
    let cs_writer = cursor_shape.clone();
    let bell_pending = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let bell_writer = bell_pending.clone();
    let reader = pair.master
        .try_clone_reader()
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("clone reader error: {e}")))?;
    let output_ring = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<u8>::new()));
    spawn_reader_thread(reader, term_reader, dv_writer, cs_writer, bell_writer, output_ring.clone());
    let child_pid = crate::platform::mouse_inject::get_child_pid(&*child);
    let mut pty_writer = pair.master.take_writer()
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("take writer error: {e}")))?;
    conpty_preemptive_dsr_response(&mut *pty_writer);
    Ok(crate::types::WarmPane { master: pair.master, writer: pty_writer, child, term, data_version, cursor_shape, bell_pending, child_pid, pane_id, rows, cols, output_ring })
}

pub fn split_active(app: &mut AppState, kind: LayoutKind) -> io::Result<()> {
    split_active_with_command(app, kind, None, None, None)
}

/// Create a new window with a raw command (program + args, no shell wrapping)
pub fn create_window_raw(pty_system: &dyn portable_pty::PtySystem, app: &mut AppState, raw_args: &[String]) -> io::Result<()> {
    let area = app.last_window_area;
    let rows = if area.height > 1 { area.height } else { 30 };
    let cols = if area.width > 1 { area.width } else { 120 };
    let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
    let pair = pty_system
        .openpty(size)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("openpty error: {e}")))?;

    let mut shell_cmd = build_raw_command(raw_args);
    set_tmux_env(&mut shell_cmd, app.next_pane_id, app.control_port, app.socket_name.as_deref(), &app.session_name, app.claude_code_fix_tty, app.claude_code_force_interactive);
    apply_user_environment(&mut shell_cmd, &app.environment);
    let child = pair
        .slave
        .spawn_command(shell_cmd)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("spawn shell error: {e}")))?;
    // Close the slave handle immediately – see create_window() comment.
    drop(pair.slave);

    let scrollback = app.history_limit;
    let term: Arc<Mutex<vt100::Parser>> = Arc::new(Mutex::new(vt100::Parser::new(size.rows, size.cols, scrollback)));
    let term_reader = term.clone();
    let data_version = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
    let dv_writer = data_version.clone();
    let cursor_shape = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(CURSOR_SHAPE_UNSET));
    let cs_writer = cursor_shape.clone();
    let bell_pending = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let bell_writer = bell_pending.clone();
    let reader = pair
        .master
        .try_clone_reader()
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("clone reader error: {e}")))?;

    let output_ring = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<u8>::new()));
    spawn_reader_thread(reader, term_reader, dv_writer, cs_writer, bell_writer, output_ring.clone());

    let child_pid = crate::platform::mouse_inject::get_child_pid(&*child);
    let mut pty_writer = pair.master.take_writer()
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("take writer error: {e}")))?;
    conpty_preemptive_dsr_response(&mut *pty_writer);
    let epoch = std::time::Instant::now() - Duration::from_secs(2);
    let raw_pane_id = app.next_pane_id;
    let pane = Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: raw_pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring };
    app.next_pane_id += 1;
    let win_name = std::path::Path::new(&raw_args[0]).file_stem().and_then(|s| s.to_str()).unwrap_or(&raw_args[0]).to_string();
    app.windows.push(Window { root: Node::Leaf(pane), active_path: vec![], name: win_name, id: app.next_win_id, activity_flag: false, bell_flag: false, silence_flag: false, last_output_time: std::time::Instant::now(), last_seen_version: 0, manual_rename: false, layout_index: 0, pane_mru: vec![raw_pane_id], zoom_saved: None, linked_from: None });
    app.next_win_id += 1;
    app.active_idx = app.windows.len() - 1;
    Ok(())
}

/// Minimum pane dimension (rows or cols) — ConPTY on Windows crashes
/// the child process if either dimension is less than 2.
pub const MIN_PANE_DIM: u16 = 2;

/// Minimum rows for a split to be allowed — each resulting pane needs at
/// least this many rows to run a shell prompt.
const MIN_SPLIT_ROWS: u16 = 2;
/// Minimum cols for a split to be allowed.
const MIN_SPLIT_COLS: u16 = 10;

pub fn split_active_with_command(app: &mut AppState, kind: LayoutKind, command: Option<&str>, pty_system_ref: Option<&dyn portable_pty::PtySystem>, start_dir: Option<&str>) -> io::Result<()> {
    // ── Guard: refuse split if the active pane is too small ──────────
    // After splitting, each half gets roughly (dim / 2) - 1 (for the divider).
    // If that would be below MIN_PANE_DIM, deny the split to avoid crashing
    // the child process (ConPTY cannot function below ~2 rows or cols).
    {
        let win = &app.windows[app.active_idx];
        if let Some(p) = crate::tree::active_pane(&win.root, &win.active_path) {
            let (cur_rows, cur_cols) = (p.last_rows, p.last_cols);
            match kind {
                LayoutKind::Vertical => {
                    // Splitting vertically divides height; need room for 2 panes + 1 divider
                    if cur_rows < MIN_SPLIT_ROWS * 2 + 1 {
                        return Err(io::Error::new(io::ErrorKind::Other,
                            format!("pane too small to split vertically ({cur_rows} rows, need {})", MIN_SPLIT_ROWS * 2 + 1)));
                    }
                }
                LayoutKind::Horizontal => {
                    // Splitting horizontally divides width; need room for 2 panes + 1 divider
                    if cur_cols < MIN_SPLIT_COLS * 2 + 1 {
                        return Err(io::Error::new(io::ErrorKind::Other,
                            format!("pane too small to split horizontally ({cur_cols} cols, need {})", MIN_SPLIT_COLS * 2 + 1)));
                    }
                }
            }
        }
    }

    // Reuse provided PTY system or create one as fallback
    let owned_pty;
    let pty_system: &dyn portable_pty::PtySystem = if let Some(ps) = pty_system_ref {
        ps
    } else {
        owned_pty = native_pty_system();
        &*owned_pty
    };
    // Compute target pane size from the *active pane's* actual dimensions,
    // not the full window area — ensures we don't over-estimate and then
    // immediately resize to a tiny rect.
    let (pane_rows, pane_cols) = {
        let win = &app.windows[app.active_idx];
        if let Some(p) = crate::tree::active_pane(&win.root, &win.active_path) {
            (p.last_rows, p.last_cols)
        } else {
            let area = app.last_window_area;
            (if area.height > 1 { area.height } else { 30 }, if area.width > 1 { area.width } else { 120 })
        }
    };
    let (rows, cols) = match kind {
        LayoutKind::Vertical => {
            let half = (pane_rows.saturating_sub(1)) / 2; // subtract 1 for divider
            (half.max(MIN_PANE_DIM), pane_cols.max(MIN_PANE_DIM))
        }
        LayoutKind::Horizontal => {
            let half = (pane_cols.saturating_sub(1)) / 2;
            (pane_rows.max(MIN_PANE_DIM), half.max(MIN_PANE_DIM))
        }
    };
    let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };

    // ── Fast path: transplant warm pane for default-shell splits ─────
    // The warm pane has its shell already loaded (~470ms for pwsh).  Even
    // though its ConPTY was created at full-window size, resizing to the
    // split dimensions only costs a ConPTY repaint (~10-50ms) vs a full
    // cold spawn (~500ms).  Net result: split feels nearly instant.
    // Skip warm pane when start_dir is set — the warm pane was spawned
    // in the server's CWD, not the requested directory (#107).
    if command.is_none() && start_dir.is_none() && app.warm_pane.is_some() {
        let wp = app.warm_pane.take().unwrap();
        // Resize ConPTY + parser to the split dimensions
        if rows != wp.rows || cols != wp.cols {
            let sz = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
            wp.master.resize(sz).ok();
            if let Ok(mut parser) = wp.term.lock() {
                parser.screen_mut().set_size(rows, cols);
            }
        }
        let epoch = std::time::Instant::now() - Duration::from_secs(2);
        let new_pane_id = wp.pane_id;
        let new_leaf = Node::Leaf(Pane { master: wp.master, writer: wp.writer, child: wp.child, term: wp.term, last_rows: rows, last_cols: cols, id: new_pane_id, title: hostname_cached(), title_locked: false, child_pid: wp.child_pid, data_version: wp.data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape: wp.cursor_shape, bell_pending: wp.bell_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring: wp.output_ring });
        let win = &mut app.windows[app.active_idx];
        replace_leaf_with_split(&mut win.root, &win.active_path, kind, new_leaf);
        let mut new_path = win.active_path.clone();
        new_path.push(1);
        win.active_path = new_path;
        // Add new pane to MRU (most recent)
        crate::tree::touch_mru(&mut win.pane_mru, new_pane_id);
        return Ok(());
    }

    // ── Normal path: cold-spawn a new ConPTY + shell ────────────────
    let pair = pty_system.openpty(size).map_err(|e| io::Error::new(io::ErrorKind::Other, format!("openpty error: {e}")))?;
    // When no explicit command is given, use the configured default-shell.
    // Expand format variables like #{pane_current_path} at spawn time (#111).
    let expanded_shell = crate::format::expand_format(&app.default_shell, app);
    let mut shell_cmd = if command.is_some() {
        build_command(command, app.env_shim, app.allow_predictions)
    } else if !expanded_shell.is_empty() {
        build_default_shell(&expanded_shell, app.env_shim, app.allow_predictions)
    } else {
        build_command(None, app.env_shim, app.allow_predictions)
    };
    // Override CWD if -c start_dir was specified
    if let Some(dir) = start_dir {
        shell_cmd.cwd(std::path::Path::new(dir));
    }
    set_tmux_env(&mut shell_cmd, app.next_pane_id, app.control_port, app.socket_name.as_deref(), &app.session_name, app.claude_code_fix_tty, app.claude_code_force_interactive);
    apply_user_environment(&mut shell_cmd, &app.environment);
    let child = pair.slave.spawn_command(shell_cmd).map_err(|e| io::Error::new(io::ErrorKind::Other, format!("spawn shell error: {e}")))?;
    // Close the slave handle immediately – see create_window() comment.
    drop(pair.slave);
    let term: Arc<Mutex<vt100::Parser>> = Arc::new(Mutex::new(vt100::Parser::new(size.rows, size.cols, app.history_limit)));
    let term_reader = term.clone();
    let reader = pair.master.try_clone_reader().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("clone reader error: {e}")))?;
    let data_version = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
    let dv_writer = data_version.clone();
    let cursor_shape = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(CURSOR_SHAPE_UNSET));
    let cs_writer = cursor_shape.clone();
    let bell_pending = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let bell_writer = bell_pending.clone();
    let output_ring = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<u8>::new()));
    spawn_reader_thread(reader, term_reader, dv_writer, cs_writer, bell_writer, output_ring.clone());
    let child_pid = crate::platform::mouse_inject::get_child_pid(&*child);
    let mut pty_writer = pair.master.take_writer()
        .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("take writer error: {e}")))?;
    conpty_preemptive_dsr_response(&mut *pty_writer);
    let epoch = std::time::Instant::now() - Duration::from_secs(2);
    let split_pane_id = app.next_pane_id;
    let new_leaf = Node::Leaf(Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: split_pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring });
    app.next_pane_id += 1;
    let win = &mut app.windows[app.active_idx];
    replace_leaf_with_split(&mut win.root, &win.active_path, kind, new_leaf);
    let mut new_path = win.active_path.clone();
    new_path.push(1);
    win.active_path = new_path;
    // Add new pane to MRU (most recent)
    crate::tree::touch_mru(&mut win.pane_mru, split_pane_id);
    Ok(())
}

fn kill_pane_at_path(win: &mut Window, path: &Vec<usize>) {
    // Get the ID of the pane being killed (for MRU removal)
    let killed_id = crate::tree::get_active_pane_id(&win.root, path);
    // Collect ordered pane IDs before kill for prev-by-index fallback (#71).
    let ordered_ids_before = crate::tree::collect_pane_ids(&win.root);
    // Explicitly kill the target pane's process tree FIRST.
    // remove_node() doesn't call kill_node() when the root is a single Leaf,
    // so we must do it here to ensure no orphaned processes.
    if let Some(p) = active_pane_mut(&mut win.root, path) {
        crate::platform::process_kill::kill_process_tree(&mut p.child);
    }
    kill_leaf(&mut win.root, path);
    // Remove killed pane from MRU
    if let Some(kid) = killed_id {
        crate::tree::remove_from_mru(&mut win.pane_mru, kid);
    }
    // Focus the most recently used remaining pane (tmux parity #71).
    // Walk the MRU list and pick the first pane that still exists.
    let mru_target = win.pane_mru.iter()
        .find_map(|&id| crate::tree::find_path_by_id(&win.root, id));
    // Fallback when MRU is empty (all remaining panes unvisited):
    // tmux picks previous pane by pane_index, or next if no previous.
    let fallback = || {
        if let Some(kid) = killed_id {
            let pos = ordered_ids_before.iter().position(|&id| id == kid);
            if let Some(pos) = pos {
                // Try previous by index first, then next
                let prev_id = if pos > 0 { Some(ordered_ids_before[pos - 1]) } else { None };
                let next_id = ordered_ids_before.get(pos + 1).copied();
                let candidate = prev_id.or(next_id);
                if let Some(cid) = candidate {
                    if let Some(path) = crate::tree::find_path_by_id(&win.root, cid) {
                        return path;
                    }
                }
            }
        }
        crate::tree::first_leaf_path(&win.root)
    };
    win.active_path = mru_target.unwrap_or_else(fallback);
}

pub fn kill_active_pane(app: &mut AppState) -> io::Result<()> {
    let win = &mut app.windows[app.active_idx];
    let active_path = win.active_path.clone();
    kill_pane_at_path(win, &active_path);
    Ok(())
}

pub fn kill_pane_by_id(app: &mut AppState, pane_id: usize) -> io::Result<()> {
    let restore_idx = app.active_idx;
    let restore_path = app.windows[restore_idx].active_path.clone();
    let restore_pane_id = crate::tree::get_active_pane_id(&app.windows[restore_idx].root, &restore_path);

    let target = app.windows.iter().enumerate().find_map(|(wi, win)| {
        crate::tree::find_path_by_id(&win.root, pane_id).map(|path| (wi, path))
    });

    let Some((target_idx, target_path)) = target else {
        return Ok(());
    };

    {
        let win = &mut app.windows[target_idx];
        kill_pane_at_path(win, &target_path);
    }

    // Only restore focus when the killed pane was in a DIFFERENT window.
    // For same-window kills, kill_pane_at_path already set the correct
    // MRU-based focus.  The old restore logic used path_exists() which
    // can succeed on stale indices that now point to a different pane
    // after tree restructuring (issue #140).
    if restore_idx < app.windows.len() && target_idx != restore_idx {
        app.active_idx = restore_idx;
        let restore_win = &mut app.windows[restore_idx];
        let resolved_restore_path = restore_pane_id
            .and_then(|id| crate::tree::find_path_by_id(&restore_win.root, id))
            .unwrap_or_else(|| crate::tree::first_leaf_path(&restore_win.root));
        restore_win.active_path = resolved_restore_path;
    }

    Ok(())
}

pub fn detect_shell() -> CommandBuilder {
    build_command(None, false, false)
}

/// Set TMUX, TMUX_PANE, and PSMUX_SESSION environment variables on a CommandBuilder.
/// TMUX format: /tmp/psmux-{server_pid}/{socket_name},{port},0
/// TMUX_PANE format: %{pane_id}
/// PSMUX_SESSION: actual session name (for Claude Code / tool detection)
/// The socket_name component encodes the -L namespace for child process resolution.
pub fn set_tmux_env(builder: &mut CommandBuilder, pane_id: usize, control_port: Option<u16>, socket_name: Option<&str>, session_name: &str, fix_tty: bool, _force_interactive: bool) {
    let server_pid = std::process::id();
    let port = control_port.unwrap_or(0);
    let sn = socket_name.unwrap_or("default");
    // Format compatible with tmux: <socket_path>,<pid>,<session_idx>
    // We encode the socket name in the path component for -L namespace resolution
    builder.env("TMUX", format!("/tmp/psmux-{}/{},{},0", server_pid, sn, port));
    builder.env("TMUX_PANE", format!("%{}", pane_id));
    // Override the placeholder "1" from build_command/build_default_shell with the
    // real session name.  Tools like Claude Code can use PSMUX_SESSION for explicit
    // psmux detection (e.g. `if (process.env.PSMUX_SESSION) return 'psmux'`).
    builder.env("PSMUX_SESSION", session_name);
    // Prevent MSYS2/Git-Bash from path-mangling the TMUX value (which starts
    // with /tmp/ and would be rewritten to a Windows path otherwise).
    builder.env("MSYS2_ENV_CONV_EXCL", "TMUX");
    // Enable Claude Code agent teams feature.  The standalone binary gates
    // the entire teammate tool-set (spawnTeam, spawnTeammate, …) behind
    //   T8(): LA(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) || --agent-teams
    // Without this env var the team tools are never registered and Claude
    // always falls back to the in-process "Agent" tool.
    builder.env("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1");

    // ── Claude Code workarounds (removable once upstream fixes land) ──
    //
    // claude-code-fix-tty (set -g claude-code-fix-tty on/off):
    //   Claude Code v2.1.71 standalone binary ignores `teammateMode` from
    //   settings.json (config schema strips the field).  The `--teammate-mode
    //   tmux` CLI flag DOES work.  We set PSMUX_CLAUDE_TEAMMATE_MODE=tmux so
    //   the PowerShell env-shim `claude` wrapper function injects the flag
    //   automatically.  Disable with: set -g claude-code-fix-tty off
    if fix_tty {
        builder.env("PSMUX_CLAUDE_TEAMMATE_MODE", "tmux");
    }

}

/// Apply user-defined environment variables (from set-environment -g) to a CommandBuilder.
/// This ensures variables set via config or runtime `set-environment` are explicitly
/// passed to every child pane, in addition to process inheritance.
pub fn apply_user_environment(builder: &mut CommandBuilder, environment: &std::collections::HashMap<String, String>) {
    for (key, value) in environment {
        builder.env(key, value);
    }
}

/// PowerShell env shim snippet — defines a `Global:env` function that translates
/// POSIX `env VAR=val ... command args` invocations into PowerShell equivalents.
///
/// Key design decisions for Windows + Claude Code agent teams compatibility:
///   1. POSIX backslash-escape removal uses `\\([^\w\\])` so that escapes like
///      `\@` and `\:` (produced by shell-quote) are stripped, while Windows
///      path separators (`\U` in `C:\Users`) are preserved (letter after `\`
///      is a `\w` character, so the regex does NOT match).
///   2. Escape stripping is applied to ALL arguments (env var values, the
///      command itself, and every trailing arg), not just env-var values.
///   3. `.js` / `.mjs` files are detected and automatically executed via
///      `node` because Windows associates `.js` with WScript.exe (WSH),
///      which cannot run Node.js code and instead shows error dialogs.
///   4. The shim is **always** installed (even when a native env.exe exists
///      on PATH) because Claude Code's shell-quote library produces POSIX
///      escapes (`\@`, `\:`) that native env.exe does not strip, causing
///      agent ID mismatches and spawn failures (psmux#172, #173, #180).
///      Users who need the raw env.exe can invoke it as `env.exe` explicitly.
const ENV_SHIM_PS: &str = concat!(
    "function Global:env { ",
    // _pu: POSIX-unescape helper — strips `\` before non-word, non-backslash
    // chars (e.g. \@ → @, \: → :) produced by npm shell-quote.
    // SKIPS Windows absolute paths (C:\...) where `\` is a directory
    // separator, not a POSIX escape.  On Linux paths use `/` so
    // there's never a collision; on Windows `\@` in a path like
    // `node_modules\@anthropic-ai` must be preserved.
    "function _pu($s){if($s -match '^[A-Za-z]:\\\\'){return $s}; $s -replace '\\\\([^\\w\\\\])','$1'}; ",
    // _shebang: reads the first line of a script file and extracts the
    // interpreter, mimicking Linux kernel shebang execution.
    // Handles #!/usr/bin/env node, #!/usr/bin/node, #!/usr/bin/env deno, etc.
    "function _shebang($f){ ",
    "try{ $l=(Get-Content $f -TotalCount 1 -EA Stop); ",
    "if($l -match '^#!\\s*(.+)$'){ ",
    "$p=$Matches[1].Trim(); ",
    "if($p -match '/env\\s+(.+)$'){return ($Matches[1].Trim()-split'\\s+')[0]}; ",
    "return ($p-split'/')[-1] } }catch{}; $null }; ",
    "$v=@{}; $i=0; ",
    "while($i -lt $args.Count){ ",
    "if([string]$args[$i] -match '^([A-Za-z_]\\w*)=(.*)$'){ ",
    "$v[$Matches[1]]=(_pu $Matches[2]); $i++ ",
    "} else { break } }; ",
    "if($i -lt $args.Count){ ",
    "foreach($e in $v.GetEnumerator()){[Environment]::SetEnvironmentVariable($e.Key,$e.Value,'Process')}; ",
    "$cmd=(_pu ([string]$args[$i])); $rest=@(); ",
    "if($i+1 -lt $args.Count){$rest=@($args[($i+1)..($args.Count-1)]|ForEach-Object{_pu ([string]$_)})}; ",
    // For script files (.js/.mjs/.ts/.sh/.py/etc), read the shebang line
    // to determine the interpreter — exactly like Linux kernel does.
    // Falls back to node for .js/.mjs only if no shebang is found
    // (since Windows associates .js with WScript.exe, not node).
    "$interp=$null; ",
    "$resolved=$cmd; if($cmd -match '^''(.+)''$'){$resolved=$Matches[1]}; ",
    "if(Test-Path $resolved -EA 0){$interp=(_shebang $resolved)}; ",
    "if($interp){& $interp $cmd @rest} ",
    "elseif($cmd -match '\\.m?js$'){& node $cmd @rest} ",
    "else{& $cmd @rest} ",
    "} elseif($v.Count -gt 0){ ",
    "foreach($e in $v.GetEnumerator()){[Environment]::SetEnvironmentVariable($e.Key,$e.Value,'Process')} ",
    "} else { Get-ChildItem Env:|ForEach-Object{$_.Name+'='+$_.Value} } }; ",
    // Claude Code teammate-mode wrapper (claude-code#26244):
    // The standalone (Bun SFE) binary ignores `teammateMode` from settings.json
    // but honours the `--teammate-mode tmux` CLI flag.  The agent teams tool-set
    // is separately gated by CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var (set
    // above in set_tmux_env).  This wrapper auto-injects --teammate-mode when
    // PSMUX_CLAUDE_TEAMMATE_MODE is set (via `set -g claude-code-fix-tty on`).
    // Disable with: set -g claude-code-fix-tty off
    "if($env:PSMUX_CLAUDE_TEAMMATE_MODE){ ",
    "function Global:claude { ",
    "if($args -contains '--teammate-mode'){ & claude.exe @args } ",
    "else{ & claude.exe --teammate-mode $env:PSMUX_CLAUDE_TEAMMATE_MODE @args } } }",
);

/// PSReadLine prediction fix — disables predictions that crash with
/// NullReferenceException in GetHistoryItems() during ConPTY startup.
/// See https://github.com/psmux/psmux/issues/109
const PSRL_FIX: &str = concat!(
    "try { Set-PSReadLineOption -PredictionSource None -ErrorAction Stop } catch {}; ",
    "try { Set-PSReadLineOption -PredictionViewStyle InlineView -ErrorAction Stop } catch {}; ",
    "try { Remove-PSReadLineKeyHandler -Chord 'F2' -ErrorAction Stop } catch {}",
);

/// Minimal crash guard: saves the user's original PredictionSource, then
/// disables predictions to prevent the #109 NullReferenceException during
/// ConPTY startup.  Does NOT touch PredictionViewStyle or F2 so those stay
/// at whatever the system default is.  Used pre-profile when allow-predictions
/// is on (#150).
const PSRL_CRASH_GUARD: &str = concat!(
    "$Global:__psmux_origPred = try { (Get-PSReadLineOption).PredictionSource } catch { 'History' }; ",
    "try { Set-PSReadLineOption -PredictionSource None -ErrorAction Stop } catch {}",
);

/// Post-profile prediction restore: if PredictionSource is still None (meaning
/// the user's profile did not explicitly set it), restore the saved original.
/// If the profile DID set a value, we leave it alone.
/// Used post-profile when allow-predictions is on (#150).
const PSRL_PRED_RESTORE: &str = concat!(
    "if ((Get-PSReadLineOption).PredictionSource -eq 'None' -and $Global:__psmux_origPred -ne 'None') { ",
    "try { Set-PSReadLineOption -PredictionSource $Global:__psmux_origPred -ErrorAction Stop } catch {} ",
    "}",
);

/// Source all four PowerShell profile scripts in the standard order.
/// Used with -NoProfile to give us control over execution order — we disable
/// PSReadLine predictions BEFORE the profile loads (preventing the
/// GetHistoryItems NullReferenceException), then re-disable after the profile
/// in case the user's profile re-enables predictions.
const PROFILE_SOURCE: &str = concat!(
    "foreach ($__p in @(",
    "$PROFILE.AllUsersAllHosts,",
    "$PROFILE.AllUsersCurrentHost,",
    "$PROFILE.CurrentUserAllHosts,",
    "$PROFILE.CurrentUserCurrentHost",
    ")) { if ($__p -and (Test-Path $__p)) { try { . $__p } catch { Write-Warning \"psmux: profile error in ${__p}: $_\" } } }",
);

/// Sync PowerShell's $PWD to the OS-level CWD (#111).
/// PowerShell's `cd` (Set-Location) only updates `$PWD` internally and
/// does NOT call Win32 SetCurrentDirectory(). This means the process PEB
/// still shows the original spawn directory, causing #{pane_current_path}
/// to always return the initial CWD.
///
/// Instead of wrapping the `prompt` function (which conflicts with prompt
/// customizers like Starship, oh-my-posh, etc.), we wrap the three cmdlets
/// that actually change directories: Set-Location, Push-Location, and
/// Pop-Location.  This is invisible to prompt customizers and survives
/// `. $PROFILE` reloads.
const CWD_SYNC: &str = concat!(
    "if (-not (Test-Path variable:Global:__psmux_cwd_hook)) { ",
    "$Global:__psmux_cwd_hook = $true; ",
    "try { [System.IO.Directory]::SetCurrentDirectory($PWD.ProviderPath) } catch {}; ",
    "function Global:Set-Location { ",
    "Microsoft.PowerShell.Management\\Set-Location @args; ",
    "try { [System.IO.Directory]::SetCurrentDirectory($PWD.ProviderPath) } catch {} ",
    "}; ",
    "function Global:Push-Location { ",
    "Microsoft.PowerShell.Management\\Push-Location @args; ",
    "try { [System.IO.Directory]::SetCurrentDirectory($PWD.ProviderPath) } catch {} ",
    "}; ",
    "function Global:Pop-Location { ",
    "Microsoft.PowerShell.Management\\Pop-Location @args; ",
    "try { [System.IO.Directory]::SetCurrentDirectory($PWD.ProviderPath) } catch {} ",
    "} }",
);

/// Build the full interactive init string for PowerShell:
/// 1. Disable PSReadLine predictions (before profile — prevents #109 crash)
/// 2. Source the user's profile scripts
/// 3. If allow_predictions is false, re-disable predictions after the profile;
///    if allow_predictions is true, restore the saved original PredictionSource
///    only when the profile did not set one explicitly (#150)
/// 4. Install CWD sync hook (enables #{pane_current_path} — #111)
/// 5. Optionally append the env shim
fn build_psrl_init(env_shim: bool, allow_predictions: bool) -> String {
    let (pre_profile, post_profile) = if allow_predictions {
        (PSRL_CRASH_GUARD, PSRL_PRED_RESTORE)
    } else {
        (PSRL_FIX, PSRL_FIX)
    };
    let mut s = format!("{}; {}; {}; {}", pre_profile, PROFILE_SOURCE, post_profile, CWD_SYNC);
    if env_shim {
        s.push_str("; ");
        s.push_str(ENV_SHIM_PS);
    }
    s
}

/// On Windows, translate Unix-style shell wrappers to Windows equivalents.
///
/// Tools like Overstory wrap agent commands in `/bin/bash -c '...'` for
/// environment setup (unset/export). This doesn't work on Windows because
/// `/bin/bash` doesn't exist. This function:
/// 1. If the command is `/bin/bash -c '...'` or `/bin/sh -c '...'`, try to
///    find `bash.exe` in PATH and rewrite to use the resolved path.
/// 2. If bash isn't available, extract the inner script and translate
///    common bash patterns (unset, export, &&) to PowerShell equivalents.
/// 3. For other Unix absolute paths (/usr/bin/foo), try to resolve the
///    basename from PATH.
#[cfg(windows)]
fn resolve_unix_path(cmd: &str) -> String {
    let trimmed = cmd.trim();

    // General case: resolve Unix absolute paths (e.g. /usr/bin/python3)
    if trimmed.starts_with('/') {
        let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
        let program = parts[0];
        let basename = std::path::Path::new(program)
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or(program);
        if let Ok(resolved) = which::which(basename) {
            let rest = if parts.len() > 1 { parts[1] } else { "" };
            if rest.is_empty() {
                return format!("\"{}\"", resolved.to_string_lossy());
            } else {
                return format!("\"{}\" {}", resolved.to_string_lossy(), rest);
            }
        }
    }

    // No translation needed
    cmd.to_string()
}

/// Detect if a command is a `/bin/bash -c '...'` or similar pattern.
/// Returns Some((inner_script, shell_name)) if matched.
#[cfg(windows)]
fn detect_bash_c_wrapper(cmd: &str) -> Option<(&str, &str)> {
    let shell_prefixes = [
        ("/bin/bash -c ", "bash"),
        ("/bin/sh -c ", "sh"),
        ("/usr/bin/bash -c ", "bash"),
        ("/usr/bin/sh -c ", "sh"),
        ("/usr/bin/env bash -c ", "bash"),
        ("/usr/bin/env sh -c ", "sh"),
    ];
    for (prefix, shell_name) in &shell_prefixes {
        if cmd.starts_with(prefix) {
            let rest = &cmd[prefix.len()..];
            // Strip outer quotes (single or double)
            let inner = if (rest.starts_with('\'') && rest.ends_with('\''))
                || (rest.starts_with('"') && rest.ends_with('"'))
            {
                &rest[1..rest.len() - 1]
            } else {
                rest
            };
            return Some((inner, shell_name));
        }
    }
    None
}

/// Parse a bash-style env setup script and extract environment modifications
/// plus the final command.  Returns (env_removes, env_sets, final_command).
///
/// This approach is **shell-agnostic**: instead of translating bash syntax to
/// PowerShell/cmd syntax, we parse the env operations and apply them directly
/// on the `CommandBuilder` (via `env_remove()` / `env()`).  The final command
/// is then executed through whatever default shell the user has configured,
/// without any env-manipulation syntax that could be shell-incompatible.
#[cfg(windows)]
fn parse_bash_env_script(script: &str) -> (Vec<String>, Vec<(String, String)>, String) {
    let mut removes: Vec<String> = Vec::new();
    let mut sets: Vec<(String, String)> = Vec::new();
    let mut final_parts: Vec<String> = Vec::new();

    let segments: Vec<&str> = script.split("&&").collect();
    for seg in &segments {
        let seg = seg.trim();
        if seg.is_empty() { continue; }

        if seg.starts_with("unset ") {
            let vars: Vec<&str> = seg["unset ".len()..].split_whitespace().collect();
            for var in vars {
                removes.push(var.to_string());
            }
        } else if seg.starts_with("export ") {
            let assign = &seg["export ".len()..];
            if let Some(eq_pos) = assign.find('=') {
                let var = assign[..eq_pos].to_string();
                let mut val = assign[eq_pos + 1..].trim().to_string();
                // Strip outer quotes
                if (val.starts_with('"') && val.ends_with('"'))
                    || (val.starts_with('\'') && val.ends_with('\''))
                {
                    val = val[1..val.len() - 1].to_string();
                }
                // Resolve $PATH / ${PATH} references to the actual current PATH value.
                // Also fix Unix `:` separator to Windows `;`.
                if let Ok(current_path) = std::env::var("PATH") {
                    val = val.replace(":$PATH", &format!(";{}", current_path))
                             .replace(":${PATH}", &format!(";{}", current_path))
                             .replace("$PATH:", &format!("{};", current_path))
                             .replace("${PATH}:", &format!("{};", current_path))
                             .replace("$PATH", &current_path)
                             .replace("${PATH}", &current_path);
                }
                sets.push((var, val));
            }
        } else {
            // Final command or unknown segment — preserve as-is
            final_parts.push(seg.to_string());
        }
    }

    let final_cmd = final_parts.join(" && ");
    (removes, sets, final_cmd)
}

pub fn build_command(command: Option<&str>, env_shim: bool, allow_predictions: bool) -> CommandBuilder {
    // Capture CWD early — portable_pty on Windows defaults to USERPROFILE
    // (home dir) when no cwd is set on CommandBuilder, so we must set it
    // explicitly to honour the caller's working directory.
    let cwd = std::env::current_dir().ok();
    if let Some(cmd) = command {
        // On Windows, detect `/bin/bash -c '...'` wrappers used by tools like
        // Overstory and omc for env var setup before launching agents.
        // Instead of translating to shell-specific syntax (which breaks if the
        // user's default shell is bash, cmd, or a different PowerShell version),
        // we parse the env operations from the bash script and apply them directly
        // on the CommandBuilder.  The final command is then passed to whatever
        // shell `cached_shell()` resolves to, env-manipulation-free.
        #[cfg(windows)]
        let (env_removes, env_sets, cmd) = {
            let trimmed = cmd.trim();
            if let Some((inner_script, _)) = detect_bash_c_wrapper(trimmed) {
                let (removes, sets, final_cmd) = parse_bash_env_script(inner_script);
                let final_cmd = if final_cmd.is_empty() {
                    cmd.to_string()
                } else {
                    resolve_unix_path(&final_cmd)
                };
                (removes, sets, final_cmd)
            } else {
                (Vec::new(), Vec::new(), resolve_unix_path(cmd))
            }
        };
        #[cfg(not(windows))]
        let (env_removes, env_sets, cmd) = (Vec::<String>::new(), Vec::<(String, String)>::new(), cmd.to_string());

        let shell = cached_shell().map(|s| s.to_string());

        match shell {
            Some(path) => {
                let mut builder = CommandBuilder::new(&path);
                if let Some(ref dir) = cwd { builder.cwd(dir); }
                builder.env("TERM", "xterm-256color");
                builder.env("COLORTERM", "truecolor");
                builder.env("PSMUX_SESSION", "1");
                for var in &env_removes { builder.env_remove(var); }
                for (k, v) in &env_sets { builder.env(k, v); }

                let stem = std::path::Path::new(&path).file_stem()
                    .and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
                if stem == "pwsh" || stem == "powershell" {
                    builder.args(["-NoLogo", "-Command", &cmd]);
                } else if matches!(stem.as_str(), "bash" | "sh" | "zsh" | "fish" | "dash" | "ash") {
                    builder.args(["-c", &cmd]);
                } else {
                    builder.args(["/C", &cmd]);
                }
                builder
            }
            None => {
                let mut builder = CommandBuilder::new("pwsh.exe");
                if let Some(ref dir) = cwd { builder.cwd(dir); }
                builder.env("TERM", "xterm-256color");
                builder.env("COLORTERM", "truecolor");
                builder.env("PSMUX_SESSION", "1");
                for var in &env_removes { builder.env_remove(var); }
                for (k, v) in &env_sets { builder.env(k, v); }
                builder.args(["-NoLogo", "-Command", &cmd]);
                builder
            }
        }
    } else {
        let shell = cached_shell().map(|s| s.to_string());
        // PSReadLine v2.2.6+ enables PredictionSource HistoryAndPlugin by default.
        // Predictions cause display corruption in terminal multiplexers because
        // PSReadLine's VT rendering races with ConPTY output capture.
        // Issue #109: GetHistoryItems() throws NullReferenceException when
        // predictions are enabled in the profile before PSReadLine is fully
        // initialized inside ConPTY.  We use -NoProfile and source profiles
        // ourselves, sandwiching them between prediction-disable commands.
        let psrl_init = build_psrl_init(env_shim, allow_predictions);
        match shell {
            Some(path) => {
                let mut builder = CommandBuilder::new(&path);
                if let Some(ref dir) = cwd { builder.cwd(dir); }
                builder.env("TERM", "xterm-256color");
                builder.env("COLORTERM", "truecolor");
                builder.env("PSMUX_SESSION", "1");
                if path.to_lowercase().contains("pwsh") {
                    builder.args(["-NoLogo", "-NoProfile", "-NoExit", "-Command", &psrl_init]);
                }
                builder
            }
            None => {
                let mut builder = CommandBuilder::new("pwsh.exe");
                if let Some(ref dir) = cwd { builder.cwd(dir); }
                builder.env("TERM", "xterm-256color");
                builder.env("COLORTERM", "truecolor");
                builder.env("PSMUX_SESSION", "1");
                // Apply the same -NoProfile + manual profile sourcing for
                // the fallback pwsh.exe path (previously had no PSRL fix).
                builder.args(["-NoLogo", "-NoProfile", "-NoExit", "-Command", &psrl_init]);
                builder
            }
        }
    }
}

/// Cached resolved default-shell path to avoid repeated `which::which()` scans.
static CACHED_DEFAULT_SHELL: std::sync::OnceLock<std::collections::HashMap<String, String>> = std::sync::OnceLock::new();
static CACHED_DEFAULT_SHELL_MAP: std::sync::Mutex<Option<std::collections::HashMap<String, String>>> = std::sync::Mutex::new(None);

/// Resolve a program name via `which`, caching the result.
fn cached_which(program: &str) -> String {
    // Fast path: check if already cached in the global OnceLock for the default
    // (most common case is always the same shell)
    let mut map = CACHED_DEFAULT_SHELL_MAP.lock().unwrap_or_else(|e| e.into_inner());
    let map = map.get_or_insert_with(std::collections::HashMap::new);
    if let Some(cached) = map.get(program) {
        return cached.clone();
    }
    let resolved = which::which(program).ok()
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|| program.to_string());
    map.insert(program.to_string(), resolved.clone());
    resolved
}

/// Split a shell config value into (program, extra_args), handling paths
/// that contain spaces (e.g. `C:/Program Files/Git/bin/bash.exe`).
///
/// Resolution order:
/// 1. If the whole string resolves to an existing executable, use it as-is.
/// 2. Otherwise, use quote-aware tokenising so that users can write
///    `"C:/Program Files/Git/bin/bash.exe" --login` with quotes.
fn resolve_shell_program(shell_path: &str) -> (String, Vec<String>) {
    // Fast path: whole string is the program (possibly with spaces in path).
    if std::path::Path::new(shell_path).is_file()
        || which::which(shell_path).is_ok()
    {
        return (shell_path.to_string(), vec![]);
    }

    // Quote-aware split (handles `"path with spaces" arg1 arg2`).
    let parsed = crate::commands::parse_command_line(shell_path);
    if parsed.is_empty() {
        return (shell_path.to_string(), vec![]);
    }
    let program = parsed[0].clone();
    let extra = parsed[1..].to_vec();
    (program, extra)
}

/// Build a CommandBuilder that launches the given shell path interactively.
/// Used when `default-shell` / `default-command` is configured.
/// Supports pwsh, powershell, cmd, and any arbitrary executable.
pub fn build_default_shell(shell_path: &str, env_shim: bool, allow_predictions: bool) -> CommandBuilder {
    let (program, extra_args) = resolve_shell_program(shell_path);

    // Resolve bare names via cached `which` — avoids repeated PATH scans.
    let resolved = cached_which(&program);

    let lower = resolved.to_lowercase();
    let mut builder = CommandBuilder::new(&resolved);
    // Set CWD explicitly — portable_pty on Windows defaults to USERPROFILE
    // (home dir) when no cwd is set on CommandBuilder.
    if let Ok(dir) = std::env::current_dir() { builder.cwd(dir); }
    builder.env("TERM", "xterm-256color");
    builder.env("COLORTERM", "truecolor");
    builder.env("PSMUX_SESSION", "1");

    // Prepend extra arguments (e.g. -NoProfile) BEFORE our -NoExit/-Command block
    // so they're interpreted as flags rather than as -Command arguments.
    if !extra_args.is_empty() {
        builder.args(extra_args.clone());
    }

    if lower.contains("pwsh") || lower.contains("powershell") {
        // Issue #109: -NoProfile + manual profile sourcing to prevent
        // PSReadLine GetHistoryItems NullReferenceException.
        // If the user already passed -NoProfile in extra_args, we still
        // add ours (PowerShell accepts duplicates harmlessly) and skip
        // profile sourcing only if they explicitly opted out.
        let has_noprofile = extra_args.iter()
            .any(|a| a.eq_ignore_ascii_case("-NoProfile"));
        let psrl_init = if has_noprofile {
            // User explicitly wants no profile — just apply PSRL fix + shim.
            let mut s = PSRL_FIX.to_string();
            if env_shim {
                s.push_str("; ");
                s.push_str(ENV_SHIM_PS);
            }
            s
        } else {
            build_psrl_init(env_shim, allow_predictions)
        };
        if !has_noprofile {
            builder.args(["-NoProfile"]);
        }
        builder.args(["-NoLogo", "-NoExit", "-Command", &psrl_init]);
    }

    builder
}

/// Build a CommandBuilder for direct execution (no shell wrapping).
/// raw_args[0] is the program, rest are its arguments.
/// Used when -- separator is specified in new-session.
pub fn build_raw_command(raw_args: &[String]) -> CommandBuilder {
    if raw_args.is_empty() {
        return build_command(None, true, false);
    }
    let program = &raw_args[0];
    let mut builder = CommandBuilder::new(program);
    // Set CWD explicitly — portable_pty on Windows defaults to USERPROFILE
    // (home dir) when no cwd is set on CommandBuilder.
    if let Ok(dir) = std::env::current_dir() { builder.cwd(dir); }
    builder.env("TERM", "xterm-256color");
    builder.env("COLORTERM", "truecolor");
    builder.env("PSMUX_SESSION", "1");
    if raw_args.len() > 1 {
        let args: Vec<&str> = raw_args[1..].iter().map(|s| s.as_str()).collect();
        builder.args(args);
    }
    builder
}

/// Spawn a dedicated PTY reader thread that processes output and updates the
/// data_version counter. Exits cleanly after 200 consecutive zero-byte reads
/// (indicating the PTY pipe is closed) or on any I/O error.
///
/// Uses an 8KB read buffer (down from 64KB) to reduce mutex hold time during
/// `parser.process()`, which improves DumpState latency under heavy output.

/// Scan raw ConPTY output for DECSCUSR cursor shape sequences (`\x1b[N q`).
/// Returns the last cursor shape value found, or None.
///
/// We accept all DECSCUSR cursor shape values (0-6) from child processes.
/// Value 0 resets to default, 1-2 = block, 3-4 = underline, 5-6 = bar.
fn scan_cursor_shape(data: &[u8]) -> Option<u8> {
    let mut last_shape: Option<u8> = None;
    let mut i = 0;
    while i < data.len() {
        if data[i] == 0x1b && i + 1 < data.len() && data[i + 1] == b'[' {
            let mut j = i + 2;
            let mut param: u8 = 0;
            while j < data.len() && data[j].is_ascii_digit() {
                param = param.saturating_mul(10).saturating_add(data[j] - b'0');
                j += 1;
            }
            // Check for SP q (space 0x20 + 'q') = DECSCUSR
            if j + 1 < data.len() && data[j] == b' ' && data[j + 1] == b'q' {
                if param <= 6 {
                    last_shape = Some(param);
                }
                i = j + 2;
                continue;
            }
        }
        i += 1;
    }
    last_shape
}

/// Returns true if `data` contains the RMCUP sequence (ESC[?1049l).
fn scan_rmcup(data: &[u8]) -> bool {
    const RMCUP: &[u8] = b"\x1b[?1049l";
    data.windows(RMCUP.len()).any(|w| w == RMCUP)
}

pub fn spawn_reader_thread(
    mut reader: Box<dyn std::io::Read + Send>,
    term_reader: Arc<Mutex<vt100::Parser>>,
    dv_writer: Arc<std::sync::atomic::AtomicU64>,
    cursor_shape: Arc<std::sync::atomic::AtomicU8>,
    bell_pending: Arc<std::sync::atomic::AtomicBool>,
    output_ring: Arc<Mutex<std::collections::VecDeque<u8>>>,
) {
    // ── Issue #246: split the old single reader thread into two threads ──
    //
    // The old code did `reader.read() → parser.lock() → parser.process(chunk)
    // → drop lock` for each chunk individually. When a TUI (Ink, PSReadLine,
    // pwsh-in-docker, etc.) emits a logical frame larger than the 64KB read
    // buffer — or when ConPTY/docker stdio splits a smaller frame across
    // multiple reads — the snapshot path in src/layout.rs could win the race
    // for the parser mutex BETWEEN two of those reads, serializing a partial
    // mid-frame state (typically: `ESC[2K` cleared a row but only some
    // `CUP+text` spans had landed). That partial state was rendered on the
    // client as the visible "sparse cells" / "remnant characters" artifact.
    //
    // Fix:
    //   • Reader thread: tight loop, ONLY does reader.read() and pushes raw
    //     bytes into a staging buffer. Never touches the parser mutex, so
    //     reads cannot be starved by snapshot work.
    //   • Parser thread: waits for staged bytes, then ADAPTIVELY coalesces:
    //     sleeps 1ms; if more bytes arrived, sleeps again; hard cap 8ms total.
    //     Then locks the parser ONCE and processes the entire batch
    //     atomically. Multi-chunk frames that arrive within the coalescing
    //     window land as a single unit — the snapshot can no longer observe
    //     a partial frame.
    //
    // Latency cost: 1ms minimum between byte arrival and render for streaming
    // output, capped at 8ms for sustained streams. Imperceptible to humans
    // and well below the 50ms keystroke-echo threshold.
    const COALESCE_TICK_MS: u64 = 1;
    const COALESCE_MAX_MS: u128 = 8;

    let staging: Arc<(Mutex<Vec<u8>>, Condvar)> = Arc::new((Mutex::new(Vec::with_capacity(131072)), Condvar::new()));
    let reader_done: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));

    // ── Reader thread: pure I/O, no parser lock ──
    let staging_r = staging.clone();
    let reader_done_r = reader_done.clone();
    let output_ring_r = output_ring.clone();
    thread::spawn(move || {
        let mut local = vec![0u8; 65536];
        let mut zero_reads: u32 = 0;
        loop {
            match reader.read(&mut local) {
                Ok(n) if n > 0 => {
                    zero_reads = 0;
                    // Push raw bytes into staging (no parser lock involved).
                    let (lock, cv) = &*staging_r;
                    if let Ok(mut buf) = lock.lock() {
                        buf.extend_from_slice(&local[..n]);
                        cv.notify_one();
                    }
                    // Append raw output to ring buffer for control mode %output.
                    // This is independent of parser state and must stay live.
                    if let Ok(mut ring) = output_ring_r.lock() {
                        const MAX_RING: usize = 65536;
                        let space = MAX_RING.saturating_sub(ring.len());
                        if n <= space {
                            ring.extend(&local[..n]);
                        } else {
                            let drop_count = (n - space).min(ring.len());
                            ring.drain(..drop_count);
                            ring.extend(&local[..n]);
                        }
                    }
                }
                Ok(_) => {
                    zero_reads += 1;
                    if zero_reads > 10 { break; }
                    thread::sleep(Duration::from_millis(1));
                }
                Err(_) => break,
            }
        }
        // Signal end-of-stream and wake parser thread one last time so it
        // can drain remaining bytes and run the alt-screen cleanup.
        reader_done_r.store(true, Ordering::Release);
        let (_, cv) = &*staging_r;
        cv.notify_all();
    });

    // ── Parser thread: coalesces staged bytes, processes under one lock ──
    thread::spawn(move || {
        loop {
            // Wait for at least one byte (or shutdown).
            {
                let (lock, cv) = &*staging;
                let mut buf = match lock.lock() {
                    Ok(g) => g,
                    Err(_) => break,
                };
                while buf.is_empty() {
                    if reader_done.load(Ordering::Acquire) {
                        // Reader is gone and nothing left to drain — exit
                        // after running alt-screen cleanup below.
                        drop(buf);
                        if let Ok(mut parser) = term_reader.lock() {
                            if parser.screen().alternate_screen() {
                                parser.process(b"\x1b[?25h\x1b[?1049l");
                                cursor_shape.store(0, Ordering::Release);
                                dv_writer.fetch_add(1, Ordering::Release);
                                crate::types::PTY_DATA_READY.store(true, Ordering::Release);
                            }
                        }
                        return;
                    }
                    let res = cv.wait_timeout(buf, Duration::from_millis(100));
                    buf = match res {
                        Ok((g, _)) => g,
                        Err(_) => return,
                    };
                }
                // First bytes are present — release the lock so the reader can
                // keep pushing while we run the adaptive coalescing wait.
            }

            // Adaptive coalescing: keep waiting in 1ms ticks while new bytes
            // are still arriving, hard-capped at 8ms total. This bridges
            // multi-chunk frames into a single atomic parser update.
            let coalesce_start = Instant::now();
            let mut last_len: usize = {
                let (lock, _) = &*staging;
                lock.lock().map(|b| b.len()).unwrap_or(0)
            };
            loop {
                if coalesce_start.elapsed().as_millis() >= COALESCE_MAX_MS { break; }
                thread::sleep(Duration::from_millis(COALESCE_TICK_MS));
                let cur_len = {
                    let (lock, _) = &*staging;
                    lock.lock().map(|b| b.len()).unwrap_or(0)
                };
                if cur_len == last_len {
                    // No new bytes arrived in the last tick — frame boundary.
                    break;
                }
                last_len = cur_len;
            }

            // Take the entire staged batch.
            let bytes = {
                let (lock, _) = &*staging;
                match lock.lock() {
                    Ok(mut buf) => std::mem::take(&mut *buf),
                    Err(_) => break,
                }
            };
            if bytes.is_empty() { continue; }

            // Scan for cursor shape and RMCUP on the raw batch BEFORE
            // handing to vt100 parser (preserves prior ordering semantics).
            if let Some(shape) = scan_cursor_shape(&bytes) {
                cursor_shape.store(shape, Ordering::Release);
            }
            let rmcup = scan_rmcup(&bytes);

            if let Ok(mut parser) = term_reader.lock() {
                parser.process(&bytes);
                if parser.screen_mut().take_audible_bell() {
                    bell_pending.store(true, Ordering::Release);
                }
            }
            // When TUI sends RMCUP, reset cursor shape so it doesn't
            // persist from the exiting TUI app.
            if rmcup {
                cursor_shape.store(0, Ordering::Release);
            }
            dv_writer.fetch_add(1, Ordering::Release);
            crate::types::PTY_DATA_READY.store(true, Ordering::Release);
        }
    });
}

#[cfg(test)]
#[path = "../tests-rs/test_issue151_strict_mode.rs"]
mod test_issue151_strict_mode;

#[cfg(test)]
#[path = "../tests-rs/test_issue155_output_rendering.rs"]
mod test_issue155_output_rendering;

#[cfg(test)]
#[path = "../tests-rs/test_issue165_prediction_view_style.rs"]
mod test_issue165_prediction_view_style;

#[cfg(test)]
mod test_parser_audible_bell {
    /// Helper: create a parser, process bytes, return whether bell rang.
    fn bell_after(data: &[u8]) -> bool {
        let mut p = vt100::Parser::new(24, 80, 0);
        p.process(data);
        p.screen_mut().take_audible_bell()
    }

    /// Helper: process two chunks sequentially (simulates cross-chunk reads),
    /// return whether bell rang after the second chunk.
    fn bell_after_two_chunks(chunk1: &[u8], chunk2: &[u8]) -> bool {
        let mut p = vt100::Parser::new(24, 80, 0);
        p.process(chunk1);
        // Consume any bell from chunk1 so we only test chunk2
        let _ = p.screen_mut().take_audible_bell();
        p.process(chunk2);
        p.screen_mut().take_audible_bell()
    }

    #[test]
    fn bare_bel() {
        assert!(bell_after(b"\x07"));
    }

    #[test]
    fn bel_in_plain_text() {
        assert!(bell_after(b"hello\x07world"));
    }

    #[test]
    fn osc_title_with_bel_terminator() {
        // OSC BEL terminator is NOT an audible bell
        assert!(!bell_after(b"\x1b]0;My Title\x07"));
    }

    #[test]
    fn osc_title_with_st_terminator() {
        assert!(!bell_after(b"\x1b]0;My Title\x1b\\"));
    }

    #[test]
    fn osc_then_standalone_bel() {
        // OSC terminated by BEL, then a real standalone BEL
        assert!(bell_after(b"\x1b]0;title\x07\x07"));
    }

    #[test]
    fn multiple_osc_no_real_bel() {
        assert!(!bell_after(b"\x1b]0;title1\x07\x1b]2;title2\x07"));
    }

    #[test]
    fn empty_data() {
        assert!(!bell_after(b""));
    }

    #[test]
    fn no_bel_at_all() {
        assert!(!bell_after(b"just text\x1b[31m"));
    }

    #[test]
    fn powershell_prompt_title_no_bell() {
        // Simulates PowerShell: sets title via OSC, then prints prompt (no BEL)
        let data = b"\x1b]0;PS C:\\Users\\test\x07\x1b[32mPS>\x1b[0m ";
        assert!(!bell_after(data));
    }

    #[test]
    fn take_clears_flag() {
        let mut p = vt100::Parser::new(24, 80, 0);
        p.process(b"\x07");
        assert!(p.screen_mut().take_audible_bell());
        // Second take should be false (consumed)
        assert!(!p.screen_mut().take_audible_bell());
    }

    #[test]
    fn cross_chunk_osc_then_real_bel() {
        // Chunk 1 starts OSC without terminator; chunk 2 has the
        // OSC terminator BEL then a real standalone BEL.
        // The parser maintains state across chunks, so this works
        // correctly (unlike the old stateless scan_standalone_bel).
        let mut p = vt100::Parser::new(24, 80, 0);
        p.process(b"\x1b]0;title");
        assert!(!p.screen_mut().take_audible_bell());
        p.process(b"\x07\x07");
        assert!(p.screen_mut().take_audible_bell());
    }

    #[test]
    fn cross_chunk_osc_no_real_bel() {
        // Chunk 1 starts OSC; chunk 2 only has the OSC terminator.
        // No real bell should fire.
        assert!(!bell_after_two_chunks(b"\x1b]0;title", b"\x07"));
    }
}

// reap_children is in tree.rs
