//! Gateway I/O — the ONLY place in `service/gateway/` that may perform side effects.
//!
//! Side effects here include: reading/writing config and agent state files,
//! HTTP requests to project containers (proxying, health checks, polling),
//! spawning the Matrix bot task, and the notification poller background task.

use super::config::{GatewayConfig, ProjectEntry};
pub use reqwest::Client;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::Path;

// ── Config I/O ───────────────────────────────────────────────────────────────

/// Load gateway config from a `projects.toml` file.
pub fn load_config(path: &Path) -> Result<GatewayConfig, String> {
    let contents = std::fs::read_to_string(path)
        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
    toml::from_str(&contents).map_err(|e| format!("invalid projects.toml: {e}"))
}

/// Persist the current projects map to `<config_dir>/projects.toml`.
/// Silently ignores write errors or skips when `config_dir` is empty.
///
/// Existing `[sled_tokens]` and `[release_channels]` entries are preserved so
/// that adding or removing projects via the UI does not wipe the sled
/// authentication tokens or configured release channels.
pub async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir: &Path) {
    if config_dir.as_os_str().is_empty() {
        return;
    }
    let path = config_dir.join("projects.toml");
    let existing = tokio::fs::read_to_string(&path)
        .await
        .ok()
        .and_then(|data| toml::from_str::<GatewayConfig>(&data).ok());
    let sled_tokens = existing
        .as_ref()
        .map(|c| c.sled_tokens.clone())
        .unwrap_or_default();
    let release_channels = existing
        .as_ref()
        .map(|c| c.release_channels.clone())
        .unwrap_or_default();
    let config = GatewayConfig {
        projects: projects.clone(),
        sled_tokens,
        release_channels,
    };
    if let Ok(data) = toml::to_string_pretty(&config) {
        let _ = tokio::fs::write(&path, data).await;
    }
}

// ── Bot config I/O ──────────────────────────────────────────────────────────

/// Read the current raw bot.toml as key/value pairs for the configuration UI.
/// Returns `None` values if the file does not exist.
pub fn read_bot_config_raw(config_dir: &Path) -> BotConfigFields {
    let path = config_dir.join(".huskies").join("bot.toml");
    let content = match std::fs::read_to_string(&path) {
        Ok(c) => c,
        Err(_) => return BotConfigFields::default(),
    };
    let table: toml::Value = match toml::from_str(&content) {
        Ok(v) => v,
        Err(_) => return BotConfigFields::default(),
    };
    let s = |key: &str| -> Option<String> {
        table
            .get(key)
            .and_then(|v| v.as_str())
            .map(|s| s.to_string())
    };
    BotConfigFields {
        transport: s("transport").unwrap_or_else(|| "matrix".to_string()),
        homeserver: s("homeserver"),
        username: s("username"),
        password: s("password"),
        slack_bot_token: s("slack_bot_token"),
        slack_signing_secret: s("slack_signing_secret"),
    }
}

/// Raw bot.toml fields for the configuration UI.
#[derive(Default)]
pub struct BotConfigFields {
    pub transport: String,
    pub homeserver: Option<String>,
    pub username: Option<String>,
    pub password: Option<String>,
    pub slack_bot_token: Option<String>,
    pub slack_signing_secret: Option<String>,
}

/// Write a `bot.toml` from the given content string.
pub fn write_bot_config(config_dir: &Path, content: &str) -> Result<(), String> {
    let huskies_dir = config_dir.join(".huskies");
    std::fs::create_dir_all(&huskies_dir)
        .map_err(|e| format!("cannot create .huskies dir: {e}"))?;
    let path = huskies_dir.join("bot.toml");
    std::fs::write(&path, content).map_err(|e| format!("cannot write bot.toml: {e}"))
}

// ── Identity probe I/O ───────────────────────────────────────────────────────

/// `GET {sled_url}/identity?nonce=<nonce>` and parse the JSON body.
///
/// Returns `None` when the sled is unreachable or the response body doesn't
/// parse as [`super::identity::IdentityProbeResponse`] — callers treat that
/// the same as an unverifiable identity (distinct from a legacy sled, which
/// responds but omits the `signature` field).
pub async fn probe_identity(
    client: &Client,
    sled_url: &str,
    nonce: &str,
) -> Option<super::identity::IdentityProbeResponse> {
    // `nonce` is always a hex string (see `node_identity::generate_challenge`),
    // so no percent-encoding is needed for safe inclusion in the query string.
    let url = format!("{}/identity?nonce={nonce}", sled_url.trim_end_matches('/'));
    client
        .get(&url)
        .send()
        .await
        .ok()?
        .json::<super::identity::IdentityProbeResponse>()
        .await
        .ok()
}

// ── Fleet identity I/O (story 1206) ─────────────────────────────────────────

/// Probe one sled's `/identity` endpoint and build its
/// [`super::identity::SledIdentityReport`].
///
/// Generates a fresh challenge nonce, probes `entry.url`, and compares the
/// (signature-verified) response against `entry.expected_node_id`. When
/// `entry.url` is unset the sled is reported unreachable without a network
/// call.
pub async fn probe_sled_identity_report(
    project: &str,
    entry: &ProjectEntry,
    client: &Client,
) -> super::identity::SledIdentityReport {
    let Some(url) = entry.url.as_deref() else {
        return super::identity::build_identity_report(
            project,
            None,
            entry.expected_node_id.as_deref(),
            None,
        );
    };

    let nonce = crate::node_identity::generate_challenge();
    let check = probe_identity(client, url, &nonce).await.map(|response| {
        super::check_identity(entry.expected_node_id.as_deref(), &nonce, &response)
    });

    super::identity::build_identity_report(
        project,
        Some(url),
        entry.expected_node_id.as_deref(),
        check,
    )
}

/// Re-pin a sled's expected identity via TOFU (trust-on-first-use).
///
/// Probes `sled_url`, and — only when the response's signature verifies —
/// captures the verified node ID as the new pin, overwriting whatever was
/// previously recorded (including a mismatched one; that's the point of an
/// explicit re-pin). Persists the change to both the live `projects_store`
/// and `projects.toml` at `config_dir`.
///
/// Refuses (returns `Err`) when the sled is unreachable, its response has no
/// signature (a legacy sled), or the signature fails to verify — in none of
/// those cases is a responder's claimed identity trustworthy enough to pin.
pub async fn repin_sled_identity(
    project: &str,
    sled_url: &str,
    projects_store: &std::sync::Arc<tokio::sync::RwLock<BTreeMap<String, ProjectEntry>>>,
    config_dir: &Path,
    client: &Client,
) -> Result<String, String> {
    let nonce = crate::node_identity::generate_challenge();
    let Some(response) = probe_identity(client, sled_url, &nonce).await else {
        return Err(format!(
            "cannot re-pin `{project}` at `{sled_url}`: the sled is unreachable"
        ));
    };

    // Compare against `None` regardless of any existing pin — an explicit
    // re-pin always (re-)captures on a verified signature rather than
    // reporting a `Mismatch` against the old pin.
    match super::check_identity(None, &nonce, &response) {
        super::IdentityCheck::FirstContact { node_id } => {
            {
                let mut projects = projects_store.write().await;
                if let Some(entry) = projects.get_mut(project) {
                    entry.expected_node_id = Some(node_id.clone());
                }
            }
            let snapshot = projects_store.read().await.clone();
            save_config(&snapshot, config_dir).await;
            Ok(node_id)
        }
        super::IdentityCheck::InvalidSignature => Err(format!(
            "refusing to pin `{project}` at `{sled_url}`: the `/identity` response signature \
             did not verify — the responder's identity cannot be trusted"
        )),
        super::IdentityCheck::MissingSignature => Err(format!(
            "refusing to pin `{project}` at `{sled_url}`: the response had no signature \
             (a legacy sled predating signed identity) — identity cannot be verified"
        )),
        super::IdentityCheck::Match | super::IdentityCheck::Mismatch { .. } => {
            unreachable!(
                "check_identity(None, ..) only returns FirstContact/InvalidSignature/MissingSignature"
            )
        }
    }
}

// ── Release channel I/O (story 1169) ────────────────────────────────────────

/// Fetch `{base_url}/manifest.json` and parse it into a
/// [`release_manifest::SignedManifest`].
///
/// Sends `Authorization: Bearer <token>` when `bearer_token` is set. Returns
/// `Err` on any network, HTTP-status, or parse failure — `pull` treats all of
/// these the same as any other loud, install-nothing failure.
pub async fn fetch_release_manifest(
    client: &Client,
    base_url: &str,
    bearer_token: Option<&str>,
) -> Result<release_manifest::SignedManifest, String> {
    let url = format!("{}/manifest.json", base_url.trim_end_matches('/'));
    let mut req = client.get(&url);
    if let Some(token) = bearer_token {
        req = req.bearer_auth(token);
    }
    let resp = req
        .send()
        .await
        .map_err(|e| format!("cannot reach {url}: {e}"))?;
    if !resp.status().is_success() {
        return Err(format!("{url} returned HTTP {}", resp.status()));
    }
    resp.json()
        .await
        .map_err(|e| format!("invalid manifest.json at {url}: {e}"))
}

/// Download a named artifact from a release channel.
///
/// Sends `Authorization: Bearer <token>` when `bearer_token` is set.
pub async fn download_channel_artifact(
    client: &Client,
    base_url: &str,
    artifact_name: &str,
    bearer_token: Option<&str>,
) -> Result<Vec<u8>, String> {
    let url = format!("{}/{artifact_name}", base_url.trim_end_matches('/'));
    let mut req = client.get(&url);
    if let Some(token) = bearer_token {
        req = req.bearer_auth(token);
    }
    let resp = req
        .send()
        .await
        .map_err(|e| format!("cannot reach {url}: {e}"))?;
    if !resp.status().is_success() {
        return Err(format!("{url} returned HTTP {}", resp.status()));
    }
    resp.bytes()
        .await
        .map(|b| b.to_vec())
        .map_err(|e| format!("failed reading artifact body from {url}: {e}"))
}

/// Atomically install a verified artifact plus its `.hash` and
/// `.manifest.json` sidecars into the gateway's artifact store.
///
/// Mirrors the write-tmp-then-rename pattern `release` uses for
/// `SLED_ARTIFACT_NAME` so a concurrent `/api/artifacts` download never
/// observes a half-written file. The `.hash` sidecar carries `manifest.version`
/// so `upgrade all`'s existing convergence check (`sled_upgrade.rs`) keeps
/// working unmodified. The `.manifest.json` sidecar records the full manifest
/// so the next pull can run [`super::release_manifest::check_rollback`]
/// against it.
pub fn install_pulled_artifact(
    artifacts_dir: &Path,
    artifact_name: &str,
    bytes: &[u8],
    manifest: &release_manifest::ReleaseManifest,
) -> Result<(), String> {
    std::fs::create_dir_all(artifacts_dir)
        .map_err(|e| format!("cannot create {}: {e}", artifacts_dir.display()))?;

    let artifact_path = artifacts_dir.join(artifact_name);
    let tmp_path = artifacts_dir.join(".pull.tmp");
    std::fs::write(&tmp_path, bytes).map_err(|e| format!("write tmp artifact failed: {e}"))?;
    std::fs::rename(&tmp_path, &artifact_path)
        .map_err(|e| format!("rename artifact failed: {e}"))?;

    let hash_path = artifacts_dir.join(format!("{artifact_name}.hash"));
    std::fs::write(&hash_path, &manifest.version)
        .map_err(|e| format!("write hash sidecar failed: {e}"))?;

    let manifest_path = artifacts_dir.join(format!("{artifact_name}.manifest.json"));
    let manifest_json = serde_json::to_string_pretty(manifest)
        .map_err(|e| format!("serialise manifest sidecar: {e}"))?;
    std::fs::write(&manifest_path, manifest_json)
        .map_err(|e| format!("write manifest sidecar failed: {e}"))?;

    Ok(())
}

/// Read the `.manifest.json` sidecar for the currently installed artifact, if any.
///
/// Returns `None` when no artifact has ever been pulled from a channel (e.g.
/// only `release` has published so far, or this is a fresh gateway) — callers
/// treat that as "no rollback information available", not an error.
pub fn read_installed_manifest(
    artifacts_dir: &Path,
    artifact_name: &str,
) -> Option<release_manifest::ReleaseManifest> {
    let path = artifacts_dir.join(format!("{artifact_name}.manifest.json"));
    let content = std::fs::read_to_string(path).ok()?;
    serde_json::from_str(&content).ok()
}

// ── MCP proxy I/O ───────────────────────────────────────────────────────────

/// Proxy an MCP `tools/call` request to the sled with `Accept: text/event-stream`
/// and return the raw response for streaming.  No per-request timeout is applied
/// so long-running tool calls (e.g. `run_tests`, up to 1200 s) are not cut short.
///
/// The caller reads `.bytes_stream()` from the returned response and re-emits
/// each SSE `data:` line as a new event to the originating client.
pub async fn proxy_mcp_call_sse(
    client: &Client,
    base_url: &str,
    request_bytes: &[u8],
) -> Result<reqwest::Response, String> {
    let mcp_url = format!("{}/mcp", base_url.trim_end_matches('/'));
    client
        .post(&mcp_url)
        .header("Content-Type", "application/json")
        .header("Accept", "text/event-stream")
        .body(request_bytes.to_vec())
        .send()
        .await
        .map_err(|e| format!("failed to reach {mcp_url}: {e}"))
}

/// Fetch and aggregate pipeline status for a single project URL.
pub async fn fetch_one_project_pipeline_status(url: &str, client: &Client) -> Value {
    let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
    let rpc_body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
            "name": "get_pipeline_status",
            "arguments": {}
        }
    });

    match client.post(&mcp_url).json(&rpc_body).send().await {
        Ok(resp) => match resp.json::<Value>().await {
            Ok(upstream) => {
                if let Some(text) = upstream
                    .get("result")
                    .and_then(|r| r.get("content"))
                    .and_then(|c| c.get(0))
                    .and_then(|c| c.get("text"))
                    .and_then(|t| t.as_str())
                {
                    match serde_json::from_str::<Value>(text) {
                        Ok(pipeline) => {
                            crate::service::pipeline::aggregate_pipeline_counts(&pipeline)
                        }
                        Err(_) => json!({ "error": "invalid pipeline JSON" }),
                    }
                } else {
                    json!({ "error": "unexpected response shape" })
                }
            }
            Err(e) => json!({ "error": format!("invalid response: {e}") }),
        },
        Err(e) => json!({ "error": format!("unreachable: {e}") }),
    }
}

/// Fetch `get_pipeline_status` from every registered project URL in parallel.
pub async fn fetch_all_project_pipeline_statuses(
    project_urls: &BTreeMap<String, String>,
    client: &Client,
) -> BTreeMap<String, Value> {
    use futures::future::join_all;

    let futures: Vec<_> = project_urls
        .iter()
        .map(|(name, url)| {
            let name = name.clone();
            let url = url.clone();
            let client = client.clone();
            async move {
                let result = fetch_one_project_pipeline_status(&url, &client).await;
                (name, result)
            }
        })
        .collect();

    join_all(futures).await.into_iter().collect()
}

/// Fetch pipeline items for a single project URL.
///
/// Returns `{ "active": [...], "backlog_count": N }` preserving individual
/// story items so the gateway UI can render them.  On error returns
/// `{ "error": "..." }`.  This is distinct from
/// `fetch_one_project_pipeline_status` which discards items and returns
/// aggregated counts.
pub async fn fetch_one_project_pipeline_items(url: &str, client: &Client) -> Value {
    let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
    let rpc_body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
            "name": "get_pipeline_status",
            "arguments": {}
        }
    });

    match client.post(&mcp_url).json(&rpc_body).send().await {
        Ok(resp) => match resp.json::<Value>().await {
            Ok(upstream) => {
                if let Some(text) = upstream
                    .get("result")
                    .and_then(|r| r.get("content"))
                    .and_then(|c| c.get(0))
                    .and_then(|c| c.get("text"))
                    .and_then(|t| t.as_str())
                {
                    match serde_json::from_str::<Value>(text) {
                        Ok(pipeline) => {
                            let active = pipeline.get("active").cloned().unwrap_or(json!([]));
                            let backlog = pipeline.get("backlog").cloned().unwrap_or(json!([]));
                            let backlog_count = pipeline
                                .get("backlog_count")
                                .and_then(|n| n.as_u64())
                                .unwrap_or(0);
                            let archived = pipeline.get("archived").cloned().unwrap_or(json!([]));
                            json!({ "active": active, "backlog": backlog, "backlog_count": backlog_count, "archived": archived })
                        }
                        Err(_) => json!({ "error": "invalid pipeline JSON" }),
                    }
                } else {
                    json!({ "error": "unexpected response shape" })
                }
            }
            Err(e) => json!({ "error": format!("invalid response: {e}") }),
        },
        Err(e) => json!({ "error": format!("unreachable: {e}") }),
    }
}

/// Fetch pipeline items from every registered project URL in parallel.
///
/// Returns per-project `{ "active": [...], "backlog_count": N }` objects
/// suitable for the gateway web UI.
pub async fn fetch_all_project_pipeline_items(
    project_urls: &BTreeMap<String, String>,
    client: &Client,
) -> BTreeMap<String, Value> {
    use futures::future::join_all;

    let futures: Vec<_> = project_urls
        .iter()
        .map(|(name, url)| {
            let name = name.clone();
            let url = url.clone();
            let client = client.clone();
            async move {
                let result = fetch_one_project_pipeline_items(&url, &client).await;
                (name, result)
            }
        })
        .collect();

    join_all(futures).await.into_iter().collect()
}

/// Fetch the pipeline status from a single project for the `gateway_status` tool.
pub async fn fetch_pipeline_status_for_project(
    client: &Client,
    base_url: &str,
) -> Result<Value, String> {
    let mcp_url = format!("{}/mcp", base_url.trim_end_matches('/'));
    let rpc_body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
            "name": "get_pipeline_status",
            "arguments": {}
        }
    });

    let resp = client
        .post(&mcp_url)
        .json(&rpc_body)
        .send()
        .await
        .map_err(|e| format!("failed to reach {mcp_url}: {e}"))?;

    resp.json()
        .await
        .map_err(|e| format!("invalid upstream response: {e}"))
}

/// Check health of a single project URL via the read-RPC `health.check` method.
///
/// Sends an RPC request to the project's `/mcp` endpoint.  A successful
/// response (HTTP 2xx) indicates the project container is reachable and
/// serving requests.
pub async fn check_project_health(client: &Client, base_url: &str) -> Result<bool, String> {
    let mcp_url = format!("{}/mcp", base_url.trim_end_matches('/'));
    let rpc_body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/list",
        "params": {}
    });
    match client.post(&mcp_url).json(&rpc_body).send().await {
        Ok(resp) => Ok(resp.status().is_success()),
        Err(e) => Err(format!("unreachable: {e}")),
    }
}

// ── Gateway MCP JSON ────────────────────────────────────────────────────────

/// Write (or overwrite) a `.mcp.json` in `config_dir` that points Claude Code
/// CLI at the gateway's own `/mcp` endpoint.
pub fn write_gateway_mcp_json(config_dir: &Path, port: u16) -> Result<(), std::io::Error> {
    let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
    let url = format!("http://{host}:{port}/mcp");
    let content = json!({
        "mcpServers": {
            "huskies": {
                "type": "http",
                "url": url
            }
        }
    });
    let path = config_dir.join(".mcp.json");
    std::fs::write(&path, serde_json::to_string_pretty(&content).unwrap())?;
    crate::slog!("[gateway] Wrote {} pointing to {}", path.display(), url);
    Ok(())
}

// ── Init project I/O ────────────────────────────────────────────────────────

/// Check if a path already has a `.huskies/` directory.
pub fn has_huskies_dir(path: &Path) -> bool {
    path.join(".huskies").exists()
}

/// Create a directory (and parents) if it does not exist.
pub fn ensure_directory(path: &Path) -> Result<(), String> {
    if !path.exists() {
        std::fs::create_dir_all(path)
            .map_err(|e| format!("failed to create directory '{}': {e}", path.display()))?;
    }
    Ok(())
}

/// Scaffold a huskies project at the given path.
pub fn scaffold_project(path: &Path) -> Result<(), String> {
    crate::io::fs::scaffold::scaffold_story_kit(path, 3001)
}

/// Initialise wizard state at the given path.
pub fn init_wizard_state(path: &Path) {
    crate::io::wizard::WizardState::init_if_missing(path);
}

// ── Notification poller ─────────────────────────────────────────────────────

/// Spawn a background task that reads [`super::GatewayStatusEvent`]s from the
/// gateway broadcast channel and forwards each one to the configured rooms via
/// `transport`, formatted with a `[project-name]` prefix.
///
/// Survives broadcaster back-pressure: when the receiver falls behind
/// ([`tokio::sync::broadcast::error::RecvError::Lagged`]), the task
/// re-subscribes so it does not permanently stall.
///
/// The task exits cleanly when the broadcast channel is closed (i.e. when
/// `GatewayState` is dropped).
/// Window within which identical disk-space warnings from different sleds are
/// deduped into a single forwarded chat message (story 1200 AC4). Matches the
/// sled-side default `rate_limit_secs`.
const DISK_DEDUPE_WINDOW: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60);

/// Returns the dedupe key for a [`crate::service::events::StoredEvent`], or
/// `None` if the event type is never deduped (only disk-space events dedupe
/// across sleds — story 1200 AC4).
fn dedupe_key(event: &crate::service::events::StoredEvent) -> Option<String> {
    match event {
        crate::service::events::StoredEvent::DiskSpaceWarning { level, .. } => {
            Some(format!("disk_warning:{level}"))
        }
        crate::service::events::StoredEvent::DiskSpaceRecovered { .. } => {
            Some("disk_recovered".to_string())
        }
        _ => None,
    }
}

pub fn spawn_gateway_broadcaster_forwarder(
    transport: std::sync::Arc<dyn crate::chat::ChatTransport>,
    room_ids: Vec<String>,
    mut rx: tokio::sync::broadcast::Receiver<super::GatewayStatusEvent>,
) {
    tokio::spawn(async move {
        let mut last_forwarded: std::collections::HashMap<String, std::time::Instant> =
            std::collections::HashMap::new();
        loop {
            match rx.recv().await {
                Ok(event) => {
                    if let Some(key) = dedupe_key(&event.event) {
                        let now = std::time::Instant::now();
                        let should_forward = match last_forwarded.get(&key) {
                            None => true,
                            Some(last) => now.duration_since(*last) >= DISK_DEDUPE_WINDOW,
                        };
                        if !should_forward {
                            crate::slog!(
                                "[gateway-forwarder] Deduping repeated {key} within window"
                            );
                            continue;
                        }
                        last_forwarded.insert(key, now);
                    }
                    let (plain, html) =
                        super::polling::format_gateway_event(&event.project, &event.event);
                    for room_id in &room_ids {
                        if let Err(e) = transport.send_message(room_id, &plain, &html).await {
                            crate::slog!(
                                "[gateway-forwarder] Failed to forward event to {room_id}: {e}"
                            );
                        }
                    }
                }
                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                    crate::slog!(
                        "[gateway-forwarder] Broadcaster lagged by {n} messages; resubscribing"
                    );
                    rx = rx.resubscribe();
                }
                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
                    crate::slog!("[gateway-forwarder] Broadcast channel closed; forwarder exiting");
                    break;
                }
            }
        }
    });
}

// ── Gateway bot spawn ───────────────────────────────────────────────────────

/// Re-export type alias for the active project lock.
pub type ActiveProject = std::sync::Arc<tokio::sync::RwLock<String>>;

/// Attempt to spawn the Matrix bot against the gateway config directory.
///
/// `gateway_event_tx` — when `Some`, the bot will subscribe to the gateway
/// status broadcaster and forward [`super::GatewayStatusEvent`]s to its
/// configured Matrix rooms with a `[project-name]` prefix.
///
/// Returns `(abort_handle, shutdown_tx)`.  The caller **must** hold
/// `shutdown_tx` for the bot's lifetime and send `Some(ShutdownReason)` on it
/// before process exit so the bot can announce "going offline" to its rooms.
#[allow(clippy::too_many_arguments)]
pub fn spawn_gateway_bot(
    config_dir: &Path,
    active_project: ActiveProject,
    gateway_projects_store: std::sync::Arc<tokio::sync::RwLock<BTreeMap<String, ProjectEntry>>>,
    port: u16,
    gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
    permission_registry: std::sync::Arc<crate::service::permission_router::ResponderRegistry>,
    gateway_channels_store: std::sync::Arc<
        tokio::sync::RwLock<BTreeMap<String, super::config::ReleaseChannelConfig>>,
    >,
) -> (
    Option<tokio::task::AbortHandle>,
    tokio::sync::watch::Sender<Option<crate::rebuild::ShutdownReason>>,
) {
    use crate::agents::AgentPool;
    use crate::services::Services;
    use tokio::sync::broadcast;

    let (watcher_tx, _) = broadcast::channel::<crate::io::watcher::WatcherEvent>(16);
    let (shutdown_tx, shutdown_rx) =
        tokio::sync::watch::channel::<Option<crate::rebuild::ShutdownReason>>(None);
    // shutdown_tx is intentionally NOT forgotten — the caller holds it and
    // sends Some(reason) on gateway shutdown so the bot announces "going offline".

    let agents = std::sync::Arc::new(AgentPool::new(port, watcher_tx.clone()));

    // Read the gateway's bot.toml so display_name, ambient_rooms, and
    // permission_timeout_secs are honoured (matches the standard-mode
    // initialisation in main.rs). Previously this path hardcoded
    // `bot_name = "Assistant"` regardless of bot.toml's display_name,
    // breaking @-addressing for users who configured a different name.
    let bot_cfg = crate::chat::transport::matrix::BotConfig::load(config_dir);

    let services = std::sync::Arc::new(Services {
        project_root: config_dir.to_path_buf(),
        status: agents.status_broadcaster(),
        agents,
        bot_name: bot_cfg
            .as_ref()
            .and_then(|c| c.display_name.clone())
            .unwrap_or_else(|| "Assistant".to_string()),
        bot_user_id: String::new(),
        ambient_rooms: std::sync::Arc::new(std::sync::Mutex::new(
            bot_cfg
                .as_ref()
                .map(|c| c.ambient_rooms.iter().cloned().collect())
                .unwrap_or_default(),
        )),
        permission_registry,
        pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
        permission_timeout_secs: bot_cfg
            .as_ref()
            .map(|c| c.permission_timeout_secs)
            .unwrap_or(120),
        chat_dispatcher: std::sync::Arc::new(crate::chat::dispatcher::ChatDispatcher::new(
            bot_cfg
                .as_ref()
                .map(|c| c.coalesce_window_ms)
                .unwrap_or(1_500),
        )),
    });

    let timer_store = std::sync::Arc::new(crate::service::timer::TimerStore::load(
        config_dir.join(".huskies").join("timers.json"),
    ));
    let gateway_event_rx = gateway_event_tx.map(|tx| tx.subscribe());
    let handle = crate::chat::transport::matrix::spawn_bot(
        config_dir,
        watcher_tx,
        services,
        shutdown_rx,
        Some(active_project),
        Some(gateway_projects_store),
        timer_store,
        gateway_event_rx,
        Some(port),
        Some(gateway_channels_store),
    );
    (handle, shutdown_tx)
}

// ── Tests ────────────────────────────────────────────────────────────────────

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

    /// Regression test for story 918: `spawn_gateway_bot` must return a live
    /// `shutdown_tx` (not one dropped via `std::mem::forget`) so that callers
    /// can signal `Some(reason)` and the bot's shutdown watcher receives it.
    #[tokio::test]
    async fn spawn_gateway_bot_shutdown_tx_not_forgotten() {
        let tmp = tempfile::tempdir().unwrap();
        let active = std::sync::Arc::new(tokio::sync::RwLock::new("proj".to_string()));
        let (event_tx, _) = tokio::sync::broadcast::channel(4);

        let permission_registry = crate::service::permission_router::ResponderRegistry::new();
        let projects_store =
            std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new()));
        let channels_store =
            std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new()));
        let (handle, shutdown_tx) = spawn_gateway_bot(
            tmp.path(),
            active,
            projects_store,
            3001,
            Some(event_tx),
            permission_registry,
            channels_store,
        );

        // No bot.toml in tmp → no abort handle spawned.
        assert!(handle.is_none());

        // The shutdown_tx must be live: subscribe a receiver and verify that
        // sending Some(reason) is observed — this would fail if the sender
        // had been forgotten (channel closed, send returns Err).
        let rx = shutdown_tx.subscribe();
        shutdown_tx
            .send(Some(crate::rebuild::ShutdownReason::Manual))
            .expect("shutdown_tx must not be closed (was previously std::mem::forget'd)");
        assert_eq!(
            *rx.borrow(),
            Some(crate::rebuild::ShutdownReason::Manual),
            "shutdown receiver must see the Manual reason"
        );
    }

    // ── fetch_release_manifest / download_channel_artifact ──────────────────

    /// Spawn a one-shot TCP listener that captures the raw request text of a
    /// single connection and responds with `body`.
    fn spawn_capturing_responder(
        listener: tokio::net::TcpListener,
        body: Vec<u8>,
    ) -> tokio::sync::oneshot::Receiver<String> {
        let (tx, rx) = tokio::sync::oneshot::channel();
        tokio::spawn(async move {
            if let Ok((mut stream, _)) = listener.accept().await {
                use tokio::io::{AsyncReadExt, AsyncWriteExt};
                let mut buf = [0u8; 8192];
                let n = stream.read(&mut buf).await.unwrap_or(0);
                let request = String::from_utf8_lossy(&buf[..n]).to_string();
                let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
                let _ = stream.write_all(response.as_bytes()).await;
                let _ = stream.write_all(&body).await;
                let _ = tx.send(request);
            }
        });
        rx
    }

    fn sample_signed_manifest() -> release_manifest::SignedManifest {
        release_manifest::SignedManifest {
            manifest: release_manifest::ReleaseManifest {
                artifact: "huskies-linux-arm64".to_string(),
                sha256: "a".repeat(64),
                version: "abc1234".to_string(),
                channel: "stable".to_string(),
                timestamp: 1,
            },
            signature: "deadbeef".to_string(),
        }
    }

    #[tokio::test]
    async fn fetch_release_manifest_sends_bearer_auth_header() {
        let body = serde_json::to_vec(&sample_signed_manifest()).unwrap();

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        let rx = spawn_capturing_responder(listener, body);

        let client = Client::new();
        let base_url = format!("http://127.0.0.1:{port}");
        let fetched = fetch_release_manifest(&client, &base_url, Some("secret-token"))
            .await
            .unwrap();
        assert_eq!(fetched.manifest.version, "abc1234");

        let request = rx.await.unwrap();
        assert!(
            request
                .to_lowercase()
                .contains("authorization: bearer secret-token"),
            "request must carry the bearer token: {request}"
        );
    }

    #[tokio::test]
    async fn fetch_release_manifest_without_token_sends_no_auth_header() {
        let body = serde_json::to_vec(&sample_signed_manifest()).unwrap();

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        let rx = spawn_capturing_responder(listener, body);

        let client = Client::new();
        let base_url = format!("http://127.0.0.1:{port}");
        fetch_release_manifest(&client, &base_url, None)
            .await
            .unwrap();

        let request = rx.await.unwrap();
        assert!(
            !request.to_lowercase().contains("authorization:"),
            "request must not carry an Authorization header when no token is set: {request}"
        );
    }

    #[tokio::test]
    async fn fetch_release_manifest_unreachable_is_error() {
        let client = Client::new();
        let err = fetch_release_manifest(&client, "http://127.0.0.1:1", None)
            .await
            .unwrap_err();
        assert!(!err.is_empty());
    }

    #[tokio::test]
    async fn download_channel_artifact_returns_bytes() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        let rx = spawn_capturing_responder(listener, b"binary bytes".to_vec());

        let client = Client::new();
        let base_url = format!("http://127.0.0.1:{port}");
        let bytes = download_channel_artifact(&client, &base_url, "huskies-linux-arm64", None)
            .await
            .unwrap();
        assert_eq!(bytes, b"binary bytes");
        rx.await.unwrap();
    }

    #[tokio::test]
    async fn download_channel_artifact_sends_bearer_auth_header() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        let rx = spawn_capturing_responder(listener, b"bytes".to_vec());

        let client = Client::new();
        let base_url = format!("http://127.0.0.1:{port}");
        download_channel_artifact(&client, &base_url, "art", Some("tok"))
            .await
            .unwrap();

        let request = rx.await.unwrap();
        assert!(request.to_lowercase().contains("authorization: bearer tok"));
    }

    // ── install_pulled_artifact / read_installed_manifest ────────────────────

    #[test]
    fn install_pulled_artifact_writes_atomically_with_sidecars() {
        let dir = tempfile::tempdir().unwrap();
        let manifest = sample_signed_manifest().manifest;
        install_pulled_artifact(
            dir.path(),
            "huskies-linux-arm64",
            b"binary content",
            &manifest,
        )
        .unwrap();

        let artifact_path = dir.path().join("huskies-linux-arm64");
        assert_eq!(std::fs::read(&artifact_path).unwrap(), b"binary content");
        assert!(
            !dir.path().join(".pull.tmp").exists(),
            "tmp file must be renamed away"
        );

        let hash = std::fs::read_to_string(dir.path().join("huskies-linux-arm64.hash")).unwrap();
        assert_eq!(hash, "abc1234");

        let read_back = read_installed_manifest(dir.path(), "huskies-linux-arm64").unwrap();
        assert_eq!(read_back, manifest);
    }

    #[test]
    fn read_installed_manifest_missing_sidecar_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        assert!(read_installed_manifest(dir.path(), "huskies-linux-arm64").is_none());
    }

    // ── dedupe_key (story 1200 AC4) ──────────────────────────────────────────

    #[test]
    fn dedupe_key_disk_warning_keys_by_level() {
        let warn = crate::service::events::StoredEvent::DiskSpaceWarning {
            level: "warn".to_string(),
            free_bytes: 1,
            target_bytes: 0,
            worktrees_bytes: 0,
            host_id: "sled-a".to_string(),
            timestamp_ms: 0,
        };
        let critical = crate::service::events::StoredEvent::DiskSpaceWarning {
            level: "critical".to_string(),
            free_bytes: 1,
            target_bytes: 0,
            worktrees_bytes: 0,
            host_id: "sled-b".to_string(),
            timestamp_ms: 0,
        };
        // Different sleds at the same level share a key (so they dedupe)...
        assert_eq!(dedupe_key(&warn), dedupe_key(&warn));
        // ...but different levels do not.
        assert_ne!(dedupe_key(&warn), dedupe_key(&critical));
    }

    #[test]
    fn dedupe_key_disk_recovered_has_a_single_shared_key() {
        let a = crate::service::events::StoredEvent::DiskSpaceRecovered {
            free_bytes: 1,
            host_id: "sled-a".to_string(),
            timestamp_ms: 0,
        };
        let b = crate::service::events::StoredEvent::DiskSpaceRecovered {
            free_bytes: 2,
            host_id: "sled-b".to_string(),
            timestamp_ms: 1,
        };
        assert_eq!(dedupe_key(&a), dedupe_key(&b));
    }

    #[test]
    fn dedupe_key_non_disk_events_are_not_deduped() {
        let transition = crate::service::events::StoredEvent::StageTransition {
            story_id: "1_story".to_string(),
            story_name: String::new(),
            from_stage: "2_current".to_string(),
            to_stage: "3_qa".to_string(),
            timestamp_ms: 0,
        };
        assert_eq!(dedupe_key(&transition), None);
    }

    // ── fleet identity (story 1206) ──────────────────────────────────────────

    /// Spawn a one-shot TCP listener that answers a single `GET
    /// /identity?nonce=<hex>` request with a JSON body signed by `kp` over
    /// whatever nonce the caller actually sent. Mirrors the responder used in
    /// `chat::transport::matrix::sled_upgrade`'s tests.
    fn spawn_identity_responder(
        listener: tokio::net::TcpListener,
        kp: bft_json_crdt::keypair::Ed25519KeyPair,
    ) {
        tokio::spawn(async move {
            if let Ok((mut stream, _)) = listener.accept().await {
                use tokio::io::{AsyncReadExt, AsyncWriteExt};
                let mut buf = [0u8; 4096];
                let n = stream.read(&mut buf).await.unwrap_or(0);
                let req = String::from_utf8_lossy(&buf[..n]);
                let nonce = req
                    .lines()
                    .next()
                    .unwrap_or("")
                    .split("nonce=")
                    .nth(1)
                    .and_then(|s| s.split_whitespace().next())
                    .unwrap_or("")
                    .to_string();
                let pubkey = crate::node_identity::public_key_hex(&kp);
                let sig = crate::node_identity::sign_challenge(&kp, &nonce);
                let body = serde_json::json!({
                    "node_id": pubkey,
                    "pubkey": pubkey,
                    "signature": sig,
                })
                .to_string();
                let response = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
                    body.len(),
                    body
                );
                let _ = stream.write_all(response.as_bytes()).await;
            }
        });
    }

    /// Spawn a one-shot TCP listener that answers `GET /identity` with an
    /// unsigned body (no `signature` field) — a legacy sled.
    fn spawn_unsigned_identity_responder(listener: tokio::net::TcpListener) {
        tokio::spawn(async move {
            if let Ok((mut stream, _)) = listener.accept().await {
                use tokio::io::{AsyncReadExt, AsyncWriteExt};
                let mut buf = [0u8; 4096];
                let _ = stream.read(&mut buf).await;
                let body = serde_json::json!({
                    "node_id": "legacy-node-id",
                    "pubkey": "legacy-node-id",
                })
                .to_string();
                let response = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
                    body.len(),
                    body
                );
                let _ = stream.write_all(response.as_bytes()).await;
            }
        });
    }

    #[tokio::test]
    async fn probe_sled_identity_report_matches_recorded_pin() {
        let kp = bft_json_crdt::keypair::make_keypair();
        let pubkey = crate::node_identity::public_key_hex(&kp);

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        spawn_identity_responder(listener, kp);

        let entry = ProjectEntry {
            url: Some(format!("http://127.0.0.1:{port}")),
            auth_token: None,
            ssh_port: None,
            host_path: None,
            expected_node_id: Some(pubkey.clone()),
        };
        let client = reqwest::Client::new();
        let report = probe_sled_identity_report("myapp", &entry, &client).await;

        assert!(report.connected);
        assert!(report.matched);
        assert_eq!(report.live_node_id.as_deref(), Some(pubkey.as_str()));
    }

    #[tokio::test]
    async fn probe_sled_identity_report_no_url_is_unreachable() {
        let entry = ProjectEntry {
            url: None,
            auth_token: None,
            ssh_port: None,
            host_path: None,
            expected_node_id: Some("abc123".to_string()),
        };
        let client = reqwest::Client::new();
        let report = probe_sled_identity_report("myapp", &entry, &client).await;
        assert!(!report.connected);
        assert!(!report.matched);
    }

    #[tokio::test]
    async fn repin_sled_identity_captures_verified_node_id_and_persists() {
        let kp = bft_json_crdt::keypair::make_keypair();
        let pubkey = crate::node_identity::public_key_hex(&kp);

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        spawn_identity_responder(listener, kp);

        let sled_url = format!("http://127.0.0.1:{port}");
        let mut map = BTreeMap::new();
        map.insert(
            "myapp".to_string(),
            ProjectEntry {
                url: Some(sled_url.clone()),
                auth_token: None,
                ssh_port: None,
                host_path: None,
                // A stale/mismatched pin — re-pin must overwrite it.
                expected_node_id: Some("stale-node-id".to_string()),
            },
        );
        let store = std::sync::Arc::new(tokio::sync::RwLock::new(map));
        let config_dir = tempfile::tempdir().unwrap();
        let client = reqwest::Client::new();

        let result =
            repin_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await;
        assert_eq!(result, Ok(pubkey.clone()));

        let captured = store
            .read()
            .await
            .get("myapp")
            .and_then(|e| e.expected_node_id.clone());
        assert_eq!(captured, Some(pubkey.clone()));

        let toml_content = tokio::fs::read_to_string(config_dir.path().join("projects.toml")).await;
        assert!(
            toml_content.unwrap_or_default().contains(&pubkey),
            "re-pinned node_id should be persisted to projects.toml"
        );
    }

    #[tokio::test]
    async fn repin_sled_identity_refuses_invalid_signature() {
        // A responder that signs with a different key than it claims — an
        // untrustworthy identity that must never be pinned.
        let kp = bft_json_crdt::keypair::make_keypair();
        let other_kp = bft_json_crdt::keypair::make_keypair();
        let claimed_pubkey = crate::node_identity::public_key_hex(&other_kp);

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        tokio::spawn(async move {
            if let Ok((mut stream, _)) = listener.accept().await {
                use tokio::io::{AsyncReadExt, AsyncWriteExt};
                let mut buf = [0u8; 4096];
                let n = stream.read(&mut buf).await.unwrap_or(0);
                let req = String::from_utf8_lossy(&buf[..n]);
                let nonce = req
                    .lines()
                    .next()
                    .unwrap_or("")
                    .split("nonce=")
                    .nth(1)
                    .and_then(|s| s.split_whitespace().next())
                    .unwrap_or("")
                    .to_string();
                // Sign with `kp` but claim `other_kp`'s pubkey — verification must fail.
                let sig = crate::node_identity::sign_challenge(&kp, &nonce);
                let body = serde_json::json!({
                    "node_id": claimed_pubkey,
                    "pubkey": claimed_pubkey,
                    "signature": sig,
                })
                .to_string();
                let response = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
                    body.len(),
                    body
                );
                let _ = stream.write_all(response.as_bytes()).await;
            }
        });

        let sled_url = format!("http://127.0.0.1:{port}");
        let mut map = BTreeMap::new();
        map.insert(
            "myapp".to_string(),
            ProjectEntry {
                url: Some(sled_url.clone()),
                auth_token: None,
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );
        let store = std::sync::Arc::new(tokio::sync::RwLock::new(map));
        let config_dir = tempfile::tempdir().unwrap();
        let client = reqwest::Client::new();

        let result =
            repin_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await;
        let err = result.expect_err("invalid signature must refuse to pin");
        assert!(err.contains("refusing to pin"), "error: {err}");
        assert!(
            err.contains("myapp"),
            "error should name the project: {err}"
        );

        let captured = store
            .read()
            .await
            .get("myapp")
            .and_then(|e| e.expected_node_id.clone());
        assert!(captured.is_none(), "no pin should be captured on refusal");
    }

    #[tokio::test]
    async fn repin_sled_identity_refuses_missing_signature() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        spawn_unsigned_identity_responder(listener);

        let sled_url = format!("http://127.0.0.1:{port}");
        let mut map = BTreeMap::new();
        map.insert(
            "myapp".to_string(),
            ProjectEntry {
                url: Some(sled_url.clone()),
                auth_token: None,
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );
        let store = std::sync::Arc::new(tokio::sync::RwLock::new(map));
        let config_dir = tempfile::tempdir().unwrap();
        let client = reqwest::Client::new();

        let result =
            repin_sled_identity("myapp", &sled_url, &store, config_dir.path(), &client).await;
        let err = result.expect_err("missing signature must refuse to pin");
        assert!(err.contains("refusing to pin"), "error: {err}");
    }

    #[tokio::test]
    async fn repin_sled_identity_refuses_unreachable_sled() {
        let store = std::sync::Arc::new(tokio::sync::RwLock::new(BTreeMap::new()));
        let config_dir = tempfile::tempdir().unwrap();
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_millis(200))
            .build()
            .unwrap();

        let result = repin_sled_identity(
            "myapp",
            "http://127.0.0.1:1",
            &store,
            config_dir.path(),
            &client,
        )
        .await;
        let err = result.expect_err("unreachable sled must refuse to pin");
        assert!(err.contains("unreachable"), "error: {err}");
    }
}
