//! Gateway service — domain logic for the multi-project gateway.
//!
//! Follows the conventions in `docs/architecture/service-modules.md`:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration, `GatewayState`
//! - `io.rs` — the ONLY place that performs side effects (filesystem, network, process spawn)
//! - `config.rs` — pure config types and validation
//! - `aggregation.rs` — pure cross-project pipeline formatting
//! - `polling.rs` — pure notification event formatting

/// Cross-project pipeline status aggregation and formatting.
pub mod aggregation;
/// Gateway configuration types and TOML parsing.
pub mod config;
/// Pure identity-probe verification (match/mismatch/first-contact) — no I/O.
pub mod identity;
pub(crate) mod io;
/// Notification event polling for gateway-level broadcasts.
pub mod polling;
/// Pure signed release-manifest verification (signature, sha256, rollback) — no I/O.
pub mod release_manifest;
/// Fleet resource types and collection (`fleet_resources` MCP tool, story 1207).
pub mod resources;

pub use aggregation::{
    find_project_containing_story, format_aggregate_status_compact, format_identity_reports,
    format_overview_compact,
};
pub use config::{GatewayConfig, ProjectEntry};
pub use identity::{IdentityCheck, SledIdentityReport, check_identity};
pub use io::{
    fetch_all_project_pipeline_items, fetch_all_project_pipeline_statuses, probe_identity,
    spawn_gateway_broadcaster_forwarder,
};

use crate::http::context::PermissionForward;
use crate::rebuild::ShutdownReason;
use io::Client;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::RwLock;
use tokio::sync::mpsc;

pub use crate::crdt_state::NodePresenceView;

// ── Status event broadcaster ────────────────────────────────────────────────

/// Capacity of the gateway status event broadcast channel.
const EVENT_CHANNEL_CAPACITY: usize = 64;

/// A status event pushed by a project node and fanned out to all local
/// subscribers (e.g. the Web UI, notification forwarders).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct GatewayStatusEvent {
    /// The project name that emitted this event.
    pub project: String,
    /// The pipeline event payload.
    pub event: crate::service::events::StoredEvent,
}

// ── Sled connection ─────────────────────────────────────────────────────────

/// Maximum age, in milliseconds, of a sled heartbeat before the gateway
/// considers the connection stale (story 899 AC 3).
pub const HEARTBEAT_MAX_AGE_MS: i64 = 30_000;

/// Maximum event age in seconds before a sled relay is considered `silent` rather than `ok`.
/// Default is 10 minutes; configurable at the call site via this constant.
pub const RELAY_MAX_AGE_SECS: f64 = 600.0;

/// Default per-request timeout, in milliseconds, for an MCP call proxied over
/// a sled uplink WebSocket.  Mirrors the existing reqwest-based path which has
/// no explicit cap; we set a generous bound so long-running tools (e.g.
/// `run_tests`) still complete.
pub const MCP_VIA_WS_TIMEOUT_MS: u64 = 1_200_000;

/// Handle to a sled currently connected to the gateway via the uplink WebSocket.
///
/// Created by the `/api/sled-uplink` WS handler on connect and stored in
/// [`GatewayState::sled_connections`].  The gateway's MCP proxy reads this
/// to forward requests over the live connection rather than via HTTP.
#[derive(Clone)]
pub struct SledConnection {
    /// Sender side of the channel the WS handler reads to forward outgoing
    /// frames (e.g. `mcp_request`) to the sled.
    pub tx: mpsc::UnboundedSender<crate::sled_uplink::UplinkEnvelope>,
    /// Timestamp (ms since Unix epoch) of the last `heartbeat` frame received
    /// from this sled.  Updated atomically by the WS handler task.
    pub last_heartbeat_ms: Arc<AtomicI64>,
    /// In-flight MCP requests waiting for a matching `mcp_response` from this
    /// sled.  Keyed by `req_id`; the oneshot sender is resolved when the
    /// response arrives.
    pub in_flight:
        Arc<TokioMutex<HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>>>,
}

impl SledConnection {
    /// Returns `true` if a heartbeat has been received within the last `max_age_ms`
    /// milliseconds.
    pub fn is_alive(&self, max_age_ms: i64) -> bool {
        let last = self.last_heartbeat_ms.load(Ordering::Relaxed);
        let now = chrono::Utc::now().timestamp_millis();
        now - last <= max_age_ms
    }
}

/// Proxy a raw MCP request body to a sled over its uplink WebSocket and
/// return the serialised JSON response bytes.
///
/// Generates a fresh correlation id, registers a oneshot in the connection's
/// in-flight map, sends an `mcp_request` envelope, and waits for the
/// matching `mcp_response` (or [`MCP_VIA_WS_TIMEOUT_MS`] to elapse).
///
/// The response payload is serialised back into JSON bytes so callers can
/// return it directly to the HTTP client unchanged.
pub async fn proxy_mcp_via_ws(
    conn: &SledConnection,
    request_bytes: &[u8],
) -> Result<Vec<u8>, String> {
    let req_id = uuid::Uuid::new_v4().to_string();
    let body_str = std::str::from_utf8(request_bytes)
        .map_err(|e| format!("non-utf8 mcp request body: {e}"))?
        .to_string();

    let (tx, rx) = tokio::sync::oneshot::channel();
    conn.in_flight.lock().await.insert(req_id.clone(), tx);

    let env = crate::sled_uplink::UplinkEnvelope {
        msg_type: "mcp_request".to_string(),
        req_id: req_id.clone(),
        payload: serde_json::json!({ "body": body_str }),
    };

    if conn.tx.send(env).is_err() {
        conn.in_flight.lock().await.remove(&req_id);
        return Err("sled uplink connection closed".to_string());
    }

    let timeout = std::time::Duration::from_millis(MCP_VIA_WS_TIMEOUT_MS);
    match tokio::time::timeout(timeout, rx).await {
        Ok(Ok(response_value)) => {
            serde_json::to_vec(&response_value).map_err(|e| format!("serialise mcp_response: {e}"))
        }
        Ok(Err(_)) => Err("sled response channel dropped".to_string()),
        Err(_) => {
            conn.in_flight.lock().await.remove(&req_id);
            Err(format!(
                "mcp call to sled timed out after {MCP_VIA_WS_TIMEOUT_MS} ms"
            ))
        }
    }
}

// ── Error type ──────────────────────────────────────────────────────────────

/// Typed errors returned by `service::gateway` functions.
///
/// HTTP handlers map these to appropriate status codes:
/// - [`Error::ProjectNotFound`] → 404 Not Found
/// - [`Error::UnreachableProject`] → 502 Bad Gateway
/// - [`Error::DuplicateToken`] → 409 Conflict
/// - [`Error::InvalidAgent`] → 404 Not Found / 400 Bad Request
/// - [`Error::Config`] → 400 Bad Request
/// - [`Error::Upstream`] → 502 Bad Gateway
#[derive(Debug)]
pub enum Error {
    /// A referenced project does not exist in the gateway config.
    ProjectNotFound(String),
    /// A project container is unreachable.
    UnreachableProject(String),
    /// A join token has already been consumed or a project name is taken.
    DuplicateToken(String),
    /// An agent ID is invalid or not found.
    InvalidAgent(String),
    /// A configuration value is invalid.
    Config(String),
    /// An upstream project container returned an unexpected response.
    Upstream(String),
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ProjectNotFound(msg) => write!(f, "Project not found: {msg}"),
            Self::UnreachableProject(msg) => write!(f, "Unreachable project: {msg}"),
            Self::DuplicateToken(msg) => write!(f, "Duplicate token: {msg}"),
            Self::InvalidAgent(msg) => write!(f, "Invalid agent: {msg}"),
            Self::Config(msg) => write!(f, "Config error: {msg}"),
            Self::Upstream(msg) => write!(f, "Upstream error: {msg}"),
        }
    }
}

// ── Gateway state ───────────────────────────────────────────────────────────

/// A one-time join token that has been generated but not yet consumed.
pub(crate) struct PendingToken {
    #[allow(dead_code)]
    pub(crate) created_at: f64,
}

/// Shared gateway state threaded through HTTP handlers.
#[derive(Clone)]
pub struct GatewayState {
    /// The live set of registered projects (initially loaded from `projects.toml`).
    pub projects: Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
    /// The currently active project name.
    pub active_project: Arc<RwLock<String>>,
    /// HTTP client for proxying requests to project containers.
    pub client: Client,
    /// One-time join tokens that have been issued but not yet consumed.
    pub(crate) pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
    /// Directory containing `projects.toml` and the `.huskies/` subfolder.
    pub config_dir: PathBuf,
    /// HTTP port the gateway is listening on.
    pub port: u16,
    /// Abort handle for the running Matrix bot task (if any).
    pub bot_handle: Arc<TokioMutex<Option<tokio::task::AbortHandle>>>,
    /// Watch sender used to signal the Matrix bot to shut down gracefully.
    ///
    /// Send `Some(ShutdownReason)` on this channel before process exit so the
    /// bot task receives the signal and posts an "going offline" announcement.
    pub bot_shutdown_tx:
        Arc<TokioMutex<Option<tokio::sync::watch::Sender<Option<ShutdownReason>>>>>,
    /// Broadcast sender for [`GatewayStatusEvent`]s pushed by project nodes.
    ///
    /// Call `event_tx.subscribe()` to obtain a receiver for outbound fan-out.
    pub event_tx: tokio::sync::broadcast::Sender<GatewayStatusEvent>,
    /// Sender end of the gateway's permission channel.
    ///
    /// The sled-uplink handler uses this to inject `perm_request` messages
    /// received from connected sleds into the gateway's Matrix bot permission
    /// pipeline.
    pub perm_tx: mpsc::UnboundedSender<PermissionForward>,
    /// Registry of tasks registered to receive forwarded permission requests
    /// (shared with the gateway's embedded Matrix bot).
    ///
    /// The Matrix bot's `permission_listener` registers with this for its
    /// lifetime; the sled-uplink WS handler sends requests via `perm_tx`,
    /// which the router task (spawned in [`GatewayState::new`]) dispatches
    /// into this registry.
    pub permission_registry: Arc<crate::service::permission_router::ResponderRegistry>,
    /// Reversed sled-token map: token → project_name (sled_id).
    ///
    /// Built at startup from both [`GatewayConfig::sled_tokens`] AND the
    /// per-project `auth_token` field (story 899).  The handler looks up
    /// incoming tokens in O(1) to identify the project the sled represents.
    pub sled_tokens: HashMap<String, String>,
    /// Live sled connections keyed by project name.
    ///
    /// Populated by the `/api/sled-uplink` WS handler when a sled authenticates
    /// and depopulated when it disconnects.  MCP proxy functions check here
    /// first (WS route), falling back to HTTP when no live connection exists.
    pub sled_connections: Arc<RwLock<HashMap<String, SledConnection>>>,
    /// Configured signed release channels (story 1169), keyed by channel name.
    ///
    /// Read by the `pull <channel>` gateway chat command to resolve a
    /// channel's `base_url`, pinned `pubkey`, and optional `bearer_token`.
    pub release_channels: Arc<RwLock<BTreeMap<String, config::ReleaseChannelConfig>>>,
}

impl GatewayState {
    /// Create a new gateway state from a config and config directory.
    ///
    /// The active project is restored from the CRDT `gateway_config.active_project`
    /// register when available.  Falls back to the first project in the config.
    /// Agent registrations are stored in the CRDT nodes collection.
    pub fn new(
        gateway_config: GatewayConfig,
        config_dir: PathBuf,
        port: u16,
    ) -> Result<Self, String> {
        let first_from_config = config::validate_config(&gateway_config)?;
        // Restore active project from CRDT if the stored value is still valid.
        let first = crate::crdt_state::read_gateway_active_project()
            .filter(|p| gateway_config.projects.contains_key(p))
            .unwrap_or(first_from_config);
        let (event_tx, _) = tokio::sync::broadcast::channel(EVENT_CHANNEL_CAPACITY);
        let (perm_tx, perm_rx) = mpsc::unbounded_channel::<PermissionForward>();
        let permission_registry = crate::service::permission_router::ResponderRegistry::new();
        // `GatewayState::new` is also called from plain `#[test]` fns with no
        // tokio runtime; skip spawning when there's no reactor to spawn onto
        // since those tests never exercise the permission plumbing.
        if tokio::runtime::Handle::try_current().is_ok() {
            crate::service::permission_router::spawn_permission_router(
                perm_rx,
                Arc::clone(&permission_registry),
            );
        }

        // Build token→project_name map from two sources:
        // 1. Legacy top-level [sled_tokens] section (sled_id → token, reversed)
        // 2. Per-project auth_token fields (project_name → token, reversed)
        let mut sled_tokens: HashMap<String, String> = gateway_config
            .sled_tokens
            .iter()
            .map(|(sled_id, token)| (token.clone(), sled_id.clone()))
            .collect();
        for (project_name, entry) in &gateway_config.projects {
            if let Some(ref token) = entry.auth_token {
                sled_tokens.insert(token.clone(), project_name.clone());
            }
        }

        Ok(Self {
            projects: Arc::new(RwLock::new(gateway_config.projects)),
            active_project: Arc::new(RwLock::new(first)),
            client: Client::new(),
            pending_tokens: Arc::new(RwLock::new(HashMap::new())),
            config_dir,
            port,
            bot_handle: Arc::new(TokioMutex::new(None)),
            bot_shutdown_tx: Arc::new(TokioMutex::new(None)),
            event_tx,
            perm_tx,
            permission_registry,
            sled_tokens,
            sled_connections: Arc::new(RwLock::new(HashMap::new())),
            release_channels: Arc::new(RwLock::new(gateway_config.release_channels)),
        })
    }

    /// Get the URL of the currently active project, if one is configured.
    ///
    /// Returns `Err` when the active project has no URL configured (WS-uplink
    /// only) or the project name is not found.
    pub async fn active_url(&self) -> Result<String, Error> {
        let name = self.active_project.read().await.clone();
        self.projects
            .read()
            .await
            .get(&name)
            .and_then(|p| p.url.clone())
            .ok_or_else(|| {
                Error::ProjectNotFound(format!(
                    "active project '{name}' has no URL configured \
                     (use sled-uplink WS or add url to projects.toml)"
                ))
            })
    }

    /// Register a live sled connection for the given project.
    pub async fn register_sled_connection(&self, project_name: String, conn: SledConnection) {
        self.sled_connections
            .write()
            .await
            .insert(project_name, conn);
    }

    /// Remove the sled connection for the given project (on disconnect).
    pub async fn deregister_sled_connection(&self, project_name: &str) {
        self.sled_connections.write().await.remove(project_name);
    }

    /// Look up the live sled connection for an explicit project name,
    /// returning a clone if one exists and has a recent heartbeat.
    ///
    /// Returns `None` when no sled has connected for this project or when its
    /// heartbeat is overdue. Generalizes [`GatewayState::active_sled_connection`]
    /// to any registered project rather than only the active one (story 1208).
    pub async fn sled_connection_for(&self, project_name: &str) -> Option<SledConnection> {
        let conn = self
            .sled_connections
            .read()
            .await
            .get(project_name)
            .cloned()?;
        if conn.is_alive(HEARTBEAT_MAX_AGE_MS) {
            Some(conn)
        } else {
            None
        }
    }

    /// Look up the live sled connection for the active project, returning a
    /// clone if one exists and has a recent heartbeat.
    ///
    /// Returns `None` when no sled has connected for this project or when its
    /// heartbeat is overdue.
    pub async fn active_sled_connection(&self) -> Option<SledConnection> {
        let name = self.active_project.read().await.clone();
        self.sled_connection_for(&name).await
    }

    /// Proxy an MCP request to the active project over its live sled-uplink
    /// WebSocket (story 899 AC 2).
    ///
    /// The gateway is WS-only for MCP proxying (story 1180): when no live
    /// uplink connection exists for the active project, this returns an
    /// immediate, actionable error naming the sled rather than falling back
    /// to HTTP. Callers (e.g. bot chat commands) surface this error straight
    /// to the user instead of hanging on an unreachable HTTP endpoint.
    ///
    /// Returns the raw response body bytes ready to be relayed to the caller.
    pub async fn proxy_active_mcp(&self, bytes: &[u8]) -> Result<Vec<u8>, String> {
        let name = self.active_project.read().await.clone();
        match self.active_sled_connection().await {
            Some(conn) => proxy_mcp_via_ws(&conn, bytes).await,
            None => Err(format!(
                "sled '{name}' has no live WS uplink connection; \
                 ensure the sled is running and connected to this gateway"
            )),
        }
    }

    /// Proxy an MCP request to an explicitly named project over its live
    /// sled-uplink WebSocket, without reading or mutating `active_project`
    /// (story 1208 AC 1).
    ///
    /// Lets a single ops/LLM session address any registered project by name
    /// on a per-call basis instead of first mutating the shared
    /// `active_project` via `switch_project` — which avoids the race where a
    /// second concurrent caller's tool calls get proxied to the wrong
    /// project mid-session.
    ///
    /// Returns the raw response body bytes ready to be relayed to the caller.
    pub async fn proxy_mcp_for_project(
        &self,
        project: &str,
        bytes: &[u8],
    ) -> Result<Vec<u8>, String> {
        match self.sled_connection_for(project).await {
            Some(conn) => proxy_mcp_via_ws(&conn, bytes).await,
            None => Err(format!(
                "sled '{project}' has no live WS uplink connection; \
                 ensure the sled is running and connected to this gateway"
            )),
        }
    }
}

// ── Public API ──────────────────────────────────────────────────────────────

/// Switch the active project. Returns the project's URL (empty for WS-only projects).
///
/// Writes the new active project to the CRDT `gateway_config.active_project`
/// register (LWW — last write wins) so the selection is persisted across
/// restarts and replicated to connected peers.
pub async fn switch_project(state: &GatewayState, project: &str) -> Result<String, Error> {
    if project.is_empty() {
        return Err(Error::Config("missing required parameter: project".into()));
    }

    let url = {
        let projects = state.projects.read().await;
        config::validate_project_exists(&projects, project).map_err(Error::ProjectNotFound)?
    };

    *state.active_project.write().await = project.to_string();
    crate::crdt_state::write_gateway_active_project(project);
    Ok(url)
}

/// Generate a one-time join token. Returns the token string.
pub async fn generate_join_token(state: &GatewayState) -> String {
    let token = uuid::Uuid::new_v4().to_string();
    let now = chrono::Utc::now().timestamp() as f64;
    state
        .pending_tokens
        .write()
        .await
        .insert(token.clone(), PendingToken { created_at: now });
    crate::slog!("[gateway] Generated join token {:.8}…", &token);
    token
}

/// Register a new build agent using a one-time join token.
///
/// Validates and consumes the token, then writes the agent's node presence
/// and metadata to the CRDT collection.  Returns the newly-created node view.
pub async fn register_agent(
    state: &GatewayState,
    token: &str,
    label: String,
    address: String,
) -> Result<NodePresenceView, Error> {
    {
        let mut tokens = state.pending_tokens.write().await;
        if !tokens.contains_key(token) {
            return Err(Error::InvalidAgent(
                "invalid or already-used join token".into(),
            ));
        }
        tokens.remove(token);
    }

    let node_id = uuid::Uuid::new_v4().to_string();
    let now = chrono::Utc::now().timestamp() as f64;
    let now_ms = chrono::Utc::now().timestamp_millis() as f64;

    crate::crdt_state::write_node_presence(&node_id, &address, now, true);
    crate::crdt_state::write_node_metadata(&node_id, &label, None, now_ms);

    crate::slog!(
        "[gateway] Registered agent '{label}' node_id={:.12}…",
        &node_id
    );

    crate::crdt_state::read_all_node_presence()
        .unwrap_or_default()
        .into_iter()
        .find(|n| n.node_id == node_id)
        .ok_or_else(|| Error::Upstream("node write did not persist".into()))
}

/// Tombstone a registered agent in the CRDT (set `alive = false`).
///
/// Returns `true` if the node was found and tombstoned.
pub fn remove_agent(node_id: &str) -> bool {
    let nodes = crate::crdt_state::read_all_node_presence().unwrap_or_default();
    let Some(node) = nodes.iter().find(|n| n.node_id == node_id) else {
        return false;
    };
    let now = chrono::Utc::now().timestamp() as f64;
    crate::crdt_state::write_node_presence(node_id, &node.address, now, false);
    true
}

/// Assign (or unassign) an agent to a project in the CRDT.
///
/// Validates that the project exists in the gateway config (when assigning),
/// then writes the updated `assigned_project` field to the CRDT.
pub async fn assign_agent(
    state: &GatewayState,
    node_id: &str,
    project: Option<String>,
) -> Result<NodePresenceView, Error> {
    if let Some(ref p) = project {
        let projects = state.projects.read().await;
        if !projects.contains_key(p.as_str()) {
            return Err(Error::ProjectNotFound(format!("unknown project '{p}'")));
        }
    }

    let nodes = crate::crdt_state::read_all_node_presence().unwrap_or_default();
    let node = nodes
        .iter()
        .find(|n| n.node_id == node_id)
        .ok_or_else(|| Error::InvalidAgent(format!("agent not found: {node_id}")))?;

    let now_ms = chrono::Utc::now().timestamp_millis() as f64;
    crate::crdt_state::write_node_metadata(
        node_id,
        node.label.as_deref().unwrap_or(""),
        project.as_deref(),
        now_ms,
    );

    crate::crdt_state::read_all_node_presence()
        .unwrap_or_default()
        .into_iter()
        .find(|n| n.node_id == node_id)
        .ok_or_else(|| Error::Upstream("node write did not persist".into()))
}

/// Update an agent's heartbeat via CRDT. Returns `true` if the node was found.
pub fn heartbeat_agent(id: &str) -> bool {
    let now = chrono::Utc::now().timestamp() as f64;
    let nodes = crate::crdt_state::read_all_node_presence().unwrap_or_default();
    let Some(node) = nodes.iter().find(|n| n.node_id == id) else {
        return false;
    };
    crate::crdt_state::write_node_presence(id, &node.address, now, node.alive);
    true
}

/// List all registered build agents from the CRDT nodes collection.
pub fn list_agents() -> Vec<NodePresenceView> {
    crate::crdt_state::read_all_node_presence()
        .unwrap_or_default()
        .into_iter()
        .filter(|n| n.alive)
        .collect()
}

/// Add a new project to the gateway config.
pub async fn add_project(state: &GatewayState, name: &str, url: &str) -> Result<(), Error> {
    let name = name.trim().to_string();
    let url = url.trim().to_string();

    if name.is_empty() {
        return Err(Error::Config("project name must not be empty".into()));
    }
    if url.is_empty() {
        return Err(Error::Config("project url must not be empty".into()));
    }

    {
        let mut projects = state.projects.write().await;
        if projects.contains_key(&name) {
            return Err(Error::DuplicateToken(format!(
                "project '{name}' already exists"
            )));
        }
        projects.insert(name.clone(), ProjectEntry::with_url(&url));
    }

    let snapshot = state.projects.read().await.clone();
    io::save_config(&snapshot, &state.config_dir).await;
    crate::slog!("[gateway] Added project '{name}' ({url})");
    Ok(())
}

/// Remove a project from the gateway config.
pub async fn remove_project(state: &GatewayState, name: &str) -> Result<(), Error> {
    let active = state.active_project.read().await.clone();

    {
        let mut projects = state.projects.write().await;
        if !projects.contains_key(name) {
            return Err(Error::ProjectNotFound(format!(
                "project '{name}' not found"
            )));
        }
        if projects.len() == 1 {
            return Err(Error::Config("cannot remove the last project".into()));
        }
        projects.remove(name);
    }

    let snapshot = state.projects.read().await.clone();
    io::save_config(&snapshot, &state.config_dir).await;

    // If the removed project was active, switch to the first remaining.
    if active == name {
        let first = state.projects.read().await.keys().next().cloned();
        if let Some(new_active) = first {
            *state.active_project.write().await = new_active;
        }
    }

    crate::slog!("[gateway] Removed project '{name}'");
    Ok(())
}

/// Initialise a new huskies project at the given path.
///
/// Optionally registers the project in the gateway's project map.
pub async fn init_project(
    state: &GatewayState,
    path_str: &str,
    name: Option<&str>,
    url: Option<&str>,
) -> Result<Option<String>, Error> {
    let path_str = path_str.trim();
    if path_str.is_empty() {
        return Err(Error::Config("missing required parameter: path".into()));
    }

    let project_path = std::path::Path::new(path_str);

    if io::has_huskies_dir(project_path) {
        return Err(Error::Config(format!(
            "path '{}' is already a huskies project (.huskies/ exists). \
             Use wizard_status to check setup progress.",
            project_path.display()
        )));
    }

    io::ensure_directory(project_path).map_err(Error::Config)?;

    io::scaffold_project(project_path)
        .map_err(|e| Error::Config(format!("scaffold failed: {e}")))?;

    io::init_wizard_state(project_path);

    // Optionally register in projects.toml.
    let registered_name: Option<String> = match (name, url) {
        (Some(n), Some(u)) if !n.trim().is_empty() && !u.trim().is_empty() => {
            let n = n.trim();
            let u = u.trim();
            let mut projects = state.projects.write().await;
            if projects.contains_key(n) {
                return Err(Error::DuplicateToken(format!(
                    "project '{n}' is already registered. Choose a different name or use switch_project."
                )));
            }
            projects.insert(n.to_string(), ProjectEntry::with_url(u));
            io::save_config(&projects, &state.config_dir).await;
            crate::slog!("[gateway] init_project: registered '{n}' ({u})");
            Some(n.to_string())
        }
        _ => None,
    };

    Ok(registered_name)
}

/// Broadcast a status event received from a project node to all local subscribers.
///
/// For [`crate::service::events::StoredEvent::StageTransition`] events the
/// transition is also appended to the gateway's CRDT event log using the
/// project name as the `sled_id`.  This builds the live tail-merged aggregate
/// view that [`crate::crdt_state::assemble_and_advance_session`] reads when a
/// session's [`crate::crdt_state::ScopeFilter`] is set to `All`.
///
/// Returns the number of active receivers that received the event.
/// A return value of zero means no subscribers are currently connected.
pub fn broadcast_status_event(
    state: &GatewayState,
    project: String,
    event: crate::service::events::StoredEvent,
) -> usize {
    // Append StageTransition events to the gateway CRDT so the session
    // assembler can deliver a unified cross-sled event stream.
    if let crate::service::events::StoredEvent::StageTransition {
        ref story_id,
        ref from_stage,
        ref to_stage,
        timestamp_ms,
        ..
    } = event
    {
        let timestamp_secs = timestamp_ms as f64 / 1_000.0_f64;
        crate::crdt_state::append_event_log_entry(
            &project,
            timestamp_secs,
            story_id,
            from_stage,
            to_stage,
            "StageTransition",
        );
    }
    let msg = GatewayStatusEvent { project, event };
    state.event_tx.send(msg).unwrap_or(0)
}

/// Subscribe to the gateway's status event stream.
///
/// Returns a broadcast receiver that will yield [`GatewayStatusEvent`]s as
/// project nodes push them.  If the receiver falls behind (more than
/// [`EVENT_CHANNEL_CAPACITY`] events are queued), it will receive a
/// [`tokio::sync::broadcast::error::RecvError::Lagged`] error; callers
/// should discard lagged events and continue.
pub fn subscribe_status_events(
    state: &GatewayState,
) -> tokio::sync::broadcast::Receiver<GatewayStatusEvent> {
    state.event_tx.subscribe()
}

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

/// Read mode for the `fleet_identity` MCP tool: probe every registered
/// project's `/identity` endpoint and report the recorded pin next to the
/// live, signature-verified identity.
///
/// Projects are probed concurrently (bounded by however many are
/// registered — gateways register at most a handful of sleds).
pub async fn fleet_identity_read(state: &GatewayState) -> Vec<SledIdentityReport> {
    let projects = state.projects.read().await.clone();
    let probes = projects
        .iter()
        .map(|(name, entry)| io::probe_sled_identity_report(name, entry, &state.client));
    futures::future::join_all(probes).await
}

/// Re-pin action for the `fleet_identity` MCP tool: capture `project`'s
/// live, cryptographically-verified identity via TOFU and persist it as the
/// new expected pin.
///
/// **Does this need a gateway restart?** No (verified against
/// [`GatewayState`]'s design, AC 4). `GatewayState::projects` is the single
/// `Arc<RwLock<BTreeMap<String, ProjectEntry>>>` that every gateway code path
/// reads fresh on each call — [`GatewayState::active_url`], the MCP proxy,
/// `/health` polling, and the `upgrade` command's own identity check
/// (`verify_sled_identity`) all call `.read().await` on it rather than
/// caching a snapshot at startup. [`io::repin_sled_identity`] writes through
/// this same `Arc` and persists to `projects.toml`, so the next call from any
/// of those paths — with no restart — sees the new pin. This mirrors how
/// `verify_sled_identity`'s first-contact capture already updates the pin
/// live during an `upgrade` without requiring a restart.
pub async fn fleet_identity_repin(state: &GatewayState, project: &str) -> Result<String, Error> {
    let url = {
        let projects = state.projects.read().await;
        config::validate_project_exists(&projects, project).map_err(Error::ProjectNotFound)?
    };
    if url.is_empty() {
        return Err(Error::Config(format!(
            "project '{project}' has no URL configured; cannot probe it for re-pinning"
        )));
    }

    io::repin_sled_identity(
        project,
        &url,
        &state.projects,
        &state.config_dir,
        &state.client,
    )
    .await
    .map_err(Error::Upstream)
}

/// Save bot config and restart the bot.
pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) -> Result<(), Error> {
    io::write_bot_config(&state.config_dir, content).map_err(Error::Config)?;

    // Abort existing bot task and spawn a fresh one.
    {
        let mut handle = state.bot_handle.lock().await;
        if let Some(h) = handle.take() {
            h.abort();
        }
        let (new_handle, new_shutdown_tx) = io::spawn_gateway_bot(
            &state.config_dir,
            Arc::clone(&state.active_project),
            Arc::clone(&state.projects),
            state.port,
            Some(state.event_tx.clone()),
            Arc::clone(&state.permission_registry),
            Arc::clone(&state.release_channels),
        );
        *handle = new_handle;
        *state.bot_shutdown_tx.lock().await = Some(new_shutdown_tx);
    }

    crate::slog!("[gateway] Bot configuration saved; bot restarted");
    Ok(())
}

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

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

    fn make_config(names: &[(&str, &str)]) -> GatewayConfig {
        let mut projects = BTreeMap::new();
        for (name, url) in names {
            projects.insert(name.to_string(), ProjectEntry::with_url(*url));
        }
        GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        }
    }

    #[test]
    fn gateway_state_rejects_empty_config() {
        let config = GatewayConfig {
            projects: BTreeMap::new(),
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err());
    }

    #[test]
    fn gateway_state_sets_first_project_active() {
        let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        let active = state.active_project.blocking_read().clone();
        assert_eq!(active, "alpha");
    }

    #[tokio::test]
    async fn switch_project_to_known_project() {
        let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        let url = switch_project(&state, "beta").await.unwrap();
        assert_eq!(url, "http://b:3002");
        assert_eq!(*state.active_project.read().await, "beta");
    }

    #[tokio::test]
    async fn switch_project_to_unknown_fails() {
        let config = make_config(&[("alpha", "http://a:3001")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        assert!(switch_project(&state, "nonexistent").await.is_err());
    }

    #[tokio::test]
    async fn switch_project_empty_name_fails() {
        let config = make_config(&[("alpha", "http://a:3001")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        assert!(switch_project(&state, "").await.is_err());
    }

    #[tokio::test]
    async fn active_url_returns_correct_url() {
        let config = make_config(&[("myproj", "http://my:3001")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        let url = state.active_url().await.unwrap();
        assert_eq!(url, "http://my:3001");
    }

    #[tokio::test]
    async fn active_url_fails_for_ws_only_project() {
        let mut projects = BTreeMap::new();
        projects.insert(
            "ws-proj".into(),
            ProjectEntry {
                url: None,
                auth_token: Some("tok".into()),
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        assert!(state.active_url().await.is_err());
    }

    #[test]
    fn error_display_variants() {
        assert!(
            Error::ProjectNotFound("x".into())
                .to_string()
                .contains("Project not found")
        );
        assert!(
            Error::UnreachableProject("x".into())
                .to_string()
                .contains("Unreachable")
        );
        assert!(
            Error::DuplicateToken("x".into())
                .to_string()
                .contains("Duplicate")
        );
        assert!(
            Error::InvalidAgent("x".into())
                .to_string()
                .contains("Invalid agent")
        );
        assert!(
            Error::Config("x".into())
                .to_string()
                .contains("Config error")
        );
        assert!(Error::Upstream("x".into()).to_string().contains("Upstream"));
    }

    #[tokio::test]
    async fn register_agent_consumes_token_and_writes_crdt() {
        crate::crdt_state::init_for_test();
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let token = generate_join_token(&state).await;
        let node = register_agent(&state, &token, "test-agent".into(), "ws://a".into())
            .await
            .unwrap();
        assert_eq!(node.label.as_deref(), Some("test-agent"));
        assert!(state.pending_tokens.read().await.is_empty());
        let agents = list_agents();
        assert!(agents.iter().any(|n| n.node_id == node.node_id));
    }

    #[tokio::test]
    async fn register_agent_invalid_token_fails() {
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = register_agent(&state, "bad-token", "a".into(), "ws://a".into()).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn remove_agent_tombstones_crdt_node() {
        crate::crdt_state::init_for_test();
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let token = generate_join_token(&state).await;
        let node = register_agent(&state, &token, "a".into(), "ws://a".into())
            .await
            .unwrap();
        assert!(remove_agent(&node.node_id));
        let alive = list_agents();
        assert!(!alive.iter().any(|n| n.node_id == node.node_id));
    }

    #[tokio::test]
    async fn heartbeat_agent_returns_true_for_known_node() {
        crate::crdt_state::init_for_test();
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let token = generate_join_token(&state).await;
        let node = register_agent(&state, &token, "a".into(), "ws://a".into())
            .await
            .unwrap();
        assert!(heartbeat_agent(&node.node_id));
    }

    #[tokio::test]
    async fn init_project_scaffolds_directory() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = init_project(&state, dir.path().to_str().unwrap(), None, None).await;
        assert!(result.is_ok());
        assert!(dir.path().join(".huskies").exists());
    }

    #[tokio::test]
    async fn init_project_already_exists_fails() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = init_project(&state, dir.path().to_str().unwrap(), None, None).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn sled_connection_registration_and_lookup() {
        let config = make_config(&[("myproj", "http://myproj:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();

        let (tx, _rx) = mpsc::unbounded_channel();
        let conn = SledConnection {
            tx,
            last_heartbeat_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
            in_flight: Arc::new(TokioMutex::new(HashMap::new())),
        };

        state
            .register_sled_connection("myproj".to_string(), conn)
            .await;
        assert!(state.sled_connections.read().await.contains_key("myproj"));

        state.deregister_sled_connection("myproj").await;
        assert!(!state.sled_connections.read().await.contains_key("myproj"));
    }

    // ── explicit per-project proxy targeting (story 1208) ──────────────────

    #[tokio::test]
    async fn sled_connection_for_unregistered_project_returns_none() {
        let config = make_config(&[("alpha", "http://a:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        assert!(state.sled_connection_for("nonexistent").await.is_none());
    }

    #[tokio::test]
    async fn sled_connection_for_finds_connection_by_explicit_name_not_active() {
        let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        // "alpha" is active (first project), but the live connection is for "beta".
        assert_eq!(*state.active_project.read().await, "alpha");

        let (tx, _rx) = mpsc::unbounded_channel();
        let conn = SledConnection {
            tx,
            last_heartbeat_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
            in_flight: Arc::new(TokioMutex::new(HashMap::new())),
        };
        state
            .register_sled_connection("beta".to_string(), conn)
            .await;

        assert!(state.sled_connection_for("beta").await.is_some());
        assert!(state.active_sled_connection().await.is_none());
    }

    #[tokio::test]
    async fn proxy_mcp_for_project_without_live_connection_fails() {
        let config = make_config(&[("alpha", "http://a:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = state.proxy_mcp_for_project("alpha", b"{}").await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("no live WS uplink"));
    }

    #[tokio::test]
    async fn auth_token_in_project_entry_populates_sled_tokens_map() {
        let mut projects = BTreeMap::new();
        projects.insert(
            "huskies".into(),
            ProjectEntry {
                url: Some("http://huskies:3001".into()),
                auth_token: Some("secret-token".into()),
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        assert_eq!(
            state.sled_tokens.get("secret-token").map(|s| s.as_str()),
            Some("huskies"),
            "Per-project auth_token must be in reversed sled_tokens map"
        );
    }

    // ── fleet_identity (story 1206) ─────────────────────────────────────────

    #[tokio::test]
    async fn fleet_identity_read_reports_unreachable_project_with_no_url() {
        let mut projects = BTreeMap::new();
        projects.insert(
            "myapp".to_string(),
            ProjectEntry {
                url: None,
                auth_token: None,
                ssh_port: None,
                host_path: None,
                expected_node_id: Some("abc".to_string()),
            },
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let reports = fleet_identity_read(&state).await;
        assert_eq!(reports.len(), 1);
        assert_eq!(reports[0].project, "myapp");
        assert!(!reports[0].connected);
    }

    #[tokio::test]
    async fn fleet_identity_repin_unknown_project_fails() {
        let config = make_config(&[("alpha", "http://a:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = fleet_identity_repin(&state, "nonexistent").await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn fleet_identity_repin_project_without_url_fails() {
        let mut projects = BTreeMap::new();
        projects.insert(
            "ws-only".to_string(),
            ProjectEntry {
                url: None,
                auth_token: Some("tok".into()),
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = fleet_identity_repin(&state, "ws-only").await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn fleet_identity_repin_updates_live_state_without_restart() {
        // Regression test for AC 4: re-pinning must be visible to a *second*,
        // independent read through the same `GatewayState` without any
        // restart step in between — proving the pin lives in the shared
        // in-memory `Arc<RwLock<..>>`, not some snapshot taken at startup.
        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();
        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;
            }
        });

        let sled_url = format!("http://127.0.0.1:{port}");
        let mut projects = BTreeMap::new();
        projects.insert("myapp".to_string(), ProjectEntry::with_url(&sled_url));
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();

        let repinned = fleet_identity_repin(&state, "myapp").await.unwrap();
        assert_eq!(repinned, pubkey);

        // No restart, no new GatewayState — read straight off the same `state`.
        let pin = state
            .projects
            .read()
            .await
            .get("myapp")
            .and_then(|e| e.expected_node_id.clone());
        assert_eq!(
            pin,
            Some(pubkey),
            "re-pinned identity must be visible immediately on the same live state"
        );
    }
}
