//! The sole owner of the tmux client + per-session smoothing state.
//!
//! ## Architecture (post tmux -C rewrite)
//!
//! Prior to the v0.2.0 rewrite, refreshes were driven by a 1Hz
//! `poller` task that fired `Tick` events into the main loop, which
//! in turn generated `Command::ListNow` for this actor. That had two
//! problems: (1) wasted work for idle sessions, and (2) during a long
//! `perform_attach` the tick backlog could fill bounded channels and
//! cascade into a mutual-wait deadlock between main and this actor.
//!
//! Both problems went away with the move to tmux control mode. Now
//! this actor owns a long-lived [`ControlClient`] subprocess
//! (`tmux -C attach-session -t __bosun_monitor`) and uses
//! `tokio::select!` to wait on **either** a command from main **or**
//! an asynchronous notification from tmux. Session-list refreshes
//! run on relevant notifications (session added/closed/renamed,
//! window added/closed) instead of on a timer. Zero work on an idle
//! server, zero tick backlog during long attaches.
//!
//! `Command::FocusPreview` still lets the app prioritize capturing a
//! specific session's pane immediately — useful on selection change
//! so the preview updates without waiting for a notification.
//!
//! Attach stays handled inline by the app task (needs the controlling
//! tty). This actor only handles read-only operations, command
//! execution, and the status bar side effects.
//!
//! ## Fallback
//!
//! If the control client fails to spawn at startup (e.g. tmux not
//! installed or a permissions issue), this actor emits a `Warn`
//! message and continues in **commands-only** mode — refreshes still
//! run when main sends `Command::ListNow` or any lifecycle command,
//! but there are no push updates. It's degraded, not dead.

use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};

use tokio::sync::mpsc;
use tokio::time::{self, MissedTickBehavior};

use crate::config::Config;
use crate::events::{AppMsg, ClaudeSessionMode, Command, SessionSpec, SpecOptions};
use crate::store::Store;
use crate::tmux::attach::{
    clear_ctrl_q_bound, clear_quick_jump_bound, clear_session_cycle_bound, ensure_ctrl_q_bound,
    ensure_quick_jump_bound, ensure_session_cycle_bound,
};
use crate::tmux::control::Notification;
use crate::tmux::control_client::ControlClient;
use crate::tmux::detector::{DetectContext, DetectorRegistry, Status};
use crate::tmux::session::SessionView;
use crate::tmux::status_bar::{self, BarSession};
use crate::tmux::{CreateSpec, SessionMetadata, TmuxClient};
use crate::util::collision::resolve_name_collision;
use crate::util::hysteresis::Smoother;

/// RAII cleanup for globals installed by the status bar (prefix-1..9
/// bindings), the C-q detach binding, the S-Left / S-Right session
/// cycle bindings, and the M-O quick-jump popup binding. Per-session
/// status-* options are left in place when the actor exits — they die
/// with their sessions, and leaving them means a restarting bosun can
/// reuse them without a reinit flash.
struct GlobalsGuard {
    socket: Option<String>,
    installed: bool,
    cq_installed: bool,
    cycle_installed: bool,
    quick_jump_installed: bool,
}

impl Drop for GlobalsGuard {
    fn drop(&mut self) {
        if self.installed {
            status_bar::uninstall_globals(self.socket.as_deref());
        }
        if self.cq_installed {
            clear_ctrl_q_bound(self.socket.as_deref());
        }
        if self.cycle_installed {
            clear_session_cycle_bound(self.socket.as_deref());
        }
        if self.quick_jump_installed {
            clear_quick_jump_bound(self.socket.as_deref());
        }
    }
}

pub fn spawn(
    client: Arc<dyn TmuxClient>,
    socket: Option<String>,
    config: Config,
    store: Arc<Store>,
    mut cmd_rx: mpsc::UnboundedReceiver<Command>,
    evt_tx: mpsc::UnboundedSender<AppMsg>,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        let registry = DetectorRegistry::default_stack();
        let mut smoothers: HashMap<String, Smoother> = HashMap::new();
        let mut focused: Option<String> = None;
        let mut last_bar_state: Vec<BarSession> = Vec::new();
        let mut globals = GlobalsGuard {
            socket: socket.clone(),
            installed: false,
            cq_installed: false,
            cycle_installed: false,
            quick_jump_installed: false,
        };

        // Install the C-q detach binding up-front so it's live even
        // before the first tmux notification arrives. `do_refresh`
        // re-asserts it on every tick — cheap, and guards against
        // anything that clobbers the root key table mid-session.
        ensure_ctrl_q_bound(socket.as_deref());
        globals.cq_installed = true;

        // Install the S-Left / S-Right MRU session cycle bindings. Same
        // self-heal pattern as C-q: do_refresh re-asserts every tick.
        ensure_session_cycle_bound(socket.as_deref());
        globals.cycle_installed = true;

        // Install the M-O quick-jump popup binding. Same self-heal.
        ensure_quick_jump_bound(socket.as_deref());
        globals.quick_jump_installed = true;

        // Start the control-mode monitor subprocess. The guard is
        // held for the lifetime of the actor — dropping it on exit
        // kills the subprocess. `notifs` is the receive side of a
        // channel the reader task pushes parsed notifications onto.
        //
        // Fallback: if spawn fails, we log a warning and run in
        // commands-only mode (notifs = None, the select! branch
        // falls through to std::future::pending).
        let (_control_guard, mut notifs) = match ControlClient::spawn(socket.as_deref()).await {
            Ok((guard, rx)) => (Some(guard), Some(rx)),
            Err(e) => {
                tracing::warn!("tmux control mode unavailable: {}", e);
                let _ = evt_tx.send(AppMsg::Warn(format!("live refresh off: {}", e)));
                (None, None)
            }
        };

        // Internal 1Hz refresh timer. Control-mode notifications
        // drive session/window lifecycle updates, but tmux doesn't
        // notify on plain pane content changes — so without a timer,
        // the preview for the focused session would never update
        // while the underlying pane is writing output (the exact
        // "preview: capturing…" stuck state we hit on first v0.2.0
        // build). `Skip` missed-tick behavior means a slow host or
        // a long refresh doesn't produce a burst of catch-up ticks
        // afterwards — at most one tick per wake-up.
        //
        // Unlike the old standalone `poller` task, this timer lives
        // *inside* `tmux_actor` and triggers `do_refresh` directly.
        // No tick flows through `main`'s event loop, no
        // `cmd_tx`/`evt_tx` cross-channel handoff, so the back-
        // pressure deadlock that killed v0.1.x can't manifest here.
        let mut preview_tick = time::interval(Duration::from_millis(1000));
        preview_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
        // Skip the immediate first tick — we're about to do a
        // refresh explicitly just below.
        preview_tick.tick().await;

        // Fast preview tick (`Config::preview_tick_ms`, default 200ms).
        // This is the v0.x "1 fps preview" fix from Step 0 of the 2.0
        // plan: re-capture just the focused session's pane on a tight
        // cadence so the preview is perceptually live without paying
        // for a full `refresh_all` (list-sessions + per-session
        // detector + statusbar diff). The full 1Hz `preview_tick`
        // above still runs and still updates the focused session's
        // preview as a side effect — the fast tick is purely additive.
        //
        // When `preview_tick_ms == 0` or there's no focused session,
        // the fast branch in the select! below is a no-op.
        let mut preview_fast_tick = if config.preview_tick_ms > 0 {
            let mut t = time::interval(Duration::from_millis(config.preview_tick_ms));
            t.set_missed_tick_behavior(MissedTickBehavior::Skip);
            // Skip the immediate first tick — the initial refresh
            // below covers it.
            t.tick().await;
            Some(t)
        } else {
            None
        };

        // Initial refresh so the UI populates without waiting for a
        // notification. Otherwise a user starting bosun against an
        // already-quiet tmux server would see an empty list until
        // something changed.
        let _ = do_refresh(
            &*client,
            &config,
            &registry,
            &mut smoothers,
            focused.as_deref(),
            socket.as_deref(),
            &mut last_bar_state,
            &mut globals,
            &evt_tx,
            None,
        )
        .await;

        loop {
            tokio::select! {
                maybe_cmd = cmd_rx.recv() => {
                    let Some(cmd) = maybe_cmd else { break };
                    match cmd {
                Command::ListNow => {
                    let views = refresh_all(
                        &*client,
                        &config,
                        &registry,
                        &mut smoothers,
                        focused.as_deref(),
                    )
                    .await;
                    match views {
                        Ok(views) => {
                            smoothers.retain(|name, _| views.iter().any(|v| v.name() == name));

                            // Only sync the status bar when the set of
                            // (internal, display, attached) tuples has
                            // actually changed. Skips the ~N*7 set-option
                            // calls on ticks where nothing's moved.
                            let state: Vec<BarSession> = views
                                .iter()
                                .map(|v| BarSession {
                                    internal: v.name().to_string(),
                                    display: v.display().to_string(),
                                    attached: v.session.attached,
                                })
                                .collect();
                            if !bar_state_equal(&state, &last_bar_state) {
                                sync_status_bar(socket.as_deref(), &state, &mut globals);
                                last_bar_state = state;
                            }

                            if evt_tx
                                .send(AppMsg::SessionsRefreshed {
                                    sessions: views,
                                    select_after: None,
                                })
                                .is_err()
                            {
                                break;
                            }
                        }
                        Err(e) => {
                            if evt_tx.send(AppMsg::Warn(format!("list: {}", e))).is_err() {
                                break;
                            }
                        }
                    }
                }
                Command::FocusPreview { name } => {
                    // Set focus, then refresh immediately so the
                    // preview catches up to the new selection
                    // without waiting up to 1s for the next
                    // preview_tick. Without this the user sees a
                    // stuck "preview: capturing…" when switching
                    // between sessions quickly.
                    focused = Some(name);
                    let _ = do_refresh(
                        &*client,
                        &config,
                        &registry,
                        &mut smoothers,
                        focused.as_deref(),
                        socket.as_deref(),
                        &mut last_bar_state,
                        &mut globals,
                        &evt_tx,
                        None,
                    )
                    .await;
                }
                Command::KillSession(internal) => {
                    match client.kill_session(&internal).await {
                        Ok(()) => {
                            // If we killed the focused session, drop
                            // the focus so the preview doesn't keep
                            // trying to capture a dead pane.
                            if focused.as_deref() == Some(internal.as_str()) {
                                focused = None;
                            }
                            // Force a refresh so the session disappears
                            // from the UI without a 1s wait.
                            let _ = do_refresh(
                                &*client,
                                &config,
                                &registry,
                                &mut smoothers,
                                focused.as_deref(),
                                socket.as_deref(),
                                &mut last_bar_state,
                                &mut globals,
                                &evt_tx,
                                None,
                            )
                            .await;
                        }
                        Err(e) => {
                            let _ = evt_tx.send(AppMsg::Warn(format!("kill: {}", e)));
                        }
                    }
                }
                Command::KillSessionRemoveWorktree {
                    internal,
                    worktree_path,
                    branch,
                    merge,
                } => {
                    handle_kill_remove_worktree(
                        &*client,
                        &internal,
                        &worktree_path,
                        &branch,
                        merge,
                        &evt_tx,
                    )
                    .await;
                    // Mirror the KillSession post-kill refresh: drop the
                    // focus if we just tore down the focused session, then
                    // force a refresh so the row disappears without a 1s wait.
                    if focused.as_deref() == Some(internal.as_str()) {
                        focused = None;
                    }
                    let _ = do_refresh(
                        &*client,
                        &config,
                        &registry,
                        &mut smoothers,
                        focused.as_deref(),
                        socket.as_deref(),
                        &mut last_bar_state,
                        &mut globals,
                        &evt_tx,
                        None,
                    )
                    .await;
                }
                Command::KillContainer { tabs } => {
                    // Multi-kill: iterate each tab serially so a
                    // failure on one doesn't abort the rest. The
                    // sidebar reconcile after the final refresh
                    // drops the now-tab-less container.
                    let mut failed = Vec::new();
                    for tab in &tabs {
                        if let Err(e) = client.kill_session(tab).await {
                            failed.push(format!("{}: {}", tab, e));
                        } else if focused.as_deref() == Some(tab.as_str()) {
                            focused = None;
                        }
                    }
                    if !failed.is_empty() {
                        let _ = evt_tx.send(AppMsg::Warn(format!(
                            "kill container: {}",
                            failed.join(", ")
                        )));
                    }
                    let _ = do_refresh(
                        &*client,
                        &config,
                        &registry,
                        &mut smoothers,
                        focused.as_deref(),
                        socket.as_deref(),
                        &mut last_bar_state,
                        &mut globals,
                        &evt_tx,
                        None,
                    )
                    .await;
                }
                Command::DeleteRecent(id) => {
                    if let Err(e) = store.delete_recent(id) {
                        tracing::warn!("delete_recent({}): {}", id, e);
                    }
                }
                Command::RestartSession {
                    internal,
                    continue_session,
                } => {
                    // In-place restart, two phases split across the
                    // actor and the app so the agent never relaunches
                    // before its OSC background responder is live
                    // (issue #2). Here we only do the *stop* half:
                    // `restart_in_place` with an empty command sends
                    // C-c until the pane drops back to a shell, then
                    // leaves it clean. The session, its internal name,
                    // and the pane all stay the same — no sidebar
                    // churn, no ghost row, no slot change. We then emit
                    // `DeferRelaunch`; the app waits for `sync_embed`
                    // to (re)attach the embed and fires
                    // `Command::LaunchAgent`, which types the command
                    // into the now-OSC-answering pane. This matches the
                    // fresh-create deferral so a cold-start `R` (whose
                    // embed may not be attached yet) no longer relaunches
                    // Codex against a dead pane and caches a dark diff.
                    match client.get_session_metadata(&internal).await {
                        Ok(Some(meta)) => {
                            let spec = metadata_to_spec(meta);
                            // Stop only — no line prep, so the relaunch
                            // call below is the sole place the prompt's
                            // precmd hooks fire (issue #2; was running a
                            // `git status` precmd twice per restart).
                            if let Err(e) = client.restart_in_place(&internal, "", false).await {
                                let _ = evt_tx.send(AppMsg::Warn(format!("restart: {}", e)));
                                continue;
                            }
                            // Recents row is touched so this session
                            // bubbles to the top of the recents store,
                            // matching the pre-existing kill+create
                            // semantics.
                            if let Err(e) = store.upsert_recent(&spec) {
                                tracing::warn!("store upsert on restart: {}", e);
                            }
                            let _ =
                                evt_tx.send(AppMsg::Warn(format!("restarted {}", spec.name)));
                            // Hand the relaunch back to the app so it
                            // gates on the embed (OSC responder) being
                            // attached before the agent starts.
                            let _ = evt_tx.send(AppMsg::DeferRelaunch {
                                internal: internal.clone(),
                                resume: continue_session,
                            });
                        }
                        Ok(None) => {
                            let _ = evt_tx.send(AppMsg::Warn(
                                "cannot restart: session predates metadata support".to_string(),
                            ));
                        }
                        Err(e) => {
                            let _ = evt_tx.send(AppMsg::Warn(format!("restart read: {}", e)));
                        }
                    }
                }
                Command::LaunchAgent { internal, resume } => {
                    // Deferred agent launch for a session that's sitting
                    // at a bare shell — either freshly created or just
                    // stopped by an in-place restart (issue #2). The app
                    // fires this once the OSC-answering embed has
                    // attached, so the agent's startup background probe
                    // gets a real answer. We rebuild the command from the
                    // persisted metadata; `resume` overrides the launch
                    // mode for this one launch (`None` = use the stored
                    // mode for a fresh create, `Some(b)` = the restart's
                    // one-shot choice). `restart_in_place` waits for the
                    // shell, types the command, and bursts a redraw. A
                    // `terminal` session has an empty command and is left
                    // as a plain shell.
                    match client.get_session_metadata(&internal).await {
                        Ok(Some(meta)) => {
                            let spec = metadata_to_spec(meta);
                            let command = build_launch_command(
                                &spec.agent,
                                &spec.options,
                                &spec.args,
                                resume.unwrap_or(spec.resume),
                            );
                            if command.is_empty() {
                                continue;
                            }
                            // prep_line = true: this call does the single
                            // C-u/Enter/C-u cleanup right before typing.
                            if let Err(e) = client.restart_in_place(&internal, &command, true).await
                            {
                                let _ = evt_tx.send(AppMsg::Warn(format!("launch: {}", e)));
                                continue;
                            }
                            for delay_ms in [200u64, 600, 1200] {
                                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
                                let _ = do_refresh(
                                    &*client,
                                    &config,
                                    &registry,
                                    &mut smoothers,
                                    focused.as_deref(),
                                    socket.as_deref(),
                                    &mut last_bar_state,
                                    &mut globals,
                                    &evt_tx,
                                    None,
                                )
                                .await;
                            }
                        }
                        Ok(None) => {
                            // No metadata — nothing to launch. Leave the
                            // shell as-is rather than guessing a command.
                            tracing::debug!("launch: no metadata for {}", internal);
                        }
                        Err(e) => {
                            let _ = evt_tx.send(AppMsg::Warn(format!("launch read: {}", e)));
                        }
                    }
                }
                Command::RenameSession {
                    internal,
                    new_display,
                } => match client.set_display_name(&internal, &new_display).await {
                    Ok(()) => {
                        let _ = do_refresh(
                            &*client,
                            &config,
                            &registry,
                            &mut smoothers,
                            focused.as_deref(),
                            socket.as_deref(),
                            &mut last_bar_state,
                            &mut globals,
                            &evt_tx,
                            None,
                        )
                        .await;
                    }
                    Err(e) => {
                        let _ = evt_tx.send(AppMsg::Warn(format!("rename: {}", e)));
                    }
                },
                Command::CreateSession(spec) => {
                    // Collision-check against the CURRENT live sessions
                    // so "Bosun" auto-becomes "Bosun 2" when a session
                    // with the same display name already exists.
                    let spec = match resolve_collision(&*client, &config, spec).await {
                        Ok(resolved) => resolved,
                        Err(e) => {
                            let _ = evt_tx.send(AppMsg::Warn(format!("create: {}", e)));
                            continue;
                        }
                    };

                    // Defer the agent launch to a post-embed
                    // `LaunchAgent` whenever embeds are on, so the OSC
                    // background-color responder is live before the
                    // agent probes (issue #2). With embeds off there's
                    // no responder anyway, so launch inline.
                    let defer_launch = config.embed_enabled;
                    match create_session(&*client, &config, spec.clone(), defer_launch).await {
                        Ok(internal_name) => {
                            focused = Some(internal_name.clone());
                            // Save the recent (on the resolved spec —
                            // so if "Bosun" became "Bosun 2", the
                            // recents store remembers "Bosun 2").
                            if let Err(e) = store.upsert_recent(&spec) {
                                tracing::warn!("store upsert_recent: {}", e);
                            }
                            let _ = evt_tx.send(AppMsg::Warn(format!("created {}", internal_name)));
                            let _ = do_refresh(
                                &*client,
                                &config,
                                &registry,
                                &mut smoothers,
                                focused.as_deref(),
                                socket.as_deref(),
                                &mut last_bar_state,
                                &mut globals,
                                &evt_tx,
                                Some(internal_name),
                            )
                            .await;
                        }
                        Err(e) => {
                            let _ = evt_tx.send(AppMsg::Warn(format!("create: {}", e)));
                        }
                    }
                }
                Command::OpenModifySession { internal } => {
                    // JIT read of the live `@bosun_*` metadata so
                    // the modify modal pre-fills against the
                    // current state of the session (not whatever
                    // was last cached in the recents store).
                    // Surfacing this as a warning is fine because
                    // the only way to land here is `m` on a session
                    // that bosun didn't create — recoverable.
                    match client.get_session_metadata(&internal).await {
                        Ok(Some(meta)) => {
                            let spec = metadata_to_spec(meta);
                            let _ = evt_tx.send(AppMsg::ModifySpecReady {
                                internal,
                                spec,
                            });
                        }
                        Ok(None) => {
                            let _ = evt_tx.send(AppMsg::Warn(
                                "modify: session predates metadata support".to_string(),
                            ));
                        }
                        Err(e) => {
                            let _ = evt_tx
                                .send(AppMsg::Warn(format!("modify read: {}", e)));
                        }
                    }
                }
                Command::ModifySession { internal, spec } => {
                    // Write the new spec back as `@bosun_*` user
                    // options on the live session. The agent
                    // process keeps running with its old flags;
                    // the next `R` (restart) picks the new spec up
                    // via the same `get_session_metadata` path
                    // RestartSession already uses.
                    let meta = spec_to_metadata(&spec);
                    let mut any_err = false;
                    if let Err(e) =
                        client.set_display_name(&internal, &meta.display_name).await
                    {
                        any_err = true;
                        let _ = evt_tx
                            .send(AppMsg::Warn(format!("modify display: {}", e)));
                    }
                    if let Err(e) =
                        client.set_session_metadata(&internal, &meta).await
                    {
                        any_err = true;
                        let _ = evt_tx
                            .send(AppMsg::Warn(format!("modify metadata: {}", e)));
                    }
                    if let Err(e) = store.upsert_recent(&spec) {
                        tracing::warn!("modify upsert_recent: {}", e);
                    }
                    if !any_err {
                        let _ = evt_tx.send(AppMsg::Warn(format!(
                            "modified {} — press R to apply",
                            spec.name
                        )));
                    }
                    let _ = do_refresh(
                        &*client,
                        &config,
                        &registry,
                        &mut smoothers,
                        focused.as_deref(),
                        socket.as_deref(),
                        &mut last_bar_state,
                        &mut globals,
                        &evt_tx,
                        None,
                    )
                    .await;
                }
                Command::Attach { .. } => {
                    tracing::warn!("tmux_actor received Attach — ignored; app task handles attach");
                }
                Command::SetTheme { .. }
                | Command::SaveDivider(_)
                | Command::SaveSidebar(_)
                | Command::SaveSessionHistory(_)
                | Command::SaveBannerFont(_)
                | Command::InsertSection { .. }
                | Command::RenameSection { .. }
                | Command::OpenEditor { .. } => {
                    // Pure UI state — the app loop intercepts these
                    // before forwarding. If one makes it here the
                    // intercept path is broken.
                    tracing::warn!("tmux_actor received UI-only command — should be intercepted by app");
                }
                        Command::Shutdown => break,
                    }
                }
                maybe_notif = async {
                    // If the control client failed at spawn or has
                    // since closed, disable this branch by awaiting
                    // a future that never resolves. select! will
                    // then only poll the cmd branch.
                    match notifs.as_mut() {
                        Some(rx) => rx.recv().await,
                        None => std::future::pending().await,
                    }
                } => {
                    let Some(notif) = maybe_notif else {
                        // Reader task exited — monitor is gone. Fall
                        // back to commands-only mode for the rest of
                        // this actor's lifetime.
                        tracing::warn!("tmux control notification stream closed");
                        notifs = None;
                        continue;
                    };

                    // Lifecycle notifications trigger a full refresh.
                    // Pane `%output` and layout changes are ignored
                    // for now — status detection still runs against
                    // pane captures on refresh, and preview updates
                    // come via FocusPreview commands. (A future
                    // improvement can wire %output into the
                    // detectors for push-based status + preview.)
                    let should_refresh = matches!(
                        notif,
                        Notification::SessionsChanged
                            | Notification::SessionChanged { .. }
                            | Notification::SessionRenamed { .. }
                            | Notification::SessionClosed { .. }
                            | Notification::SessionWindowChanged { .. }
                            | Notification::WindowAdd { .. }
                            | Notification::WindowClose { .. }
                            | Notification::WindowRenamed { .. }
                    );

                    if matches!(notif, Notification::Exit) {
                        tracing::warn!(
                            "tmux control subprocess exited — commands-only mode"
                        );
                        notifs = None;
                        continue;
                    }

                    if should_refresh {
                        let _ = do_refresh(
                            &*client,
                            &config,
                            &registry,
                            &mut smoothers,
                            focused.as_deref(),
                            socket.as_deref(),
                            &mut last_bar_state,
                            &mut globals,
                            &evt_tx,
                            None,
                        )
                        .await;
                    }
                }
                _ = preview_tick.tick() => {
                    // Periodic refresh for preview + status
                    // detection. See the comment on `preview_tick`
                    // above for why this lives inside the actor
                    // rather than in a separate task.
                    let _ = do_refresh(
                        &*client,
                        &config,
                        &registry,
                        &mut smoothers,
                        focused.as_deref(),
                        socket.as_deref(),
                        &mut last_bar_state,
                        &mut globals,
                        &evt_tx,
                        None,
                    )
                    .await;
                }
                _ = async {
                    // If the fast tick is disabled (`preview_tick_ms = 0`),
                    // park forever — select! just never picks this branch.
                    match preview_fast_tick.as_mut() {
                        Some(t) => { t.tick().await; }
                        None => { std::future::pending::<()>().await; }
                    }
                } => {
                    // Fast tick: live status + preview for the
                    // *focused* session only.
                    //
                    // We used to `capture-pane` every managed session
                    // on this tick to keep the whole sidebar's
                    // Running/Waiting glyphs at `preview_tick_ms`
                    // latency. That meant `1 + N` tmux execs every tick
                    // (one `list-sessions` + one `capture-pane` per
                    // session) — with a dozen sessions and a 200ms
                    // tick that's ~60 short-lived `tmux` processes a
                    // second, *per bosun instance*. macOS Gatekeeper
                    // re-scans each exec of an ad-hoc-signed binary
                    // (Homebrew's tmux included) and never caches the
                    // verdict, so a high exec rate pins `syspolicyd` at
                    // hundreds of percent CPU. See the project notes.
                    //
                    // Background sessions don't need sub-second glyphs:
                    // the 1Hz `preview_tick` already captures and
                    // detects every managed session via `refresh_all`,
                    // so they stay live at 1s. Only the session the
                    // user is actually watching needs the tight
                    // cadence, so the fast tick now captures just that
                    // one — dropping the per-tick cost from `1 + N` to
                    // `1 + 1`. capture-pane failures are silently
                    // dropped; the 1Hz tick reconciles membership.
                    //
                    // Nothing focused → nothing needs the tight
                    // cadence, so skip the tick outright and don't
                    // even pay the `list-sessions` exec.
                    if focused.is_none() {
                        continue;
                    }
                    match client.list_sessions().await {
                        Ok(raw) => {
                            let now = SystemTime::now();
                            for s in raw
                                .into_iter()
                                .filter(|s| config.manages(&s.name))
                                .filter(|s| Some(s.name.as_str()) == focused.as_deref())
                            {
                                let bytes = match client.capture_pane(&s.name).await {
                                    Ok(b) => b,
                                    Err(e) => {
                                        tracing::debug!(
                                            "fast capture {}: {}", s.name, e
                                        );
                                        continue;
                                    }
                                };
                                let plain = crate::tmux::detector::strip_ansi(&bytes);
                                let prev = smoothers
                                    .get(&s.name)
                                    .map(|sm| sm.current());
                                let ctx = DetectContext::from_parts(
                                    &bytes,
                                    &plain,
                                    s.last_activity,
                                    now,
                                    prev,
                                    &s.name,
                                );
                                let detected = registry.detect(&ctx);
                                let smoothed = smoothers
                                    .entry(s.name.clone())
                                    .or_default()
                                    .observe(detected);
                                let publish = if smoothed == Status::Unknown {
                                    Status::Idle
                                } else {
                                    smoothed
                                };
                                let _ = evt_tx.send(AppMsg::StatusRefreshed {
                                    name: s.name.clone(),
                                    status: publish,
                                });
                                // Focused session also gets the
                                // preview bytes so the right-pane
                                // capture (and any non-embed preview
                                // path) stays live at this cadence.
                                if Some(s.name.as_str()) == focused.as_deref() {
                                    let arc: Arc<[u8]> =
                                        Arc::from(bytes.into_boxed_slice());
                                    let _ = evt_tx.send(AppMsg::PreviewRefreshed {
                                        name: s.name.clone(),
                                        bytes: arc,
                                    });
                                }
                            }
                        }
                        Err(e) => {
                            tracing::debug!("fast list-sessions: {}", e);
                        }
                    }
                }
            }
        }

        // `globals` drops here → uninstall_globals runs.
        drop(globals);
    })
}

/// Kill a worktree-backed session and clean up its git worktree.
///
/// Extracted from the command loop so it's testable against the real
/// git client without spinning up the whole actor. Every failure is
/// surfaced as a `Warn` toast and leaves the worktree intact — we
/// never force-remove a dirty tree and never auto-merge past a
/// conflict.
async fn handle_kill_remove_worktree(
    client: &dyn TmuxClient,
    internal: &str,
    worktree_path: &str,
    branch: &str,
    merge: bool,
    evt_tx: &mpsc::UnboundedSender<AppMsg>,
) {
    // 1. Kill the tmux session (idempotent — fine if already gone).
    let _ = client.kill_session(internal).await;
    // 2. Resolve the main repo root from the worktree path.
    let repo = match client.main_repo_root(worktree_path).await {
        Ok(r) => r,
        Err(e) => {
            let _ = evt_tx.send(AppMsg::Warn(format!("worktree cleanup: {}", e)));
            return;
        }
    };
    // 3. Dirty guard — never force-remove a dirty tree.
    match client.is_dirty(worktree_path).await {
        Ok(true) => {
            let _ = evt_tx.send(AppMsg::Warn(
                "worktree has uncommitted changes; not removed".into(),
            ));
            return;
        }
        Ok(false) => {}
        Err(e) => {
            let _ = evt_tx.send(AppMsg::Warn(format!("worktree status: {}", e)));
            return;
        }
    }
    // 4. Optional merge into the repo's current branch.
    if merge {
        if let Err(e) = client.branch_merge(&repo, branch).await {
            // A conflicted `git merge` exits non-zero but leaves the main
            // repo mid-merge (conflict markers + MERGE_HEAD). Abort so the
            // repo is restored to exactly its pre-merge state — otherwise
            // the user would have to run `git merge --abort` by hand.
            if let Err(abort_err) = client.merge_abort(&repo).await {
                let _ = evt_tx.send(AppMsg::Warn(format!(
                    "merge {}: {} (also failed to abort: {})",
                    branch, e, abort_err
                )));
            } else {
                let _ = evt_tx.send(AppMsg::Warn(format!(
                    "merge {} conflicted; aborted, worktree kept",
                    branch
                )));
            }
            return; // leave the worktree intact on a failed/conflicted merge
        }
    }
    // 5. Remove the worktree (force=false; the dirty guard above already passed).
    // Note: cleanup is not atomic. If `merge` already succeeded above and this
    // removal fails, the merge stays committed on the repo's current branch
    // while the worktree (and branch) survive — surfaced as a Warn, no rollback
    // (rolling back a git merge is riskier than leaving the stray worktree).
    if let Err(e) = client.worktree_remove(&repo, worktree_path, false).await {
        // Tell the user if the merge already landed, so they know the branch
        // was integrated even though the worktree couldn't be removed.
        let prefix = if merge {
            "merged, but worktree remove failed"
        } else {
            "worktree remove"
        };
        let _ = evt_tx.send(AppMsg::Warn(format!("{}: {}", prefix, e)));
        return;
    }
    // 6. Delete the branch only on the merge path. Surface a failure as a Warn
    // for consistency with the rest of the handler (in practice `branch -d`
    // after a successful merge, with the worktree now removed, always succeeds).
    if merge {
        if let Err(e) = client.branch_delete(&repo, branch).await {
            let _ = evt_tx.send(AppMsg::Warn(format!("branch delete {}: {}", branch, e)));
        }
    }
}

/// Assemble the internal tmux session name from the user's typed
/// display name. Internal format: `<prefix><slug>-<hex-suffix>`,
/// e.g. `bosun-my-rocket-fox-a1b2c3d4`. The display name can contain
/// caps, spaces, punctuation — anything — but the tmux-visible name
/// is a lowercase dashed slug + unique hex suffix so it's safe to
/// pass to `-t` and always unique even for duplicate display names.
fn build_internal_name(prefix: &str, display: &str) -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let suffix = format!("{:08x}", nanos as u32);
    let slug = slugify(display);
    // If the slug somehow ends up empty (e.g. display was all symbols,
    // which the modal should reject but be defensive), fall back to
    // "session".
    let slug = if slug.is_empty() {
        "session".to_string()
    } else {
        slug
    };
    format!("{}{}-{}", prefix, slug, suffix)
}

/// Lowercase slug: alphanumeric and underscores are kept (underscore
/// is valid in tmux session names); everything else collapses to
/// single dashes; leading/trailing dashes are trimmed.
///
/// Distinct on purpose from `ui::modal::new_session::slug` (which slugs
/// the git *branch* name and drops `_`). This one feeds the internal
/// tmux *session* name — don't unify them.
pub(crate) fn slugify(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut last_dash = false;
    for c in s.chars() {
        if c.is_alphanumeric() || c == '_' {
            for lower in c.to_lowercase() {
                out.push(lower);
            }
            last_dash = false;
        } else if !last_dash && !out.is_empty() {
            out.push('-');
            last_dash = true;
        }
    }
    out.trim_end_matches('-').to_string()
}

/// Reverse of `build_internal_name`: extract the slug portion from an
/// internal session name shaped like `<prefix><slug>-<8-hex>`. Returns
/// `None` if the input doesn't match the expected shape — caller can
/// then fall back to showing the raw internal name.
///
/// Used by the sidebar to render a friendlier label on "missing" rows
/// (sessions that died with a tmux server restart) and to match those
/// rows back to a `Recent` so `R` can recreate them.
pub(crate) fn slug_from_internal<'a>(internal: &'a str, prefix: &str) -> Option<&'a str> {
    let after_prefix = if prefix.is_empty() {
        internal
    } else {
        internal.strip_prefix(prefix)?
    };
    // Last `-` separates slug from the 8-hex suffix.
    let dash = after_prefix.rfind('-')?;
    let (slug, rest) = after_prefix.split_at(dash);
    let suffix = rest.strip_prefix('-')?;
    if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
        Some(slug)
    } else {
        None
    }
}

/// Map the agent + options + extra args into a shell command to type
/// into the session's pane.
///
/// We type the command directly into the user's login shell — no
/// `bash -c 'exec ...'` wrapping. Bosun runs its own tmux server on
/// a dedicated `-L bosun` socket, which is a child of the bosun
/// process, so pane shells inherit the right environment (including
/// Keychain lineage for Claude Code). The agent runs as a child of
/// the shell; Ctrl-Z suspends the agent directly, fg resumes it, and
/// when the agent exits the shell stays alive so the session doesn't
/// die.
///
/// `terminal` just types whatever extra args the user provided (or
/// nothing — you get a plain shell).
fn build_agent_command(agent: &str, options: &SpecOptions, args: &str) -> String {
    let args = args.trim();
    match agent {
        "claude" => {
            let mut parts: Vec<String> = vec!["claude".into()];
            match options.claude.session_mode {
                ClaudeSessionMode::New => {}
                ClaudeSessionMode::Continue => parts.push("--continue".into()),
                ClaudeSessionMode::Resume => parts.push("--resume".into()),
            }
            if options.claude.skip_permissions {
                parts.push("--dangerously-skip-permissions".into());
            }
            if !args.is_empty() {
                parts.push(args.to_string());
            }
            parts.join(" ")
        }
        "codex" => {
            let mut parts: Vec<String> = vec!["codex".into()];
            if options.codex.yolo {
                parts.push("--yolo".into());
            }
            if !args.is_empty() {
                parts.push(args.to_string());
            }
            parts.join(" ")
        }
        _ => args.to_string(),
    }
}

/// Build the launch command, optionally forcing a one-shot resume.
/// When `resume` is true and the agent supports it, swap in the resume
/// invocation — claude `--continue`, codex `resume --last` — instead of
/// whatever the persisted `options` would produce. Callers that resume
/// (the restart prompt's `r` action) never persist the override, so the
/// next plain launch goes back to the saved mode. For agents with no
/// resume concept (or `resume == false`) this is identical to
/// `build_agent_command`.
fn build_launch_command(agent: &str, options: &SpecOptions, args: &str, resume: bool) -> String {
    if !resume {
        return build_agent_command(agent, options, args);
    }
    match agent {
        "claude" => {
            let mut options = options.clone();
            options.claude.session_mode = ClaudeSessionMode::Continue;
            build_agent_command(agent, &options, args)
        }
        "codex" => {
            let args = args.trim();
            let mut parts: Vec<String> = vec!["codex".into(), "resume".into(), "--last".into()];
            if options.codex.yolo {
                parts.push("--yolo".into());
            }
            if !args.is_empty() {
                parts.push(args.to_string());
            }
            parts.join(" ")
        }
        _ => build_agent_command(agent, options, args),
    }
}

async fn create_session(
    client: &dyn TmuxClient,
    config: &Config,
    spec: SessionSpec,
    defer_launch: bool,
) -> crate::error::Result<String> {
    let internal = build_internal_name(&config.session_prefix, &spec.name);
    // If a worktree was requested, create it and repoint the path.
    // `spec` is taken by value (see the fn signature), so rebinding as
    // mut is valid and later shared reads/clones of `spec` still compile.
    let mut spec = spec;
    // When we create a worktree below, remember (repo, worktree_path, branch)
    // so we can roll it back if a later step (the tmux `create_session`)
    // fails — otherwise the worktree + its new branch would be orphaned with
    // no session attached to them.
    let mut created_worktree: Option<(String, String, String)> = None;
    if let Some(wt) = spec.worktree.clone() {
        let repo = client.repo_root(&spec.path).await?; // errors if not a git repo
                                                        // The `Subdir` scheme drops the worktree inside the repo's own
                                                        // working tree (`<repo>/.worktrees/`). Git doesn't auto-ignore a
                                                        // nested linked worktree, so without this it shows as untracked in
                                                        // `git status` and `git add -A` would try to embed it as a gitlink.
                                                        // Exclude it locally (best-effort — never block create on this).
        if config.worktree_location == crate::config::WorktreeLocation::Subdir {
            if let Err(e) = client.ensure_excluded(&repo, "/.worktrees/").await {
                tracing::warn!("failed to exclude .worktrees/ in {}: {}", repo, e);
            }
        }
        let worktree_path = resolve_worktree_path(&repo, &wt.branch, config.worktree_location);
        client
            .worktree_add(&repo, &wt.branch, &worktree_path)
            .await?; // aborts create on failure
        created_worktree = Some((repo, worktree_path.clone(), wt.branch));
        spec.path = worktree_path; // spec_to_metadata reads spec.path + spec.worktree below
    }
    // `defer_launch` creates the pane as a bare shell and leaves the
    // agent command for a later `Command::LaunchAgent` — fired by the
    // app once the OSC-answering embed has attached (issue #2). The
    // metadata still carries the full spec, so LaunchAgent can rebuild
    // the exact command. When not deferring (embeds off), the command
    // runs as part of create, matching the pre-issue-#2 behavior.
    let command = if defer_launch {
        String::new()
    } else {
        build_launch_command(&spec.agent, &spec.options, &spec.args, spec.resume)
    };
    let metadata = Some(spec_to_metadata(&spec));
    let create = CreateSpec {
        name: internal.clone(),
        display_name: Some(spec.name.clone()),
        path: spec.path.clone(),
        command,
        metadata,
    };
    match client.create_session(&create).await {
        Ok(_) => Ok(internal),
        Err(e) => {
            // Roll back a just-created worktree so a failed tmux create
            // doesn't leave an orphaned worktree + branch behind. The
            // worktree is pristine (no commits of its own), so force-remove
            // is safe. `git worktree remove` does NOT delete the branch that
            // `worktree_add -b` created, so delete it explicitly afterwards —
            // otherwise a retry with the same name hits "branch already
            // exists". Both steps are best-effort (log on failure).
            if let Some((repo, worktree_path, branch)) = created_worktree {
                if let Err(cleanup_err) = client.worktree_remove(&repo, &worktree_path, true).await
                {
                    tracing::warn!(
                        "failed to roll back worktree {} after create error: {}",
                        worktree_path,
                        cleanup_err
                    );
                } else if let Err(branch_err) = client.branch_delete(&repo, &branch).await {
                    // Only attempt the branch delete once the worktree is
                    // gone — `branch -d` is refused while the branch is
                    // checked out in a worktree.
                    tracing::warn!(
                        "failed to roll back branch {} after create error: {}",
                        branch,
                        branch_err
                    );
                }
            }
            Err(e)
        }
    }
}

/// Project a `SessionSpec` into the persisted tmux-options shape.
fn spec_to_metadata(spec: &SessionSpec) -> SessionMetadata {
    SessionMetadata {
        display_name: spec.name.clone(),
        path: spec.path.clone(),
        agent: spec.agent.clone(),
        args: spec.args.clone(),
        claude_session_mode: match spec.options.claude.session_mode {
            ClaudeSessionMode::New => "New".to_string(),
            ClaudeSessionMode::Continue => "Continue".to_string(),
            ClaudeSessionMode::Resume => "Resume".to_string(),
        },
        claude_skip_permissions: spec.options.claude.skip_permissions,
        codex_yolo: spec.options.codex.yolo,
        container_id: spec.container_id.clone(),
        // By the time this runs inside `create_session`, `spec.path` has
        // already been repointed to the resolved worktree path (see the
        // worktree branch there), so persist both from the spec.
        worktree_path: spec.worktree.is_some().then(|| spec.path.clone()),
        branch: spec.worktree.as_ref().map(|w| w.branch.clone()),
    }
}

/// Compute where a new git worktree for `branch` should live, given the
/// repo root and the configured placement scheme. Pure — the actual
/// `git worktree add` happens in `create_session`.
fn resolve_worktree_path(
    repo_root: &str,
    branch: &str,
    loc: crate::config::WorktreeLocation,
) -> String {
    use crate::config::WorktreeLocation::*;
    let repo = repo_root.trim_end_matches('/');
    match loc {
        Subdir => format!("{}/.worktrees/{}", repo, branch),
        Sibling => format!("{}-{}", repo, branch),
    }
}

/// Inverse of `spec_to_metadata` — rebuild a SessionSpec from the
/// metadata we read off a live tmux session during restart.
fn metadata_to_spec(meta: SessionMetadata) -> SessionSpec {
    use crate::events::{ClaudeOptions, CodexOptions};
    SessionSpec {
        name: meta.display_name,
        path: meta.path,
        agent: meta.agent,
        args: meta.args,
        options: SpecOptions {
            claude: ClaudeOptions {
                session_mode: match meta.claude_session_mode.as_str() {
                    "Continue" => ClaudeSessionMode::Continue,
                    "Resume" => ClaudeSessionMode::Resume,
                    _ => ClaudeSessionMode::New,
                },
                skip_permissions: meta.claude_skip_permissions,
            },
            codex: CodexOptions {
                yolo: meta.codex_yolo,
            },
        },
        container_id: meta.container_id,
        resume: false,
        // Restart/modify never re-create the worktree — it already
        // exists on disk from the original create.
        worktree: None,
    }
}

/// Query the live session list, extract display names, and rename
/// `spec.name` via `resolve_name_collision` if needed. Pure-ish
/// wrapper; the one side-effect is the tmux list-sessions roundtrip.
async fn resolve_collision(
    client: &dyn TmuxClient,
    config: &Config,
    mut spec: SessionSpec,
) -> crate::error::Result<SessionSpec> {
    let sessions = client.list_sessions().await?;
    let existing: Vec<String> = sessions
        .into_iter()
        .filter(|s| config.manages(&s.name))
        .map(|s| s.display_name.unwrap_or(s.name))
        .collect();
    spec.name = resolve_name_collision(&spec.name, &existing);
    Ok(spec)
}

#[allow(clippy::too_many_arguments)]
async fn do_refresh(
    client: &dyn TmuxClient,
    config: &Config,
    registry: &DetectorRegistry,
    smoothers: &mut HashMap<String, Smoother>,
    focused: Option<&str>,
    socket: Option<&str>,
    last_bar_state: &mut Vec<BarSession>,
    globals: &mut GlobalsGuard,
    evt_tx: &mpsc::UnboundedSender<AppMsg>,
    select_after: Option<String>,
) -> crate::error::Result<()> {
    // Re-assert the Ctrl-Q detach binding on every refresh. `bind-key`
    // is idempotent, but running it once per tick means the binding
    // self-heals if anything clobbers the root key table during a
    // long-running session (source-file, another tool's hook, etc).
    ensure_ctrl_q_bound(socket);
    // Same self-heal for the S-Left / S-Right cycle bindings.
    ensure_session_cycle_bound(socket);
    // And for the M-O quick-jump popup binding.
    ensure_quick_jump_bound(socket);

    let views = refresh_all(client, config, registry, smoothers, focused).await?;
    smoothers.retain(|name, _| views.iter().any(|v| v.name() == name));

    let state: Vec<BarSession> = views
        .iter()
        .map(|v| BarSession {
            internal: v.name().to_string(),
            display: v.display().to_string(),
            attached: v.session.attached,
        })
        .collect();
    if !bar_state_equal(&state, last_bar_state) {
        sync_status_bar(socket, &state, globals);
        *last_bar_state = state;
    }

    let _ = evt_tx.send(AppMsg::SessionsRefreshed {
        sessions: views,
        select_after,
    });
    Ok(())
}

fn bar_state_equal(a: &[BarSession], b: &[BarSession]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    a.iter().zip(b.iter()).all(|(x, y)| {
        x.internal == y.internal && x.display == y.display && x.attached == y.attached
    })
}

fn sync_status_bar(socket: Option<&str>, sessions: &[BarSession], globals: &mut GlobalsGuard) {
    // Install the global prefix-1..9 bindings on first non-empty state.
    if !globals.installed && !sessions.is_empty() {
        if let Err(e) = status_bar::install_globals(socket, sessions) {
            tracing::warn!("status bar: install_globals failed: {}", e);
            return;
        }
        globals.installed = true;
    } else if globals.installed {
        // Already installed — rebind in case the list changed.
        if let Err(e) = status_bar::install_globals(socket, sessions) {
            tracing::warn!("status bar: rebind jump keys failed: {}", e);
        }
    }

    // Apply per-session status-* options. Bosun only touches sessions
    // it manages; everything else keeps whatever bar it had.
    for entry in sessions {
        if let Err(e) = status_bar::configure_session(socket, &entry.internal, sessions) {
            tracing::warn!(
                "status bar: configure_session {} failed: {}",
                entry.internal,
                e
            );
        }
    }
}

/// One full refresh pass: list, filter by the configured prefix,
/// capture (with preview for focused), detect, smooth. Returns a
/// ready-to-ship Vec<SessionView>.
async fn refresh_all(
    client: &dyn TmuxClient,
    config: &Config,
    registry: &DetectorRegistry,
    smoothers: &mut HashMap<String, Smoother>,
    focused: Option<&str>,
) -> crate::error::Result<Vec<SessionView>> {
    let raw = client.list_sessions().await?;
    // Drop anything that doesn't match the managed-session prefix.
    // Empty prefix → everything matches.
    let sessions: Vec<_> = raw
        .into_iter()
        .filter(|s| config.manages(&s.name))
        .collect();

    let now = SystemTime::now();
    let mut out = Vec::with_capacity(sessions.len());

    for s in sessions {
        // Capture the visible pane only (no scrollback) for both status
        // detection and preview rendering. Scrollback would pick up old
        // shell command history — not what the user expects to see.
        let ansi = match client.capture_pane(&s.name).await {
            Ok(v) => v,
            Err(e) => {
                tracing::warn!("capture-pane {} failed: {}", s.name, e);
                Vec::new()
            }
        };

        let plain = crate::tmux::detector::strip_ansi(&ansi);
        let prev = smoothers.get(&s.name).map(|sm| sm.current());
        let ctx = DetectContext::from_parts(&ansi, &plain, s.last_activity, now, prev, &s.name);
        let detected = registry.detect(&ctx);
        let smoothed = smoothers
            .entry(s.name.clone())
            .or_default()
            .observe(detected);

        // Only hold onto the preview buffer for the focused session — the
        // others get None so we don't keep megabytes of pane history alive.
        let preview = if Some(s.name.as_str()) == focused {
            Some(Arc::from(ansi.into_boxed_slice()))
        } else {
            None
        };
        // Fingerprint the visible text so the app can tell when a row
        // has changed since the user last looked at it (the unread
        // dot). Cheap, and rides the capture we already did — no extra
        // tmux exec.
        let content_hash = content_hash(&plain);
        let mut view = SessionView::new(
            s,
            if smoothed == Status::Unknown {
                // Never surface Unknown to the UI — fall back to Idle so the
                // glyph is stable instead of blinking.
                Status::Idle
            } else {
                smoothed
            },
            preview,
        );
        view.content_hash = content_hash;
        out.push(view);
    }

    Ok(out)
}

/// Layout-independent fingerprint of a pane's visible plain text, used
/// for unread detection (see `AppState::session_unread`).
///
/// The point is to hash the *text*, not how it happens to be laid out
/// for the currently attached client. A resize — most visibly,
/// re-attaching from a different-size device like a phone — reflows
/// every pane, and naively hashing the raw capture would then read
/// every session as unread even though no agent produced new output.
/// Two normalizations keep the hash about content:
///
/// - `capture_pane` already passes `-J`, which rejoins lines tmux
///   wrapped to the pane width, so a width change doesn't re-split a
///   long line into a different number of pieces.
/// - here we trim each line's trailing whitespace (tmux pads to the
///   pane width) and drop blank lines entirely, so trailing-space
///   padding and vertical blank-row differences don't perturb it — this
///   also covers an idle pane whose only "change" is cursor parking on
///   a blank row.
///
/// Returns `0` for empty/whitespace-only text (a failed or blank
/// capture) so the app treats it as "no information" rather than a
/// change.
fn content_hash(plain: &str) -> u64 {
    use std::hash::{Hash, Hasher};
    let mut h = std::collections::hash_map::DefaultHasher::new();
    let mut any = false;
    for line in plain.lines() {
        let trimmed = line.trim_end();
        if trimmed.is_empty() {
            continue;
        }
        any = true;
        trimmed.hash(&mut h);
        0u8.hash(&mut h); // unambiguous separator between lines
    }
    if !any {
        return 0;
    }
    h.finish()
}

#[cfg(test)]
mod content_hash_tests {
    use super::content_hash;

    #[test]
    fn empty_capture_is_zero() {
        assert_eq!(content_hash(""), 0);
        assert_eq!(content_hash("   \n  \n\n"), 0);
    }

    #[test]
    fn trailing_whitespace_does_not_change_hash() {
        // tmux pads each line to the pane width; the padding must not
        // count as content, or a width change would read as unread.
        let a = content_hash("hello\nworld");
        let b = content_hash("hello   \nworld\t");
        assert_eq!(a, b);
        assert_ne!(a, 0);
    }

    #[test]
    fn blank_line_padding_does_not_change_hash() {
        // A shorter terminal shows fewer/more blank rows; ignore them.
        let a = content_hash("line one\nline two");
        let b = content_hash("\nline one\n\n\nline two\n\n");
        assert_eq!(a, b);
    }

    #[test]
    fn different_text_changes_hash() {
        assert_ne!(content_hash("answer yes?"), content_hash("answer no?"));
    }

    #[test]
    fn line_boundaries_are_significant() {
        // "ab" on one line is not the same content as "a"/"b" on two —
        // the separator keeps these distinct so we don't collide real
        // text differences.
        assert_ne!(content_hash("ab"), content_hash("a\nb"));
    }
}

#[cfg(test)]
mod build_cmd_tests {
    use super::*;
    use crate::events::{ClaudeOptions, CodexOptions};

    fn opts() -> SpecOptions {
        SpecOptions::default()
    }

    #[test]
    fn claude_with_no_options_is_bare() {
        assert_eq!(build_agent_command("claude", &opts(), ""), "claude");
    }

    #[test]
    fn claude_continue_adds_flag() {
        let mut o = opts();
        o.claude.session_mode = ClaudeSessionMode::Continue;
        assert_eq!(build_agent_command("claude", &o, ""), "claude --continue");
    }

    #[test]
    fn claude_resume_skip_permissions_combines() {
        let o = SpecOptions {
            claude: ClaudeOptions {
                session_mode: ClaudeSessionMode::Resume,
                skip_permissions: true,
            },
            codex: CodexOptions::default(),
        };
        assert_eq!(
            build_agent_command("claude", &o, ""),
            "claude --resume --dangerously-skip-permissions"
        );
    }

    #[test]
    fn claude_with_extra_args_appends() {
        let o = SpecOptions {
            claude: ClaudeOptions {
                skip_permissions: true,
                ..Default::default()
            },
            codex: CodexOptions::default(),
        };
        assert_eq!(
            build_agent_command("claude", &o, "--model=opus"),
            "claude --dangerously-skip-permissions --model=opus"
        );
    }

    #[test]
    fn codex_yolo() {
        let o = SpecOptions {
            codex: CodexOptions { yolo: true },
            ..Default::default()
        };
        assert_eq!(build_agent_command("codex", &o, ""), "codex --yolo");
    }

    #[test]
    fn terminal_ignores_options_runs_args() {
        let o = SpecOptions {
            claude: ClaudeOptions {
                skip_permissions: true,
                ..Default::default()
            },
            ..Default::default()
        };
        assert_eq!(
            build_agent_command("terminal", &o, "vim .zshrc"),
            "vim .zshrc"
        );
        assert_eq!(build_agent_command("terminal", &opts(), ""), "");
    }

    #[test]
    fn launch_without_resume_matches_plain_build() {
        assert_eq!(build_launch_command("claude", &opts(), "", false), "claude");
    }

    #[test]
    fn launch_resume_forces_claude_continue() {
        // Persisted mode is the default (New); the one-shot resume
        // override swaps in `--continue` without touching the options.
        assert_eq!(
            build_launch_command("claude", &opts(), "", true),
            "claude --continue"
        );
    }

    #[test]
    fn launch_resume_keeps_other_claude_flags() {
        let o = SpecOptions {
            claude: ClaudeOptions {
                session_mode: ClaudeSessionMode::New,
                skip_permissions: true,
            },
            ..Default::default()
        };
        assert_eq!(
            build_launch_command("claude", &o, "--model=opus", true),
            "claude --continue --dangerously-skip-permissions --model=opus"
        );
    }

    #[test]
    fn launch_resume_uses_codex_resume_last() {
        assert_eq!(
            build_launch_command("codex", &opts(), "", true),
            "codex resume --last"
        );
    }

    #[test]
    fn launch_resume_codex_keeps_yolo_and_args() {
        let o = SpecOptions {
            codex: CodexOptions { yolo: true },
            ..Default::default()
        };
        assert_eq!(
            build_launch_command("codex", &o, "--model gpt-5", true),
            "codex resume --last --yolo --model gpt-5"
        );
    }

    #[test]
    fn launch_resume_noop_for_terminal() {
        assert_eq!(
            build_launch_command("terminal", &opts(), "vim .zshrc", true),
            "vim .zshrc"
        );
    }

    #[test]
    fn slugify_lowercases_and_dashes() {
        assert_eq!(slugify("My Rocket Fox"), "my-rocket-fox");
        assert_eq!(slugify("Foo.Bar_baz"), "foo-bar_baz");
        assert_eq!(slugify("  leading space"), "leading-space");
        assert_eq!(slugify("multi   spaces"), "multi-spaces");
        assert_eq!(slugify("trailing!!!"), "trailing");
    }

    #[test]
    fn slug_from_internal_strips_prefix_and_hex_suffix() {
        assert_eq!(
            slug_from_internal("bosun-raycast-1e18ae00", "bosun-"),
            Some("raycast")
        );
        assert_eq!(
            slug_from_internal("bosun-my-rocket-fox-a1b2c3d4", "bosun-"),
            Some("my-rocket-fox")
        );
        // Empty prefix (BOSUN_PREFIX="") is allowed.
        assert_eq!(slug_from_internal("raycast-1e18ae00", ""), Some("raycast"));
    }

    #[test]
    fn slug_from_internal_rejects_non_hex_suffix() {
        // Last 8 chars after `-` aren't hex → not bosun-shaped, decline.
        assert_eq!(slug_from_internal("bosun-foo-zzzzzzzz", "bosun-"), None);
        // Suffix is hex but wrong length.
        assert_eq!(slug_from_internal("bosun-foo-abc", "bosun-"), None);
        // No prefix match.
        assert_eq!(slug_from_internal("other-foo-12345678", "bosun-"), None);
    }
}

#[cfg(test)]
mod kill_remove_worktree_tests {
    use super::*;
    use crate::tmux::client::TokioTmuxClient;

    /// Spawn `git -C <dir> <args>`, asserting the command succeeds.
    /// Local copy — `client.rs`'s git_tests helper is private to that
    /// module.
    fn run_git(dir: &std::path::Path, args: &[&str]) {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .output()
            .expect("spawn git");
        assert!(
            out.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&out.stderr)
        );
    }

    /// Create an initialised repo with one empty commit at `dir/repo`.
    fn init_repo(dir: &std::path::Path) -> std::path::PathBuf {
        let repo = dir.join("repo");
        std::fs::create_dir(&repo).unwrap();
        run_git(&repo, &["init", "-q"]);
        run_git(&repo, &["config", "user.email", "t@t"]);
        run_git(&repo, &["config", "user.name", "t"]);
        run_git(&repo, &["commit", "-q", "--allow-empty", "-m", "init"]);
        repo
    }

    #[tokio::test]
    async fn kill_remove_worktree_merge_path_removes_and_merges() {
        let dir = tempfile::tempdir().unwrap();
        let repo = init_repo(dir.path());
        let wt = dir.path().join("wt");
        let client = TokioTmuxClient::new();
        client
            .worktree_add(repo.to_str().unwrap(), "feat", wt.to_str().unwrap())
            .await
            .unwrap();

        // Commit a file on `feat` inside the worktree.
        std::fs::write(wt.join("f.txt"), "data").unwrap();
        run_git(&wt, &["add", "f.txt"]);
        run_git(&wt, &["commit", "-q", "-m", "add f"]);

        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        // `internal` names a tmux session that doesn't exist —
        // kill_session is idempotent, so no tmux server is needed.
        handle_kill_remove_worktree(
            &client,
            "nonexistent-sess",
            wt.to_str().unwrap(),
            "feat",
            true,
            &tx,
        )
        .await;

        // Worktree dir gone.
        assert!(!wt.exists(), "worktree dir should be removed");
        // Branch `feat` gone (merge path deletes it).
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(&repo)
            .args(["branch", "--list", "feat"])
            .output()
            .unwrap();
        assert!(
            String::from_utf8_lossy(&out.stdout).trim().is_empty(),
            "branch feat should be deleted"
        );
        // The feat commit's file is present on the repo's checked-out
        // branch (i.e. it was merged in).
        assert!(repo.join("f.txt").exists(), "feat commit should be merged");
        // No warnings on the happy path.
        assert!(rx.try_recv().is_err(), "no warning expected on success");
    }

    #[tokio::test]
    async fn kill_remove_worktree_conflicted_merge_aborts_and_leaves_repo_clean() {
        let dir = tempfile::tempdir().unwrap();
        let repo = init_repo(dir.path());

        let wt = dir.path().join("wt");
        let client = TokioTmuxClient::new();
        // Branch `feat` from the initial (empty) commit BEFORE either side
        // touches x.txt, so the two sides diverge from a common base that
        // lacks the file → a real content conflict on merge.
        client
            .worktree_add(repo.to_str().unwrap(), "feat", wt.to_str().unwrap())
            .await
            .unwrap();

        // Main side commits x.txt one way...
        std::fs::write(repo.join("x.txt"), "main-side\n").unwrap();
        run_git(&repo, &["add", "x.txt"]);
        run_git(&repo, &["commit", "-q", "-m", "main x"]);
        // ...and the feat worktree commits the same path differently.
        std::fs::write(wt.join("x.txt"), "feat-side\n").unwrap();
        run_git(&wt, &["add", "x.txt"]);
        run_git(&wt, &["commit", "-q", "-m", "feat x"]);

        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        handle_kill_remove_worktree(
            &client,
            "nonexistent-sess",
            wt.to_str().unwrap(),
            "feat",
            true,
            &tx,
        )
        .await;

        // A Warn about the merge was surfaced.
        match rx.try_recv() {
            Ok(AppMsg::Warn(msg)) => {
                assert!(
                    msg.contains("merge"),
                    "warning should mention the merge: {msg}"
                );
            }
            other => panic!("expected a merge Warn, got {other:?}"),
        }
        // The worktree is left intact — a failed merge must not remove it.
        assert!(wt.exists(), "worktree must survive a conflicted merge");
        // The main repo must NOT be stuck mid-merge: no MERGE_HEAD and a
        // clean working tree (the abort restored the pre-merge state).
        assert!(
            !repo.join(".git").join("MERGE_HEAD").exists(),
            "MERGE_HEAD must be gone — merge should have been aborted"
        );
        let status = std::process::Command::new("git")
            .arg("-C")
            .arg(&repo)
            .args(["status", "--porcelain"])
            .output()
            .unwrap();
        assert!(
            String::from_utf8_lossy(&status.stdout).trim().is_empty(),
            "main repo working tree must be clean after abort"
        );
    }

    #[tokio::test]
    async fn kill_remove_worktree_dirty_tree_is_left_intact() {
        let dir = tempfile::tempdir().unwrap();
        let repo = init_repo(dir.path());
        let wt = dir.path().join("wt");
        let client = TokioTmuxClient::new();
        client
            .worktree_add(repo.to_str().unwrap(), "feat", wt.to_str().unwrap())
            .await
            .unwrap();

        // Dirty the worktree with an untracked file → is_dirty true.
        std::fs::write(wt.join("scratch.txt"), "wip").unwrap();

        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        handle_kill_remove_worktree(
            &client,
            "nonexistent-sess",
            wt.to_str().unwrap(),
            "feat",
            false,
            &tx,
        )
        .await;

        // Removal refused → worktree dir still exists.
        assert!(wt.exists(), "dirty worktree must be left intact");
        // A Warn was surfaced.
        match rx.try_recv() {
            Ok(AppMsg::Warn(msg)) => {
                assert!(
                    msg.contains("uncommitted"),
                    "warning should mention uncommitted changes: {msg}"
                );
            }
            other => panic!("expected a Warn, got {other:?}"),
        }
    }
}

#[cfg(test)]
mod worktree_tests {
    use super::*;
    use crate::config::WorktreeLocation;

    fn minimal_spec() -> SessionSpec {
        SessionSpec {
            name: "api".into(),
            path: "/srv/api".into(),
            agent: "claude".into(),
            args: String::new(),
            options: SpecOptions::default(),
            container_id: None,
            resume: false,
            worktree: None,
        }
    }

    #[test]
    fn spec_to_metadata_carries_worktree() {
        let mut spec = minimal_spec();
        spec.worktree = Some(crate::events::WorktreeSpec {
            branch: "feat".into(),
        });
        // path is set by the actor to the resolved worktree path before
        // persist; here it's the spec's path. Both halves of the derivation
        // must round-trip: branch from the WorktreeSpec, worktree_path from
        // spec.path (only when a worktree was requested).
        let meta = spec_to_metadata(&spec);
        assert_eq!(meta.branch.as_deref(), Some("feat"));
        assert_eq!(meta.worktree_path.as_deref(), Some("/srv/api"));
    }

    #[test]
    fn resolve_worktree_path_subdir_and_sibling() {
        assert_eq!(
            resolve_worktree_path("/srv/proj", "feat", WorktreeLocation::Subdir),
            "/srv/proj/.worktrees/feat"
        );
        assert_eq!(
            resolve_worktree_path("/srv/proj", "feat", WorktreeLocation::Sibling),
            "/srv/proj-feat"
        );
        // A trailing slash on the repo root is normalized away.
        assert_eq!(
            resolve_worktree_path("/srv/proj/", "feat", WorktreeLocation::Subdir),
            "/srv/proj/.worktrees/feat"
        );
    }
}
