//! Matrix on_room_message handler — receives an incoming Matrix room event and
//! dispatches it to handle_message after access checks and command routing.

use crate::chat::util::is_permission_approval;
use crate::http::context::PermissionDecision;
use crate::slog;
use matrix_sdk::{
    Client,
    event_handler::Ctx,
    room::Room,
    ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
};
use std::sync::Arc;

use super::super::context::BotContext;
use super::super::format::markdown_to_html;
use super::super::mentions::{is_addressed_to_other, is_reply_to_bot, mentions_bot};
use super::super::verification::check_sender_verified;

use super::handle_message;

/// Return `true` when the message is a `health` command addressed to the bot.
///
/// Recognised case-insensitively as the single word `health` after stripping the bot
/// mention prefix.  Any trailing whitespace is ignored; extra arguments are not
/// expected and are silently discarded.
fn extract_health_command(message: &str, bot_name: &str, bot_user_id: &str) -> bool {
    let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
    let trimmed = stripped
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric());
    let cmd = trimmed.split_whitespace().next().unwrap_or("");
    cmd.eq_ignore_ascii_case("health")
}

/// Return `true` when the message is a `projects` command addressed to the bot.
///
/// Recognised case-insensitively as the single word `projects` after stripping the bot
/// mention prefix.  Any trailing whitespace is ignored.
fn extract_projects_command(message: &str, bot_name: &str, bot_user_id: &str) -> bool {
    let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
    let trimmed = stripped
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric());
    let cmd = trimmed.split_whitespace().next().unwrap_or("");
    cmd.eq_ignore_ascii_case("projects")
}

/// Return `true` when the message is a "rebuild gateway" command addressed to the bot.
///
/// The command is recognised case-insensitively as `rebuild gateway` after stripping
/// the bot mention prefix so both `@Timmy rebuild gateway` and `Timmy rebuild gateway`
/// match.
fn extract_rebuild_gateway_command(message: &str, bot_name: &str, bot_user_id: &str) -> bool {
    let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
    let trimmed = stripped
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric());
    let (cmd, rest) = match trimmed.split_once(char::is_whitespace) {
        Some((c, r)) => (c, r.trim()),
        None => return false,
    };
    cmd.eq_ignore_ascii_case("rebuild")
        && rest
            .split_whitespace()
            .next()
            .map(|w| w.eq_ignore_ascii_case("gateway"))
            .unwrap_or(false)
}

/// Evaluate a `status <arg>` command in gateway mode, routing it to the
/// right project before falling back to the generic per-command proxy.
///
/// - Empty arg: proxies bare `status` to the active project (pipeline status).
/// - All-digit arg: resolves locally across *every* registered project's
///   pipeline (story 1203) — a story number is looked up wherever it
///   actually lives, not forwarded blindly to whichever project happens to
///   be active. This avoids "active-project drift": switching projects must
///   not change what `status <n>` reports for an existing story.
/// - Arg matching a known project name: proxies bare `status` to *that*
///   project rather than whichever one is active.
/// - Anything else: a clear error listing the valid project names.
async fn eval_gateway_status_command(
    arg: &str,
    active_project: &tokio::sync::RwLock<String>,
    store: &tokio::sync::RwLock<
        std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
    >,
) -> String {
    const NO_ACTIVE_PROJECT: &str = "No active project selected or project URL not configured.";

    if arg.is_empty() {
        let name = active_project.read().await.clone();
        let url = store.read().await.get(&name).and_then(|e| e.url.clone());
        return match url {
            Some(url) => {
                super::super::context::BotContext::run_proxy_bot_command(&url, "status", arg).await
            }
            None => NO_ACTIVE_PROJECT.to_string(),
        };
    }

    if arg.chars().all(|c| c.is_ascii_digit()) {
        return eval_gateway_numeric_status_lookup(arg, store).await;
    }

    let known: Vec<String> = store.read().await.keys().cloned().collect();
    if known.iter().any(|p| p == arg) {
        let url = store.read().await.get(arg).and_then(|e| e.url.clone());
        return match url {
            Some(url) => {
                super::super::context::BotContext::run_proxy_bot_command(&url, "status", "").await
            }
            None => format!("Project `{arg}` has no URL configured."),
        };
    }

    let available = known.join(", ");
    format!("Unknown project or story number `{arg}`. Available projects: {available}")
}

/// Resolve a numeric `status <n>` lookup locally across every registered
/// project (story 1203), rather than trusting whichever one is "active".
///
/// Fetches pipeline items from every project with a configured URL, finds
/// the one whose pipeline actually contains story `n`, and proxies `status
/// <n>` to that project so the caller gets the full triage card (ACs,
/// worktree, diff, commits) — not just a thin pipeline-item summary.
/// Returns a clear message when no project has a URL configured or no
/// registered project contains the story.
async fn eval_gateway_numeric_status_lookup(
    num_str: &str,
    store: &tokio::sync::RwLock<
        std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
    >,
) -> String {
    let project_urls: std::collections::BTreeMap<String, String> = store
        .read()
        .await
        .iter()
        .filter_map(|(name, entry)| entry.url.clone().map(|url| (name.clone(), url)))
        .collect();

    if project_urls.is_empty() {
        return "No registered projects have a URL configured — cannot resolve story lookups."
            .to_string();
    }

    let client = reqwest::Client::new();
    let items = crate::gateway::fetch_all_project_pipeline_items(&project_urls, &client).await;

    match crate::service::gateway::find_project_containing_story(&items, num_str) {
        Some(project_name) => {
            let url = project_urls.get(&project_name).cloned().unwrap_or_default();
            super::super::context::BotContext::run_proxy_bot_command(&url, "status", num_str).await
        }
        None => {
            let unreachable: Vec<&str> = items
                .iter()
                .filter(|(_, v)| v.get("error").is_some())
                .map(|(name, _)| name.as_str())
                .collect();
            if unreachable.is_empty() {
                format!("Story **{num_str}** not found in any registered project.")
            } else {
                format!(
                    "Story **{num_str}** not found in any registered project. Unreachable: {}.",
                    unreachable.join(", ")
                )
            }
        }
    }
}

/// Evaluate a gateway-local `overview` command (story 1193).
///
/// - Non-empty `args`: unchanged legacy behavior — proxies `overview <args>`
///   to the active project's own per-story overview handler.
/// - Empty `args`: fetches active pipeline items from every registered
///   project and renders a compact cross-sled overview of In Progress / QA /
///   Merge work. Projects with no configured URL are surfaced as
///   unreachable rather than silently dropped, matching AC 2.
async fn eval_gateway_overview_command(
    args: &str,
    active_project: &tokio::sync::RwLock<String>,
    store: &tokio::sync::RwLock<
        std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
    >,
) -> String {
    if !args.is_empty() {
        let name = active_project.read().await.clone();
        let url = store.read().await.get(&name).and_then(|e| e.url.clone());
        return match url {
            Some(url) => BotContext::run_proxy_bot_command(&url, "overview", args).await,
            None => "No active project selected or project URL not configured.".to_string(),
        };
    }

    let entries: Vec<(String, Option<String>)> = store
        .read()
        .await
        .iter()
        .map(|(name, entry)| (name.clone(), entry.url.clone()))
        .collect();

    let with_url: std::collections::BTreeMap<String, String> = entries
        .iter()
        .filter_map(|(name, url)| url.clone().map(|u| (name.clone(), u)))
        .collect();

    let client = reqwest::Client::new();
    let mut items_by_project =
        crate::gateway::fetch_all_project_pipeline_items(&with_url, &client).await;
    for (name, url) in &entries {
        if url.is_none() {
            items_by_project
                .entry(name.clone())
                .or_insert_with(|| serde_json::json!({ "error": "no URL configured" }));
        }
    }

    crate::gateway::format_overview_compact(&items_by_project)
}

/// Evaluate a `switch <arg>` command against the live project store.
///
/// Reads valid project names from the store at call time so newly added
/// projects are visible without a bot restart.  Returns the reply text.
pub(super) async fn eval_switch_command(
    arg: &str,
    active_project: &tokio::sync::RwLock<String>,
    store: &tokio::sync::RwLock<
        std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
    >,
) -> String {
    let projects: Vec<String> = store.read().await.keys().cloned().collect();
    if arg.is_empty() {
        let available = projects.join(", ");
        format!("Usage: `switch <project>`. Available projects: {available}")
    } else if projects.iter().any(|p| p == arg) {
        *active_project.write().await = arg.to_string();
        crate::crdt_state::write_gateway_active_project(arg);
        format!("Switched to project **{arg}**.")
    } else {
        let available = projects.join(", ");
        format!("Unknown project `{arg}`. Available: {available}")
    }
}

/// Attempt to handle an addressed message as a `compact` command.
///
/// Returns `true` when `user_message` was recognised as `compact` and the
/// real handler ran (a reply was sent via `ctx.transport`, clearing the
/// room's session and setting `pending_seed`). Returns `false` so the caller
/// falls through to the sync command registry and, eventually, the LLM.
///
/// Must be called before the registry dispatch (`try_handle_command_with_html`):
/// the registry's `compact` entry (`handle_compact_fallback`) always returns
/// `None` so `help` can list it, which means this call site — not the
/// registry — is what makes `compact` reachable. Extracted into its own
/// function so it is directly testable without a live Matrix `Room`/`Client`.
async fn try_handle_compact_command(
    ctx: &BotContext,
    sender: &str,
    user_message: &str,
    room_id_str: &str,
    incoming_room_id: &matrix_sdk::ruma::OwnedRoomId,
) -> bool {
    if super::super::super::compact::extract_compact_command(
        user_message,
        &ctx.services.bot_name,
        ctx.matrix_user_id.as_str(),
    )
    .is_none()
    {
        return false;
    }
    slog!("[matrix-bot] Handling compact command from {sender}");
    let response = super::super::super::compact::handle_compact(
        incoming_room_id,
        &ctx.history,
        &ctx.services.project_root,
        ctx.compact_seed_max_bytes,
    )
    .await;
    let html = markdown_to_html(&response);
    if let Ok(msg_id) = ctx
        .transport
        .send_message(room_id_str, &response, &html)
        .await
        && let Ok(event_id) = msg_id.parse()
    {
        ctx.bot_sent_event_ids.lock().await.insert(event_id);
    }
    true
}

pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
    ev: OriginalSyncRoomMessageEvent,
    room: Room,
    client: Client,
    Ctx(ctx): Ctx<BotContext>,
) {
    let incoming_room_id = room.room_id().to_owned();

    slog!(
        "[matrix-bot] Event received: room={} sender={}",
        incoming_room_id,
        ev.sender,
    );

    // Deduplicate: the Matrix sync loop replays events on reconnect. Insert the
    // event_id before any side-effecting work; return early if already handled.
    {
        let mut seen = ctx.handled_incoming_event_ids.lock().await;
        if !seen.insert(ev.event_id.clone()) {
            slog!(
                "[matrix-bot] Dropping duplicate event {} from {}",
                ev.event_id,
                ev.sender
            );
            return;
        }
    }

    // Only handle messages from rooms we are configured to listen in.
    if !ctx.target_room_ids.iter().any(|r| r == &incoming_room_id) {
        slog!("[matrix-bot] Ignoring message from unconfigured room {incoming_room_id}");
        return;
    }

    // Update last-event timestamp so the `health` command can detect a stale sync loop.
    ctx.last_matrix_event_ms.store(
        chrono::Utc::now().timestamp_millis(),
        std::sync::atomic::Ordering::Relaxed,
    );

    // Ignore the bot's own messages to prevent echo loops.
    if ev.sender == ctx.matrix_user_id {
        return;
    }

    // Only respond to users on the allowlist (fail-closed).
    if !ctx.allowed_users.iter().any(|u| u == ev.sender.as_str()) {
        slog!(
            "[matrix-bot] Ignoring message from unauthorised user: {}",
            ev.sender
        );
        return;
    }

    // Only handle plain text messages.
    let (body, formatted_body) = match &ev.content.msgtype {
        MessageType::Text(t) => (t.body.clone(), t.formatted.as_ref().map(|f| f.body.clone())),
        _ => return,
    };

    // Only respond when the bot is directly addressed (mentioned by name/ID),
    // when the message is a reply to one of the bot's own messages, or when
    // ambient mode is enabled for this room.
    let is_addressed = mentions_bot(&body, formatted_body.as_deref(), &ctx.matrix_user_id)
        || is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await;
    let room_id_str = incoming_room_id.to_string();
    let is_ambient = ctx
        .services
        .ambient_rooms
        .lock()
        .unwrap()
        .contains(&room_id_str);

    // Always let "ambient on" through — it is the one command that must work
    // even when the bot is not mentioned and ambient mode is off, otherwise
    // there is no way to re-enable ambient mode without an @-mention.
    let is_ambient_on = body.to_ascii_lowercase().contains("ambient on");

    if !is_addressed && !is_ambient && !is_ambient_on {
        slog!(
            "[matrix-bot] Ignoring unaddressed message from {}",
            ev.sender
        );
        return;
    }

    // In ambient mode, ignore messages that are explicitly addressed to a
    // different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
    // We still let through messages addressed to us and the "ambient on" command.
    if is_ambient
        && !is_addressed
        && !is_ambient_on
        && is_addressed_to_other(&body, &ctx.matrix_user_id, &ctx.services.bot_name)
    {
        slog!(
            "[matrix-bot] Ignoring ambient message addressed to another bot (sender={})",
            ev.sender
        );
        return;
    }

    // Reject commands from unencrypted rooms — E2EE is mandatory.
    if !room.encryption_state().is_encrypted() {
        slog!(
            "[matrix-bot] Rejecting message from {} — room {} is not encrypted. \
             Commands are only accepted from encrypted rooms.",
            ev.sender,
            incoming_room_id
        );
        return;
    }

    // Always verify that the sender has a cross-signing identity.
    // This check is unconditional and cannot be disabled via config.
    match check_sender_verified(&client, &ev.sender).await {
        Ok(true) => { /* sender has a cross-signing identity — proceed */ }
        Ok(false) => {
            slog!(
                "[matrix-bot] Rejecting message from {} — no cross-signing identity \
                 found in encrypted room {}",
                ev.sender,
                incoming_room_id
            );
            return;
        }
        Err(e) => {
            slog!(
                "[matrix-bot] Error checking verification for {}: {e} — \
                 rejecting message (fail-closed)",
                ev.sender
            );
            return;
        }
    }

    // If there is a pending permission prompt for this room, interpret the
    // message as a yes/no response instead of starting a new chat.
    {
        if let Some(tx) = ctx
            .services
            .pending_perm_replies
            .resolve_oldest(incoming_room_id.as_str())
            .await
        {
            let decision = if is_permission_approval(&body) {
                PermissionDecision::Approve
            } else {
                PermissionDecision::Deny
            };
            let _ = tx.send(decision);
            let confirmation = if decision == PermissionDecision::Approve {
                "Permission approved."
            } else {
                "Permission denied."
            };
            let html = markdown_to_html(confirmation);
            if let Ok(msg_id) = ctx
                .transport
                .send_message(&room_id_str, confirmation, &html)
                .await
                && let Ok(event_id) = msg_id.parse()
            {
                ctx.bot_sent_event_ids.lock().await.insert(event_id);
            }
            return;
        }
    }

    let sender = ev.sender.to_string();
    let user_message = body;
    slog!("[matrix-bot] Message from {sender}: {user_message}");

    // In gateway mode, resolve commands against the active project's root directory.
    // The gateway's own project_root is the gateway config dir; each project lives in
    // a subdirectory named after the project. Standalone mode is unaffected.
    let effective_root = ctx.effective_project_root().await;

    // ── Gateway command proxy ───────────────────────────────────────────
    // In gateway mode the bot has no local CRDT or project filesystem, so most
    // commands must be forwarded to the active project's `/api/bot/command`
    // endpoint.  Only a small set of gateway-local commands are handled here.
    if ctx.is_gateway() {
        // Commands that are meaningful on the gateway itself (no project state needed).
        const GATEWAY_LOCAL_COMMANDS: &[&str] = &[
            "help",
            "ambient",
            "reset",
            "switch",
            "all_status",
            "new",
            "config",
            "project-rebuild",
            "upgrade",
            "health",
            "projects",
            "overview",
        ];

        let stripped = crate::chat::util::strip_bot_mention(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric())
        .to_string();

        let (cmd, args) = match stripped.split_once(char::is_whitespace) {
            Some((c, a)) => (c.to_ascii_lowercase(), a.trim().to_string()),
            None => (stripped.to_ascii_lowercase(), String::new()),
        };

        // `status <arg>` is special-cased ahead of the generic per-command proxy
        // below so `<arg>` can resolve to a *named* project rather than always
        // being forwarded to whichever project is currently active.
        if cmd == "status" {
            slog!("[matrix-bot] Handling 'status {args}' from {sender}");
            let response = match (&ctx.gateway_active_project, &ctx.gateway_projects_store) {
                (Some(active_project), Some(store)) => {
                    eval_gateway_status_command(&args, active_project, store).await
                }
                _ => "Gateway projects store unavailable.".to_string(),
            };
            let html = markdown_to_html(&response);
            if let Ok(msg_id) = ctx
                .transport
                .send_message(&room_id_str, &response, &html)
                .await
                && let Ok(event_id) = msg_id.parse()
            {
                ctx.bot_sent_event_ids.lock().await.insert(event_id);
            }
            return;
        }

        // `overview` (bare) is special-cased ahead of the generic per-command
        // proxy below so it can render a cross-sled overview locally instead
        // of proxying to a single project (story 1193). `overview <arg>`
        // still proxies, preserving the existing per-story overview handler.
        if cmd == "overview" {
            slog!("[matrix-bot] Handling 'overview {args}' from {sender}");
            let response = match (&ctx.gateway_active_project, &ctx.gateway_projects_store) {
                (Some(active_project), Some(store)) => {
                    eval_gateway_overview_command(&args, active_project, store).await
                }
                _ => "Gateway projects store unavailable.".to_string(),
            };
            let html = markdown_to_html(&response);
            if let Ok(msg_id) = ctx
                .transport
                .send_message(&room_id_str, &response, &html)
                .await
                && let Ok(event_id) = msg_id.parse()
            {
                ctx.bot_sent_event_ids.lock().await.insert(event_id);
            }
            return;
        }

        // Only proxy if the first word is a known bot command (sync or async).
        let is_known_command = !cmd.is_empty()
            && !GATEWAY_LOCAL_COMMANDS.contains(&cmd.as_str())
            && (crate::chat::commands::commands()
                .iter()
                .any(|c| c.name == cmd)
                || ["assign", "start", "delete", "rmtree", "htop", "timer"]
                    .contains(&cmd.as_str()));

        if is_known_command {
            // Proxy to the active project server.
            let response = match ctx.proxy_bot_command(&cmd, &args).await {
                Some(r) => r,
                None => "No active project selected or project URL not configured.".to_string(),
            };
            let html = markdown_to_html(&response);
            if let Ok(msg_id) = ctx
                .transport
                .send_message(&room_id_str, &response, &html)
                .await
                && let Ok(event_id) = msg_id.parse()
            {
                ctx.bot_sent_event_ids.lock().await.insert(event_id);
            }
            return;
        }

        // `all_status` — aggregate pipeline status across all projects (gateway-only).
        if cmd == "all_status" {
            let project_urls: std::collections::BTreeMap<String, String> = if let Some(ref store) =
                ctx.gateway_projects_store
            {
                store
                    .read()
                    .await
                    .iter()
                    .filter_map(|(name, entry)| entry.url.clone().map(|url| (name.clone(), url)))
                    .collect()
            } else {
                std::collections::BTreeMap::new()
            };
            let client = reqwest::Client::new();
            let statuses =
                crate::gateway::fetch_all_project_pipeline_statuses(&project_urls, &client).await;
            let response = crate::gateway::format_aggregate_status_compact(&statuses);
            let html = markdown_to_html(&response);
            if let Ok(msg_id) = ctx
                .transport
                .send_message(&room_id_str, &response, &html)
                .await
                && let Ok(event_id) = msg_id.parse()
            {
                ctx.bot_sent_event_ids.lock().await.insert(event_id);
            }
            return;
        }

        // `config <project> <key>=<value>` — override an agent or project setting.
        if cmd == "config" {
            let response = if let Some(ref store) = ctx.gateway_projects_store {
                // Parse: "<project> <key>=<value>"
                let mut parts = args.splitn(2, char::is_whitespace);
                let project = parts.next().unwrap_or("").trim();
                let setting = parts.next().unwrap_or("").trim();
                if project.is_empty() || setting.is_empty() {
                    "Usage: `config <project> <key>=<value>`\n\
                     Examples:\n\
                     - `config myapp coder.model=opus`\n\
                     - `config myapp default_qa=human`"
                        .to_string()
                } else {
                    match setting.split_once('=') {
                        None => {
                            "Usage: setting must be in `key=value` form, e.g. `coder.model=opus`"
                                .to_string()
                        }
                        Some((key, value)) => {
                            let host_path_opt = {
                                let projects = store.read().await;
                                projects.get(project).and_then(|e| e.host_path.clone())
                            };
                            match host_path_opt {
                                None => format!(
                                    "Project `{project}` not found or has no host path configured."
                                ),
                                Some(path) => {
                                    match super::super::super::new_project::apply_project_config(
                                        std::path::Path::new(&path),
                                        key.trim(),
                                        value.trim(),
                                    ) {
                                        Ok(msg) => msg,
                                        Err(e) => format!("Config error: {e}"),
                                    }
                                }
                            }
                        }
                    }
                }
            } else {
                "Gateway projects store unavailable.".to_string()
            };
            let html = markdown_to_html(&response);
            if let Ok(msg_id) = ctx
                .transport
                .send_message(&room_id_str, &response, &html)
                .await
                && let Ok(event_id) = msg_id.parse()
            {
                ctx.bot_sent_event_ids.lock().await.insert(event_id);
            }
            return;
        }

        // Gateway-local commands and freeform text fall through to normal handling below.
    }

    // In gateway mode, handle the "new project <name> [--stack <stack>]" command
    // to bootstrap a project container and register it with the gateway.
    if ctx.is_gateway()
        && let Some(cmd) = super::super::super::new_project::extract_new_project_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        slog!(
            "[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?} git_url={:?} adopt_path={:?}",
            cmd.name,
            cmd.stack,
            cmd.git_url,
            cmd.adopt_path,
        );
        let response = if let Some(ref store) = ctx.gateway_projects_store {
            super::super::super::new_project::handle_new_project(
                &cmd.name,
                cmd.stack.as_deref(),
                cmd.git_url.as_deref(),
                cmd.git_token.as_deref(),
                cmd.host_path.as_deref(),
                cmd.adopt_path.as_deref(),
                cmd.skip_config,
                store,
                &ctx.services.project_root,
            )
            .await
        } else {
            "Gateway projects store unavailable — cannot create project.".to_string()
        };
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // In gateway mode, handle the `project-rebuild <name>` command to rebuild a
    // project container and swap it without losing pipeline state.
    if ctx.is_gateway()
        && let Some(rebuild_cmd) =
            super::super::super::project_rebuild::extract_project_rebuild_command(
                &user_message,
                &ctx.services.bot_name,
                ctx.matrix_user_id.as_str(),
            )
    {
        slog!(
            "[matrix-bot] Handling project-rebuild command from {sender}: name={:?} timeout={}s force={}",
            rebuild_cmd.name,
            rebuild_cmd.drain_timeout_secs,
            rebuild_cmd.force,
        );
        let response = if let Some(ref store) = ctx.gateway_projects_store {
            super::super::super::project_rebuild::handle_project_rebuild(
                &rebuild_cmd.name,
                rebuild_cmd.drain_timeout_secs,
                rebuild_cmd.force,
                store,
                &ctx.services.project_root,
            )
            .await
        } else {
            "Gateway projects store unavailable — cannot rebuild project.".to_string()
        };
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // In gateway mode, handle the `release` command: build the sled binary in
    // the builder container and publish it to the artifact store.
    if ctx.is_gateway()
        && super::super::super::release::extract_release_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        slog!("[matrix-bot] Handling 'release' from {sender}");
        let response = if let Some(ref store) = ctx.gateway_projects_store {
            let transport = Arc::clone(&ctx.transport);
            let bot_sent = Arc::clone(&ctx.bot_sent_event_ids);
            let room = room_id_str.clone();
            super::super::super::release::handle_release(store, |phase_msg| {
                let transport = Arc::clone(&transport);
                let bot_sent = Arc::clone(&bot_sent);
                let room = room.clone();
                async move {
                    let html = markdown_to_html(&phase_msg);
                    if let Ok(msg_id) = transport.send_message(&room, &phase_msg, &html).await
                        && let Ok(event_id) = msg_id.parse()
                    {
                        bot_sent.lock().await.insert(event_id);
                    }
                }
            })
            .await
        } else {
            "Gateway projects store unavailable — cannot release.".to_string()
        };
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // In gateway mode, handle the `pull <channel>` command: fetch, verify, and
    // install a signed release-channel artifact into the artifact store.
    if ctx.is_gateway()
        && let Some(channel) = super::super::super::pull::extract_pull_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        slog!("[matrix-bot] Handling 'pull {channel}' from {sender}");
        let response = if let Some(ref store) = ctx.gateway_channels_store {
            super::super::super::pull::handle_pull(&channel, store).await
        } else {
            "Gateway release channels unavailable — cannot pull.".to_string()
        };
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // In gateway mode, handle the `upgrade [<project>]` command to upgrade a
    // sled's binary in-container, streaming phase markers to the room.
    if ctx.is_gateway()
        && let Some(upgrade_cmd) = super::super::super::sled_upgrade::extract_upgrade_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        match upgrade_cmd {
            super::super::super::sled_upgrade::UpgradeCommand::ListProjects => {
                slog!("[matrix-bot] Handling 'upgrade' list-projects from {sender}");
                let response = if let Some(ref store) = ctx.gateway_projects_store {
                    super::super::super::sled_upgrade::handle_upgrade_list_projects(store).await
                } else {
                    "Gateway projects store unavailable.".to_string()
                };
                let html = markdown_to_html(&response);
                if let Ok(msg_id) = ctx
                    .transport
                    .send_message(&room_id_str, &response, &html)
                    .await
                    && let Ok(event_id) = msg_id.parse()
                {
                    ctx.bot_sent_event_ids.lock().await.insert(event_id);
                }
            }
            super::super::super::sled_upgrade::UpgradeCommand::Upgrade { project } => {
                slog!("[matrix-bot] Handling 'upgrade {project}' from {sender}");
                if let Some(ref store) = ctx.gateway_projects_store {
                    let transport = Arc::clone(&ctx.transport);
                    let bot_sent = Arc::clone(&ctx.bot_sent_event_ids);
                    let room = room_id_str.clone();

                    let outcome = super::super::super::sled_upgrade::handle_sled_upgrade(
                        &project,
                        store,
                        ctx.gateway_port,
                        &ctx.services.project_root,
                        |phase_msg| {
                            let transport = Arc::clone(&transport);
                            let bot_sent = Arc::clone(&bot_sent);
                            let room = room.clone();
                            async move {
                                let html = markdown_to_html(&phase_msg);
                                if let Ok(msg_id) =
                                    transport.send_message(&room, &phase_msg, &html).await
                                    && let Ok(event_id) = msg_id.parse()
                                {
                                    bot_sent.lock().await.insert(event_id);
                                }
                            }
                        },
                    )
                    .await;
                    let response = format!("{project}: {outcome}");

                    let html = markdown_to_html(&response);
                    if let Ok(msg_id) = ctx
                        .transport
                        .send_message(&room_id_str, &response, &html)
                        .await
                        && let Ok(event_id) = msg_id.parse()
                    {
                        ctx.bot_sent_event_ids.lock().await.insert(event_id);
                    }
                } else {
                    let msg = "Gateway projects store unavailable — cannot upgrade sled.";
                    let html = markdown_to_html(msg);
                    if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, msg, &html).await
                        && let Ok(event_id) = msg_id.parse()
                    {
                        ctx.bot_sent_event_ids.lock().await.insert(event_id);
                    }
                }
            }
            super::super::super::sled_upgrade::UpgradeCommand::UpgradeAll => {
                slog!("[matrix-bot] Handling 'upgrade all' from {sender}");
                if let Some(ref store) = ctx.gateway_projects_store {
                    let transport = Arc::clone(&ctx.transport);
                    let bot_sent = Arc::clone(&ctx.bot_sent_event_ids);
                    let room = room_id_str.clone();

                    let response = super::super::super::sled_upgrade::handle_upgrade_all(
                        store,
                        ctx.gateway_port,
                        &ctx.services.project_root,
                        |phase_msg| {
                            let transport = Arc::clone(&transport);
                            let bot_sent = Arc::clone(&bot_sent);
                            let room = room.clone();
                            async move {
                                let html = markdown_to_html(&phase_msg);
                                if let Ok(msg_id) =
                                    transport.send_message(&room, &phase_msg, &html).await
                                    && let Ok(event_id) = msg_id.parse()
                                {
                                    bot_sent.lock().await.insert(event_id);
                                }
                            }
                        },
                    )
                    .await;

                    let html = markdown_to_html(&response);
                    if let Ok(msg_id) = ctx
                        .transport
                        .send_message(&room_id_str, &response, &html)
                        .await
                        && let Ok(event_id) = msg_id.parse()
                    {
                        ctx.bot_sent_event_ids.lock().await.insert(event_id);
                    }
                } else {
                    let msg = "Gateway projects store unavailable — cannot upgrade sleds.";
                    let html = markdown_to_html(msg);
                    if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, msg, &html).await
                        && let Ok(event_id) = msg_id.parse()
                    {
                        ctx.bot_sent_event_ids.lock().await.insert(event_id);
                    }
                }
            }
        }
        return;
    }

    // `health` — async subsystem health report (gateway + standalone).
    if extract_health_command(
        &user_message,
        &ctx.services.bot_name,
        ctx.matrix_user_id.as_str(),
    ) {
        slog!("[matrix-bot] Handling 'health' from {sender}");
        let response = super::super::super::health::run_health_check(&ctx).await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // `projects` — gateway-only project enumeration (no liveness checks).
    if ctx.is_gateway()
        && extract_projects_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        slog!("[matrix-bot] Handling 'projects' from {sender}");
        let response = super::super::super::projects::run_projects_list(&ctx).await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // Check for the compact command, which requires async access to the
    // shared conversation history and cannot be handled by the sync command
    // registry. Must run before the registry dispatch below — the registry's
    // `compact` entry exists only so `help` lists it and always returns
    // `None`, so placement here (not after) is what makes this reachable.
    if try_handle_compact_command(
        &ctx,
        &sender,
        &user_message,
        &room_id_str,
        &incoming_room_id,
    )
    .await
    {
        return;
    }

    // Check for bot-level commands (help, status, ambient, …) before invoking
    // the LLM.  All commands are registered in commands.rs — no special-casing
    // needed here.
    let dispatch = super::super::super::commands::CommandDispatch {
        services: &ctx.services,
        project_root: &effective_root,
        bot_user_id: ctx.matrix_user_id.as_str(),
        room_id: &room_id_str,
    };
    if let Some((response, response_html)) =
        super::super::super::commands::try_handle_command_with_html(&dispatch, &user_message)
    {
        slog!("[matrix-bot] Handled bot command from {sender}");
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &response_html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // Check for the assign command, which requires async agent ops (stop +
    // start) and cannot be handled by the sync command registry.
    // Only handle the well-formed variant; BadArgs falls through to the LLM.
    if let Some(super::super::super::assign::AssignCommand::Assign {
        story_number,
        model,
    }) = super::super::super::assign::extract_assign_command(
        &user_message,
        &ctx.services.bot_name,
        ctx.matrix_user_id.as_str(),
    ) {
        slog!(
            "[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}"
        );
        let response = super::super::super::assign::handle_assign(
            &ctx.services.bot_name,
            &story_number,
            &model,
            &effective_root,
            &ctx.services.agents,
        )
        .await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // Check for the htop command, which requires async Matrix access (Room)
    // and cannot be handled by the sync command registry.
    if let Some(htop_cmd) = super::super::super::htop::extract_htop_command(
        &user_message,
        &ctx.services.bot_name,
        ctx.matrix_user_id.as_str(),
    ) {
        slog!("[matrix-bot] Handling htop command from {sender}: {htop_cmd:?}");
        match htop_cmd {
            super::super::super::htop::HtopCommand::Stop => {
                super::super::super::htop::handle_htop_stop(
                    &*ctx.transport,
                    &room_id_str,
                    &ctx.htop_sessions,
                )
                .await;
            }
            super::super::super::htop::HtopCommand::Start { duration_secs } => {
                super::super::super::htop::handle_htop_start(
                    &ctx.transport,
                    &room_id_str,
                    &ctx.htop_sessions,
                    Arc::clone(&ctx.services.agents),
                    duration_secs,
                )
                .await;
            }
        }
        return;
    }

    // Check for the delete command, which requires async agent/worktree ops
    // and cannot be handled by the sync command registry.
    // Only handle the well-formed variant; BadArgs falls through to the LLM.
    if let Some(super::super::super::delete::DeleteCommand::Delete { story_number }) =
        super::super::super::delete::extract_delete_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
        let response = super::super::super::delete::handle_delete(
            &ctx.services.bot_name,
            &story_number,
            &effective_root,
            &ctx.services.agents,
        )
        .await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // Check for the rmtree command, which requires async agent/worktree ops
    // and cannot be handled by the sync command registry.
    // Only handle the well-formed variant; BadArgs falls through to the LLM.
    if let Some(super::super::super::rmtree::RmtreeCommand::Rmtree { story_number }) =
        super::super::super::rmtree::extract_rmtree_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
        let response = super::super::super::rmtree::handle_rmtree(
            &ctx.services.bot_name,
            &story_number,
            &effective_root,
            &ctx.services.agents,
        )
        .await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // Check for the start command, which requires async agent ops and cannot
    // be handled by the sync command registry.
    // Only handle the well-formed variant; BadArgs falls through to the LLM.
    if let Some(super::super::super::start::StartCommand::Start {
        story_number,
        agent_hint,
    }) = super::super::super::start::extract_start_command(
        &user_message,
        &ctx.services.bot_name,
        ctx.matrix_user_id.as_str(),
    ) {
        slog!(
            "[matrix-bot] Handling start command from {sender}: story {story_number} agent={agent_hint:?}"
        );
        let response = super::super::super::start::handle_start(
            &ctx.services.bot_name,
            &story_number,
            agent_hint.as_deref(),
            &effective_root,
            &ctx.services.agents,
        )
        .await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // Check for the reset command, which requires async access to the shared
    // conversation history and cannot be handled by the sync command registry.
    if super::super::super::reset::extract_reset_command(
        &user_message,
        &ctx.services.bot_name,
        ctx.matrix_user_id.as_str(),
    )
    .is_some()
    {
        slog!("[matrix-bot] Handling reset command from {sender}");
        let response = super::super::super::reset::handle_reset(
            &ctx.services.bot_name,
            &incoming_room_id,
            &ctx.history,
            &ctx.services.project_root,
        )
        .await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // In gateway mode, intercept "rebuild gateway" and route it through the
    // detached trampoline so the process swap survives any bash-tool kill cascade.
    if ctx.gateway_active_project.is_some()
        && extract_rebuild_gateway_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        slog!("[matrix-bot] Handling 'rebuild gateway' command from {sender}");
        let ack = "Rebuilding gateway\u{2026} this may take a moment.";
        let ack_html = markdown_to_html(ack);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, ack, &ack_html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        let config_dir = ctx.services.project_root.clone();
        let gateway_port: u16 = ctx.gateway_port.unwrap_or(3000);
        match crate::gateway::rebuild::rebuild_gateway(&config_dir, gateway_port).await {
            Ok(()) => {
                // Trampoline is running detached — it kills this gateway and starts
                // the new one, which will post "gateway X.Y.Z ready" on startup.
            }
            Err(e) => {
                let msg = format!("Gateway rebuild failed: {e}");
                let html = markdown_to_html(&msg);
                if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &msg, &html).await
                    && let Ok(event_id) = msg_id.parse()
                {
                    ctx.bot_sent_event_ids.lock().await.insert(event_id);
                }
            }
        }
        return;
    }

    // In gateway mode, intercept "rebuild gateway" before the plain "rebuild"
    // handler so the trampoline path is used instead of a direct re-exec.
    if ctx.gateway_port.is_some()
        && super::super::super::rebuild::extract_rebuild_gateway_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
        .is_some()
    {
        slog!("[matrix-bot] Handling rebuild-gateway command from {sender}");
        let ack = "Rebuilding gateway… this may take a moment. \
                   The gateway will announce itself when the new version is ready.";
        let ack_html = markdown_to_html(ack);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, ack, &ack_html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        let port = ctx.gateway_port.unwrap_or(3000);
        match crate::gateway::rebuild::rebuild_gateway(&ctx.services.project_root, port).await {
            Ok(()) => {
                // Trampoline is running — this gateway will be killed shortly.
                // No further reply needed; the new gateway posts "gateway X.Y.Z ready".
            }
            Err(e) => {
                let msg = format!("Gateway rebuild failed: {e}");
                let html = markdown_to_html(&msg);
                if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &msg, &html).await
                    && let Ok(event_id) = msg_id.parse()
                {
                    ctx.bot_sent_event_ids.lock().await.insert(event_id);
                }
            }
        }
        return;
    }

    // In gateway mode, handle the "switch <project>" command to change the
    // active project without invoking the LLM.
    if let Some(ref active_project) = ctx.gateway_active_project {
        let stripped = crate::chat::util::strip_bot_mention(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric())
        .to_string();

        let (cmd, arg) = match stripped.split_once(char::is_whitespace) {
            Some((c, a)) => (c.to_string(), a.trim().to_string()),
            None => (stripped.clone(), String::new()),
        };

        if cmd.eq_ignore_ascii_case("switch") {
            let response = if let Some(ref store) = ctx.gateway_projects_store {
                eval_switch_command(&arg, active_project, store).await
            } else {
                "Switch is unavailable: project store not initialised.".to_string()
            };
            let html = markdown_to_html(&response);
            if let Ok(msg_id) = ctx
                .transport
                .send_message(&room_id_str, &response, &html)
                .await
                && let Ok(event_id) = msg_id.parse()
            {
                ctx.bot_sent_event_ids.lock().await.insert(event_id);
            }
            return;
        }
    }

    // Check for the cleanup_worktrees command, which requires async worktree
    // removal and cannot be handled by the sync command registry.
    if let Some(cleanup_cmd) =
        super::super::super::cleanup_worktrees::extract_cleanup_worktrees_command(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
    {
        let confirm =
            cleanup_cmd == super::super::super::cleanup_worktrees::CleanupWorktreesCommand::Confirm;
        slog!("[matrix-bot] Handling cleanup_worktrees command from {sender}: confirm={confirm}");
        let response = super::super::super::cleanup_worktrees::handle_cleanup_worktrees(
            &effective_root,
            confirm,
        )
        .await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // Check for the timer command, which requires async file I/O and cannot
    // be handled by the sync command registry.
    if let Some(timer_cmd) = crate::service::timer::extract_timer_command(
        &user_message,
        &ctx.services.bot_name,
        ctx.matrix_user_id.as_str(),
    ) {
        slog!("[matrix-bot] Handling timer command from {sender}: {timer_cmd:?}");
        let response = crate::service::timer::handle_timer_command(
            timer_cmd,
            &ctx.timer_store,
            &ctx.services.project_root,
        )
        .await;
        let html = markdown_to_html(&response);
        if let Ok(msg_id) = ctx
            .transport
            .send_message(&room_id_str, &response, &html)
            .await
            && let Ok(event_id) = msg_id.parse()
        {
            ctx.bot_sent_event_ids.lock().await.insert(event_id);
        }
        return;
    }

    // "stop" — cancel the running LLM turn for this session and clear pending queue.
    {
        let stripped = crate::chat::util::strip_bot_mention(
            &user_message,
            &ctx.services.bot_name,
            ctx.matrix_user_id.as_str(),
        )
        .trim()
        .to_ascii_lowercase();
        if stripped == "stop" {
            slog!("[matrix-bot] stop command from {sender} for session {room_id_str}");
            ctx.services.chat_dispatcher.stop(&room_id_str);
            let msg = "Stopped.";
            let html = markdown_to_html(msg);
            if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, msg, &html).await
                && let Ok(event_id) = msg_id.parse()
            {
                ctx.bot_sent_event_ids.lock().await.insert(event_id);
            }
            return;
        }
    }

    // Hand the message to the protocol-agnostic dispatcher instead of spawning
    // directly.  The dispatcher applies a coalesce window and a per-session
    // serial lock, preventing duplicate concurrent Timmy spawns.
    let ctx_for_factory = ctx.clone();
    let factory: crate::chat::dispatcher::SpawnFn = {
        let room_id_str2 = room_id_str.clone();
        std::sync::Arc::new(
            move |coalesced: String, cancel_rx: tokio::sync::watch::Receiver<bool>| {
                let room_id_str = room_id_str2.clone();
                let incoming_room_id = incoming_room_id.clone();
                let ctx = ctx_for_factory.clone();
                let sender = sender.clone();
                Box::pin(async move {
                    handle_message(
                        room_id_str,
                        incoming_room_id,
                        ctx,
                        sender,
                        coalesced,
                        cancel_rx,
                    )
                    .await;
                })
            },
        )
    };
    ctx.services
        .chat_dispatcher
        .submit(room_id_str, user_message, factory);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::{
        eval_gateway_overview_command, eval_gateway_status_command, eval_switch_command,
        try_handle_compact_command,
    };
    use crate::chat::{ChatTransport, MessageId};
    use crate::service::gateway::config::ProjectEntry;
    use async_trait::async_trait;
    use std::collections::BTreeMap;
    use tokio::sync::RwLock;

    /// Regression test: `switch` reads from the live store, not a snapshot Vec.
    ///
    /// Seeds an empty store, inserts a project at runtime, then asserts the
    /// command finds it — covering the bug where a stale `gateway_projects` Vec
    /// caused newly added projects to be invisible until the bot restarted.
    #[tokio::test]
    async fn switch_reads_live_store_after_runtime_insert() {
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::new());

        // Empty store: unknown project.
        let resp = eval_switch_command("robot-studio", &active, &store).await;
        assert!(
            resp.contains("Unknown project"),
            "empty store should not find robot-studio: {resp}"
        );

        // Insert the project at runtime — no restart.
        store.write().await.insert(
            "robot-studio".to_string(),
            ProjectEntry {
                url: Some("http://localhost:3002".to_string()),
                auth_token: None,
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );

        // Now the live store has the project; switch must succeed.
        let resp = eval_switch_command("robot-studio", &active, &store).await;
        assert_eq!(
            resp, "Switched to project **robot-studio**.",
            "live store insert must be visible without restart: {resp}"
        );
        assert_eq!(
            *active.read().await,
            "robot-studio",
            "active project must be updated after switch"
        );
    }

    #[tokio::test]
    async fn switch_empty_arg_lists_available_projects() {
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([(
            "huskies".to_string(),
            ProjectEntry {
                url: None,
                auth_token: None,
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        )]));

        let resp = eval_switch_command("", &active, &store).await;
        assert!(
            resp.contains("Usage:"),
            "empty arg should show usage: {resp}"
        );
        assert!(
            resp.contains("huskies"),
            "usage should list available projects: {resp}"
        );
    }

    /// Build a one-project store entry with no URL, to exercise the
    /// no-network-call branches of `eval_gateway_status_command`.
    fn project_without_url() -> ProjectEntry {
        ProjectEntry {
            url: None,
            auth_token: None,
            ssh_port: None,
            host_path: None,
            expected_node_id: None,
        }
    }

    #[tokio::test]
    async fn status_empty_arg_targets_active_project() {
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([(
            "huskies".to_string(),
            project_without_url(),
        )]));

        let resp = eval_gateway_status_command("", &active, &store).await;
        assert!(
            resp.contains("No active project selected"),
            "should report no URL configured for the active project: {resp}"
        );
    }

    #[tokio::test]
    async fn status_numeric_arg_with_no_project_urls_gives_clear_message() {
        // A project literally named "42" must not shadow story-number routing
        // by being matched as a project name — and with no project URLs
        // configured at all, the response must say so plainly rather than
        // silently failing (AC3).
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([
            ("huskies".to_string(), project_without_url()),
            ("42".to_string(), project_without_url()),
        ]));

        let resp = eval_gateway_status_command("42", &active, &store).await;
        assert!(
            resp.contains("No registered projects have a URL configured"),
            "numeric arg with no reachable projects should give a clear message: {resp}"
        );
    }

    /// Spawn a mock server on one port that answers both the HTTP `/mcp`
    /// `get_pipeline_status` call (used to resolve which project owns a
    /// story) and the WebSocket `/ws` `bot.command` proxy call (used to fetch
    /// the actual status card) — mirroring how a real project container
    /// serves both endpoints from the same base URL.
    fn spawn_project_mock_server(
        pipeline_active_story_ids: &'static [&'static str],
        status_card: &'static str,
    ) -> String {
        use futures::{SinkExt, StreamExt};
        use tokio::io::{AsyncReadExt, AsyncWriteExt};
        use tokio_tungstenite::tungstenite::Message as WsMsg;

        let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        std_listener.set_nonblocking(true).unwrap();
        let port = std_listener.local_addr().unwrap().port();
        let listener = tokio::net::TcpListener::from_std(std_listener).unwrap();

        tokio::spawn(async move {
            loop {
                let Ok((stream, _)) = listener.accept().await else {
                    break;
                };
                tokio::spawn(async move {
                    let mut peek_buf = [0u8; 8];
                    let n = stream.peek(&mut peek_buf).await.unwrap_or(0);
                    let is_ws_upgrade = peek_buf[..n].starts_with(b"GET");

                    if is_ws_upgrade {
                        if let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await
                            && let Some(Ok(WsMsg::Text(text))) = ws.next().await
                        {
                            let req: serde_json::Value = serde_json::from_str(&text).unwrap();
                            let resp = serde_json::json!({
                                "kind": "rpc_response",
                                "correlation_id": req["correlation_id"],
                                "ok": true,
                                "result": { "response": status_card },
                            });
                            let _ = ws.send(WsMsg::Text(resp.to_string().into())).await;
                        }
                        return;
                    }

                    let mut stream = stream;
                    let mut buf = vec![0u8; 8192];
                    let _ = stream.read(&mut buf).await;

                    let active: Vec<serde_json::Value> = pipeline_active_story_ids
                        .iter()
                        .map(|id| serde_json::json!({ "story_id": id, "name": id, "stage": "current" }))
                        .collect();
                    let pipeline = serde_json::json!({
                        "active": active, "backlog": [], "backlog_count": 0, "archived": []
                    });
                    let body = serde_json::json!({
                        "jsonrpc": "2.0",
                        "id": 1,
                        "result": {
                            "content": [{ "type": "text", "text": pipeline.to_string() }]
                        }
                    });
                    let body_bytes = serde_json::to_vec(&body).unwrap();
                    let response = format!(
                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
                        body_bytes.len()
                    );
                    let _ = stream.write_all(response.as_bytes()).await;
                    let _ = stream.write_all(&body_bytes).await;
                });
            }
        });

        format!("http://127.0.0.1:{port}")
    }

    /// AC1 + AC2: a story hosted in a project that is *not* currently active
    /// is still found and its status card returned — proving resolution
    /// happens locally across all registered projects rather than being
    /// proxied blindly to whichever one is active.
    #[tokio::test]
    async fn status_numeric_arg_resolves_across_non_active_projects() {
        let target_url =
            spawn_project_mock_server(&["777_story_target"], "## Story 777 — Target\n");
        let active_url = spawn_project_mock_server(&[], "should not be used");

        let active = RwLock::new("other".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([
            ("other".to_string(), ProjectEntry::with_url(active_url)),
            ("target".to_string(), ProjectEntry::with_url(target_url)),
        ]));

        let resp = eval_gateway_status_command("777", &active, &store).await;
        assert_eq!(
            resp, "## Story 777 — Target\n",
            "should return the status card from the project that actually owns the story: {resp}"
        );
    }

    /// AC3: a numeric lookup that matches no registered project's pipeline
    /// gives a clear "not found" message rather than failing silently.
    #[tokio::test]
    async fn status_numeric_arg_not_found_anywhere_gives_clear_message() {
        let url = spawn_project_mock_server(&["1_story_other"], "unused");
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([(
            "huskies".to_string(),
            ProjectEntry::with_url(url),
        )]));

        let resp = eval_gateway_status_command("999", &active, &store).await;
        assert!(
            resp.contains("not found in any registered project"),
            "unmatched numeric lookup should say so plainly: {resp}"
        );
    }

    #[tokio::test]
    async fn status_known_project_name_targets_that_project() {
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([
            ("huskies".to_string(), project_without_url()),
            ("robot-studio".to_string(), project_without_url()),
        ]));

        let resp = eval_gateway_status_command("robot-studio", &active, &store).await;
        assert!(
            resp.contains("robot-studio") && resp.contains("no URL configured"),
            "should target the named project, not the active one: {resp}"
        );
    }

    #[tokio::test]
    async fn status_unrecognized_arg_lists_valid_projects() {
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([
            ("huskies".to_string(), project_without_url()),
            ("robot-studio".to_string(), project_without_url()),
        ]));

        let resp = eval_gateway_status_command("not-a-project", &active, &store).await;
        assert!(
            resp.contains("Unknown project or story number"),
            "should give a clear unrecognized-argument error: {resp}"
        );
        assert!(
            resp.contains("huskies") && resp.contains("robot-studio"),
            "error should list valid projects: {resp}"
        );
    }

    // ── eval_gateway_overview_command (story 1193) ───────────────────────

    #[tokio::test]
    async fn overview_empty_arg_marks_no_url_project_unreachable_not_omitted() {
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([(
            "huskies".to_string(),
            project_without_url(),
        )]));

        let resp = eval_gateway_overview_command("", &active, &store).await;
        assert!(
            resp.contains("huskies") && resp.contains("unreachable"),
            "a project with no URL must be reported unreachable, not silently dropped: {resp}"
        );
    }

    #[tokio::test]
    async fn overview_empty_arg_empty_store_reports_no_projects() {
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::new());

        let resp = eval_gateway_overview_command("", &active, &store).await;
        assert_eq!(resp, "No projects registered.");
    }

    #[tokio::test]
    async fn overview_nonempty_arg_no_active_url_reports_no_active_project() {
        let active = RwLock::new("huskies".to_string());
        let store: RwLock<BTreeMap<String, ProjectEntry>> = RwLock::new(BTreeMap::from([(
            "huskies".to_string(),
            project_without_url(),
        )]));

        let resp = eval_gateway_overview_command("42", &active, &store).await;
        assert!(
            resp.contains("No active project selected"),
            "numeric arg should proxy to the active project, reporting no URL: {resp}"
        );
    }

    // ── compact (story 1205: still swallowed after 1192) ────────────────

    /// Captures every message sent through it, for assertion in tests.
    struct CapturingTransport {
        sent: std::sync::Mutex<Vec<(String, String)>>,
    }

    impl CapturingTransport {
        fn new() -> Self {
            Self {
                sent: std::sync::Mutex::new(Vec::new()),
            }
        }
    }

    #[async_trait]
    impl ChatTransport for CapturingTransport {
        async fn send_message(
            &self,
            room_id: &str,
            plain: &str,
            _html: &str,
        ) -> Result<MessageId, String> {
            self.sent
                .lock()
                .unwrap()
                .push((room_id.to_string(), plain.to_string()));
            Ok("msg-id".to_string())
        }

        async fn edit_message(
            &self,
            _room_id: &str,
            _original_message_id: &str,
            _plain: &str,
            _html: &str,
        ) -> Result<(), String> {
            Ok(())
        }

        async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
            Ok(())
        }
    }

    /// Build a minimal [`super::BotContext`] for driving [`try_handle_compact_command`]
    /// directly in tests, without a live Matrix `Room`/`Client`.
    fn make_test_ctx(
        services: std::sync::Arc<crate::services::Services>,
        transport: std::sync::Arc<dyn ChatTransport>,
    ) -> super::BotContext {
        use std::collections::HashSet;
        use std::sync::Arc;
        use std::sync::atomic::AtomicI64;
        use tokio::sync::Mutex as TokioMutex;

        super::BotContext {
            services,
            matrix_user_id: "@bot:example.com".parse().unwrap(),
            target_room_ids: vec![],
            allowed_users: vec![],
            history: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
            history_size: 20,
            bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
            htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
            transport,
            timer_store: Arc::new(crate::service::timer::TimerStore::load(
                std::path::PathBuf::from("/tmp/timers-on-room-message-compact-test.json"),
            )),
            gateway_active_project: None,
            gateway_projects_store: None,
            gateway_channels_store: None,
            handled_incoming_event_ids: Arc::new(TokioMutex::new(
                crate::chat::transport::matrix::bot::context::SeenEventIds::new(
                    crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP,
                ),
            )),
            gateway_port: None,
            last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
            model: None,
            compact_seed_max_bytes: 8_000,
            cache_read_suggest_threshold: 50_000,
            compact_suggest_cooldown_secs: 3_600,
        }
    }

    /// Regression test for story 1205: `compact` must not be swallowed by the
    /// registry placeholder. Drives `try_handle_compact_command` — the same
    /// entry point `on_room_message` calls before the sync command registry —
    /// with the literal message `"compact"` (never calling `handle_compact`
    /// directly), and asserts the real handler ran: the captured transport
    /// message reports the before/after byte-size confirmation (never a
    /// generic "no response" placeholder), and the room's session_id/entries
    /// were cleared with pending_seed set.
    #[tokio::test]
    async fn compact_command_runs_through_full_dispatch_and_clears_session() {
        use crate::chat::transport::matrix::bot::{ConversationEntry, ConversationRole};
        use std::sync::Arc;

        let room_id: matrix_sdk::ruma::OwnedRoomId = "!test:example.com".parse().unwrap();
        let session_id = "sess-matrix-compact-1205";

        let project_root_dir = tempfile::tempdir().unwrap();
        let project_root = project_root_dir.path().to_path_buf();
        let home = tempfile::tempdir().unwrap();
        // SAFETY: this test owns HOME for its duration; no other test in this
        // process reads HOME concurrently with this call.
        unsafe {
            std::env::set_var("HOME", home.path());
        }
        let transcript_dir =
            crate::chat::compact::transcript::transcript_path(&project_root, session_id)
                .parent()
                .unwrap()
                .to_path_buf();
        std::fs::create_dir_all(&transcript_dir).unwrap();
        let jsonl = r#"{"type":"user","message":{"role":"user","content":"hello"}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]}}"#;
        std::fs::write(transcript_dir.join(format!("{session_id}.jsonl")), jsonl).unwrap();

        let services =
            crate::services::Services::new_test(project_root.clone(), "Huskies".to_string());
        let transport = Arc::new(CapturingTransport::new());
        let ctx = make_test_ctx(services, transport.clone());

        {
            let mut guard = ctx.history.lock().await;
            guard.insert(
                room_id.clone(),
                crate::chat::transport::matrix::bot::RoomConversation {
                    session_id: Some(session_id.to_string()),
                    entries: vec![ConversationEntry {
                        role: ConversationRole::User,
                        sender: "@alice:example.com".to_string(),
                        content: "hi".to_string(),
                    }],
                    pending_seed: None,
                    last_compact_suggested_at_ms: None,
                },
            );
        }

        let handled = try_handle_compact_command(
            &ctx,
            "@alice:example.com",
            "compact",
            room_id.as_str(),
            &room_id,
        )
        .await;

        assert!(handled, "compact must be recognized and handled");

        let sent = transport.sent.lock().unwrap().clone();
        assert_eq!(sent.len(), 1, "exactly one reply must be sent");
        assert!(
            sent[0].1.contains("Compacted session context"),
            "reply must confirm compaction with before/after byte sizes, not a generic \
             placeholder like 'Command succeeded with no response text': {}",
            sent[0].1
        );

        let guard = ctx.history.lock().await;
        let conv = guard.get(&room_id).unwrap();
        assert!(
            conv.session_id.is_none(),
            "session_id must be cleared after compact"
        );
        assert!(
            conv.entries.is_empty(),
            "entries must be cleared after compact"
        );
        assert!(
            conv.pending_seed.is_some(),
            "pending_seed must be set after compact"
        );
    }

    /// A message that is not `compact` must fall through untouched, so the
    /// caller can continue to the sync command registry / LLM.
    #[tokio::test]
    async fn non_compact_message_falls_through() {
        use std::sync::Arc;

        let room_id: matrix_sdk::ruma::OwnedRoomId = "!test:example.com".parse().unwrap();
        let project_root_dir = tempfile::tempdir().unwrap();
        let services = crate::services::Services::new_test(
            project_root_dir.path().to_path_buf(),
            "Huskies".to_string(),
        );
        let transport = Arc::new(CapturingTransport::new());
        let ctx = make_test_ctx(services, transport.clone());

        let handled = try_handle_compact_command(
            &ctx,
            "@alice:example.com",
            "status",
            room_id.as_str(),
            &room_id,
        )
        .await;

        assert!(!handled, "non-compact messages must fall through");
        assert!(
            transport.sent.lock().unwrap().is_empty(),
            "no reply should be sent for a message that isn't compact"
        );
    }
}
