//! WebSocket handlers for CRDT-sync agent registration and event push.

use crate::service::gateway::{self, GatewayState};
use futures::{SinkExt, StreamExt};
use poem::handler;
use poem::http::StatusCode;
use poem::web::websocket::{Message as WsMessage, WebSocket};
use poem::web::{Data, Query};
use serde::Deserialize;
use std::sync::Arc;

// ── CRDT-sync WebSocket handler (agent registration) ─────────────────────────

/// Query parameters accepted on the `/crdt-sync` WebSocket upgrade in gateway mode.
#[derive(Deserialize)]
struct GatewayCrdtSyncParams {
    /// One-time join token from `POST /gateway/tokens`.
    token: Option<String>,
    /// Human-readable label for the connecting agent.
    label: Option<String>,
    /// WebSocket address the agent exposes for mesh connections
    /// (e.g. `ws://0.0.0.0:3002/crdt-sync`).
    address: Option<String>,
}

/// `GET /crdt-sync` — gateway-side WebSocket endpoint for build agent registration.
///
/// # Authentication
///
/// The connecting node must supply a valid one-time join token via the `token`
/// query parameter, obtained from `POST /gateway/tokens`.  The token is
/// validated pre-upgrade and consumed by [`gateway::register_agent`] once the
/// WebSocket is established.
///
/// # Protocol
///
/// 1. Pre-upgrade: token existence is verified (read-only).
/// 2. Post-upgrade: [`gateway::register_agent`] writes the node to the CRDT and
///    consumes the token.
/// 3. Keepalive: the server sends Ping frames every 30 s; each Pong resets the
///    pong deadline and calls [`gateway::heartbeat_agent`].
/// 4. Disconnect: on clean close or pong timeout, [`gateway::remove_agent`]
///    tombstones the node (`alive = false`).
#[handler]
pub async fn gateway_crdt_sync_handler(
    ws: WebSocket,
    state: Data<&Arc<GatewayState>>,
    Query(params): Query<GatewayCrdtSyncParams>,
    remote_addr: &poem::web::RemoteAddr,
) -> poem::Response {
    use crate::crdt_sync::{PING_INTERVAL_SECS, PONG_TIMEOUT_SECS};

    // ── Pre-upgrade: validate token exists ─────────────────────────────
    let token = match params.token {
        Some(t) if !t.is_empty() => t,
        _ => {
            return poem::Response::builder()
                .status(StatusCode::UNAUTHORIZED)
                .body("token query parameter required");
        }
    };

    {
        let tokens = state.pending_tokens.read().await;
        if !tokens.contains_key(&token) {
            return poem::Response::builder()
                .status(StatusCode::UNAUTHORIZED)
                .body("invalid or already-used join token");
        }
    }

    let label = params
        .label
        .filter(|l| !l.is_empty())
        .unwrap_or_else(|| "build-agent".to_string());
    let address = params
        .address
        .filter(|a| !a.is_empty())
        .unwrap_or_else(|| remote_addr.to_string());

    // ── WebSocket upgrade ───────────────────────────────────────────────
    use poem::IntoResponse as _;
    let state = Arc::clone(&state);
    ws.on_upgrade(move |socket| async move {
        // Register the agent — consumes the one-time token.
        let node = match gateway::register_agent(&state, &token, label, address).await {
            Ok(n) => n,
            Err(e) => {
                crate::slog!("[gateway/crdt-sync] Registration failed: {e}");
                return;
            }
        };
        let node_id = node.node_id.clone();
        crate::slog!(
            "[gateway/crdt-sync] Agent '{}' registered, node_id={:.12}…",
            node.label.as_deref().unwrap_or("?"),
            &node_id
        );

        let (mut sink, mut stream) = socket.split();

        let mut pong_deadline =
            tokio::time::Instant::now() + std::time::Duration::from_secs(PONG_TIMEOUT_SECS);
        let mut ping_ticker = tokio::time::interval_at(
            tokio::time::Instant::now() + std::time::Duration::from_secs(PING_INTERVAL_SECS),
            std::time::Duration::from_secs(PING_INTERVAL_SECS),
        );

        loop {
            tokio::select! {
                _ = ping_ticker.tick() => {
                    if tokio::time::Instant::now() >= pong_deadline {
                        crate::slog!(
                            "[gateway/crdt-sync] No pong from {:.12}… in {}s; disconnecting",
                            &node_id, PONG_TIMEOUT_SECS
                        );
                        break;
                    }
                    if sink.send(WsMessage::Ping(vec![])).await.is_err() {
                        break;
                    }
                }
                frame = stream.next() => {
                    match frame {
                        Some(Ok(WsMessage::Pong(_))) => {
                            pong_deadline = tokio::time::Instant::now()
                                + std::time::Duration::from_secs(PONG_TIMEOUT_SECS);
                            gateway::heartbeat_agent(&node_id);
                        }
                        Some(Ok(WsMessage::Ping(data))) => {
                            let _ = sink.send(WsMessage::Pong(data)).await;
                        }
                        Some(Ok(WsMessage::Close(_))) | None => break,
                        _ => {}
                    }
                }
            }
        }

        gateway::remove_agent(&node_id);
        crate::slog!(
            "[gateway/crdt-sync] Agent {:.12}… disconnected and tombstoned",
            &node_id
        );
    })
    .into_response()
}

// ── Sled uplink WebSocket handler ────────────────────────────────────────────

/// Query parameters accepted on the `/api/sled-uplink` WebSocket upgrade.
#[derive(Deserialize)]
struct SledUplinkParams {
    /// Legacy shared-secret token, sent as a query parameter (story 899).
    /// Superseded by the `Authorization: Bearer` header (story 1179 AC 1) but
    /// still honoured when no header is present, for backwards compatibility.
    token: Option<String>,
    /// Project name the connecting sled represents, declared upfront so a
    /// rejected connection's target project can still be logged even when
    /// the supplied token doesn't match (story 1179 AC 2).
    project: Option<String>,
}

/// `GET /api/sled-uplink` — gateway-side WebSocket endpoint for sled uplinks.
///
/// # Authentication
///
/// The connecting sled declares which project it represents via the
/// `project` query parameter and supplies its shared-secret token as an
/// `Authorization: Bearer <token>` header (preferred, story 1179 AC 1) or,
/// for backwards compatibility, a `token` query parameter. Tokens are
/// configured either as per-project `auth_token` fields under
/// `[projects.<name>]` in `projects.toml` (preferred, story 899) or, for
/// backwards compatibility, in the deprecated `[sled_tokens]` table.
///
/// When no token is configured anywhere in `projects.toml` (i.e.
/// [`GatewayState::sled_tokens`] is empty), the connection is accepted
/// unconditionally and a warning is logged that the uplink is unauthenticated
/// (story 1179 AC 3). Otherwise, a project with no token of its own is
/// rejected — auth being "on" anywhere means every project must be
/// explicitly configured, rather than silently falling back to open access.
/// Token comparison is constant-time (story 1179 AC 5).
///
/// # Protocol
///
/// See `sled_uplink.rs` for the wire format ([`UplinkEnvelope`]).
///
/// Phase 2 (story 899) expands the protocol from the original perm-only flow
/// to a full bidirectional MCP transport:
///
/// - **sled → gateway:** `identity` (mandatory first frame after upgrade),
///   `heartbeat`, `perm_request`, `mcp_response`.
/// - **gateway → sled:** `mcp_request`, `perm_response`.
///
/// On `identity`, the connection is published to
/// [`GatewayState::sled_connections`] under the project name, allowing the
/// MCP proxy (`mcp.rs::proxy_and_respond`) to route subsequent calls over
/// the live WS instead of HTTP.  The entry is removed on disconnect.
#[handler]
pub async fn gateway_sled_uplink_handler(
    ws: WebSocket,
    state: Data<&Arc<GatewayState>>,
    Query(params): Query<SledUplinkParams>,
    req: &poem::Request,
) -> poem::Response {
    let project = params.project.filter(|p| !p.is_empty());
    let bearer_token = req
        .header("Authorization")
        .and_then(|v| v.strip_prefix("Bearer "))
        .filter(|t| !t.is_empty())
        .map(str::to_string);
    let token = bearer_token.or_else(|| params.token.filter(|t| !t.is_empty()));

    let sled_id = if state.sled_tokens.is_empty() {
        // No tokens configured anywhere — accept as today, but this is the
        // one case where the sled operates entirely unauthenticated.
        let name = project.unwrap_or_else(|| "<unknown>".to_string());
        crate::slog_warn!(
            "[gateway/sled-uplink] no sled tokens configured; accepting uplink for '{name}' unauthenticated"
        );
        name
    } else {
        let name = match project {
            Some(p) => p,
            None => {
                return poem::Response::builder()
                    .status(StatusCode::UNAUTHORIZED)
                    .body("project query parameter required");
            }
        };
        let expected = expected_token_for_project(&state, &name).await;
        let authorized = match (&expected, &token) {
            (Some(expected_token), Some(given)) => tokens_match(given, expected_token),
            _ => false,
        };
        if !authorized {
            crate::slog!(
                "[gateway/sled-uplink] rejected uplink for project '{name}': missing or invalid token"
            );
            return poem::Response::builder()
                .status(StatusCode::UNAUTHORIZED)
                .body("invalid or missing token");
        }
        name
    };

    use poem::IntoResponse as _;
    let perm_tx = state.perm_tx.clone();
    let state = Arc::clone(&state);
    ws.on_upgrade(move |socket| async move {
        run_sled_uplink_session(socket, state, sled_id, perm_tx).await;
    })
    .into_response()
}

/// Resolve the configured auth token for `project`, checking the per-project
/// `auth_token` field first (preferred, story 899) and falling back to the
/// legacy `[sled_tokens]` table (keyed by sled_id, reversed onto
/// [`GatewayState::sled_tokens`]) for backwards compatibility.
async fn expected_token_for_project(state: &GatewayState, project: &str) -> Option<String> {
    if let Some(token) = state
        .projects
        .read()
        .await
        .get(project)
        .and_then(|p| p.auth_token.clone())
    {
        return Some(token);
    }
    state
        .sled_tokens
        .iter()
        .find(|(_, sled_id)| sled_id.as_str() == project)
        .map(|(token, _)| token.clone())
}

/// Constant-time comparison of a supplied token against the expected one
/// (story 1179 AC 5), preventing a timing attack from leaking the configured
/// secret byte-by-byte.
fn tokens_match(given: &str, expected: &str) -> bool {
    use subtle::ConstantTimeEq;
    given.as_bytes().ct_eq(expected.as_bytes()).into()
}

/// Run a single connected sled's request/response flow until it disconnects.
///
/// Performs the identity handshake, registers a [`SledConnection`] in
/// [`GatewayState::sled_connections`] under the published project name, and
/// pumps messages bidirectionally for the lifetime of the WebSocket.
async fn run_sled_uplink_session(
    socket: poem::web::websocket::WebSocketStream,
    state: Arc<GatewayState>,
    sled_id: String,
    perm_tx: tokio::sync::mpsc::UnboundedSender<crate::http::context::PermissionForward>,
) {
    use crate::service::gateway::SledConnection;
    use crate::sled_uplink::UplinkEnvelope;
    use std::sync::Arc as StdArc;
    use std::sync::atomic::AtomicI64;

    let (mut sink, mut stream) = socket.split();

    // ── Identity handshake: expect the first frame to be `identity` ──────
    let identity = match tokio::time::timeout(
        std::time::Duration::from_secs(10),
        wait_for_identity(&mut stream),
    )
    .await
    {
        Ok(Some(p)) => p,
        _ => {
            crate::slog!(
                "[gateway/sled-uplink] '{}' missing identity frame; closing",
                sled_id
            );
            return;
        }
    };

    // Project name in the identity must match the project declared (and
    // authenticated) pre-upgrade; otherwise the sled is claiming to be a
    // different project than the one its token was checked against.
    if identity != sled_id {
        crate::slog!(
            "[gateway/sled-uplink] identity mismatch (auth says '{}', sled claims '{}'); closing",
            sled_id,
            identity
        );
        return;
    }

    // ── Build SledConnection and publish it ─────────────────────────────
    let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<UplinkEnvelope>();
    let conn = SledConnection {
        tx: out_tx,
        last_heartbeat_ms: StdArc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
        in_flight: StdArc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
    };
    state
        .register_sled_connection(identity.clone(), conn.clone())
        .await;
    crate::slog!(
        "[gateway/sled-uplink] Sled '{}' connected and registered",
        identity
    );
    if !state.projects.read().await.contains_key(&identity) {
        crate::slog_warn!(
            "[gateway/sled-uplink] Sled registered under name '{}', which matches no project in projects.toml",
            identity
        );
    }

    // Aggregator channel for perm responses produced by spawned per-request tasks.
    let (agg_tx, mut agg_rx) =
        tokio::sync::mpsc::unbounded_channel::<(String, crate::http::context::PermissionDecision)>(
        );

    loop {
        tokio::select! {
            // Outbound: forward queued envelopes (mcp_request, etc.) to the sled.
            Some(env) = out_rx.recv() => {
                let Ok(text) = serde_json::to_string(&env) else { continue };
                if sink.send(WsMessage::Text(text)).await.is_err() {
                    break;
                }
            }
            // Inbound: messages from the sled.
            msg = stream.next() => {
                let text = match msg {
                    Some(Ok(WsMessage::Text(t))) => t,
                    Some(Ok(WsMessage::Close(_))) | None => break,
                    _ => continue,
                };
                let Ok(env) = serde_json::from_str::<UplinkEnvelope>(&text) else {
                    continue;
                };
                match env.msg_type.as_str() {
                    "heartbeat" => {
                        conn.last_heartbeat_ms.store(
                            chrono::Utc::now().timestamp_millis(),
                            std::sync::atomic::Ordering::Relaxed,
                        );
                    }
                    "mcp_response" => {
                        let tx_opt = conn.in_flight.lock().await.remove(&env.req_id);
                        if let Some(tx) = tx_opt {
                            let _ = tx.send(env.payload);
                        }
                    }
                    "perm_request" => {
                        forward_perm_request(env, &perm_tx, &agg_tx, &identity);
                    }
                    _ => {}
                }
            }
            // Aggregator: per-request permission resolutions get formatted as perm_response.
            Some((req_id, decision)) = agg_rx.recv() => {
                use crate::http::context::PermissionDecision;
                let (approved, always_allow) = match decision {
                    PermissionDecision::AlwaysAllow => (true, true),
                    PermissionDecision::Approve => (true, false),
                    PermissionDecision::Deny => (false, false),
                };
                let resp = UplinkEnvelope {
                    msg_type: "perm_response".to_string(),
                    req_id,
                    payload: serde_json::json!({
                        "approved": approved,
                        "always_allow": always_allow,
                    }),
                };
                let Ok(text) = serde_json::to_string(&resp) else { continue };
                if sink.send(WsMessage::Text(text)).await.is_err() {
                    break;
                }
            }
        }
    }

    state.deregister_sled_connection(&identity).await;
    crate::slog!("[gateway/sled-uplink] Sled '{}' disconnected", identity);
}

/// Read frames until an `identity` envelope is seen; return the project name
/// from its payload.  Returns `None` if the stream ends or an invalid frame
/// arrives where the first frame is expected to be `identity`.
async fn wait_for_identity(
    stream: &mut futures::stream::SplitStream<poem::web::websocket::WebSocketStream>,
) -> Option<String> {
    while let Some(msg) = stream.next().await {
        let text = match msg {
            Ok(WsMessage::Text(t)) => t,
            Ok(WsMessage::Close(_)) | Err(_) => return None,
            _ => continue,
        };
        let Ok(env) = serde_json::from_str::<crate::sled_uplink::UplinkEnvelope>(&text) else {
            return None;
        };
        if env.msg_type != "identity" {
            return None;
        }
        return env
            .payload
            .get("project")
            .and_then(|v| v.as_str())
            .map(str::to_string);
    }
    None
}

/// Convert an inbound `perm_request` envelope into a [`PermissionForward`] and
/// inject it into the gateway's permission pipeline.  The spawned waiter task
/// publishes the resolved decision into the aggregator channel so the WS
/// writer can emit a matching `perm_response` frame.
fn forward_perm_request(
    env: crate::sled_uplink::UplinkEnvelope,
    perm_tx: &tokio::sync::mpsc::UnboundedSender<crate::http::context::PermissionForward>,
    agg_tx: &tokio::sync::mpsc::UnboundedSender<(String, crate::http::context::PermissionDecision)>,
    sled_id: &str,
) {
    let req_id = env.req_id.clone();
    let tool_name = env
        .payload
        .get("tool_name")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string();
    let tool_input = env
        .payload
        .get("tool_input")
        .cloned()
        .unwrap_or(serde_json::Value::Null);
    let (response_tx, response_rx) = tokio::sync::oneshot::channel();
    let fwd = crate::http::context::PermissionForward {
        request_id: format!("{sled_id}:{req_id}"),
        tool_name,
        tool_input,
        response_tx,
    };
    if perm_tx.send(fwd).is_err() {
        return;
    }
    let agg_tx2 = agg_tx.clone();
    tokio::spawn(async move {
        let decision = response_rx
            .await
            .unwrap_or(crate::http::context::PermissionDecision::Deny);
        let _ = agg_tx2.send((req_id, decision));
    });
}

// ── Event-push WebSocket handler ─────────────────────────────────────────────

/// Query parameters accepted on the `/gateway/events/push` WebSocket upgrade.
#[derive(Deserialize)]
struct EventPushQueryParams {
    /// One-time join token generated by `POST /gateway/tokens`.
    token: Option<String>,
    /// The project name this node represents (e.g. `"huskies"`).
    project: Option<String>,
}

/// `GET /gateway/events/push` — WebSocket endpoint for project nodes to push
/// [`StatusEvent`] frames to the gateway.
///
/// # Authentication
///
/// The connecting node must supply a valid one-time join token via the `token`
/// query parameter, obtained from `POST /gateway/tokens`.  The token is
/// consumed on the first successful upgrade — the connection itself is then
/// kept open indefinitely.
///
/// # Protocol
///
/// Each message from the project node must be a JSON-encoded
/// [`crate::service::events::StoredEvent`].  The gateway fan-outs the event
/// (tagged with the project name) to all current local subscribers.
///
/// The server does not send data back; clients should treat any close frame
/// as a signal to reconnect with exponential back-off (see docs/gateway-protocol.html).
///
/// # Reconnect-with-backoff
///
/// Project nodes MUST reconnect on disconnect.  Recommended policy:
///
/// - Initial retry delay: **1 s**
/// - Back-off multiplier: **2×** per attempt
/// - Max delay cap: **60 s**
/// - Jitter: add ±10 % to the delay to avoid thundering herds
#[handler]
pub async fn gateway_event_push_handler(
    ws: WebSocket,
    state: Data<&Arc<GatewayState>>,
    Query(params): Query<EventPushQueryParams>,
) -> poem::Response {
    // ── Authentication (pre-upgrade) ─────────────────────────────────────
    let token = match params.token {
        Some(t) if !t.is_empty() => t,
        _ => {
            return poem::Response::builder()
                .status(StatusCode::UNAUTHORIZED)
                .body("token query parameter required");
        }
    };

    let project = match params.project {
        Some(p) if !p.is_empty() => p,
        _ => {
            return poem::Response::builder()
                .status(StatusCode::BAD_REQUEST)
                .body("project query parameter required");
        }
    };

    // Validate and consume the one-time token.
    {
        let mut tokens = state.pending_tokens.write().await;
        if !tokens.contains_key(&token) {
            return poem::Response::builder()
                .status(StatusCode::UNAUTHORIZED)
                .body("invalid or already-used join token");
        }
        tokens.remove(&token);
    }

    // ── WebSocket upgrade ────────────────────────────────────────────────
    use poem::IntoResponse as _;
    let state = Arc::clone(&state);
    ws.on_upgrade(move |socket| async move {
        let (_, mut stream) = socket.split();

        crate::slog!(
            "[gateway] Project node '{}' connected to event-push endpoint",
            project
        );

        while let Some(msg) = stream.next().await {
            let text = match msg {
                Ok(WsMessage::Text(t)) => t,
                Ok(WsMessage::Close(_)) | Err(_) => break,
                _ => continue,
            };

            match serde_json::from_str::<crate::service::events::StoredEvent>(&text) {
                Ok(event) => {
                    gateway::broadcast_status_event(&state, project.clone(), event);
                }
                Err(e) => {
                    crate::slog!(
                        "[gateway] event-push: invalid frame from '{}': {e}",
                        project
                    );
                }
            }
        }

        crate::slog!(
            "[gateway] Project node '{}' disconnected from event-push endpoint",
            project
        );
    })
    .into_response()
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::service::gateway::{GatewayConfig, ProjectEntry};
    use poem::EndpointExt as _;
    use poem::listener::TcpAcceptor;
    use std::collections::BTreeMap;
    use std::path::PathBuf;
    use tokio::net::TcpListener;
    use tokio_tungstenite::tungstenite::client::IntoClientRequest;
    use tokio_tungstenite::tungstenite::http::HeaderValue;
    use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;

    #[test]
    fn tokens_match_accepts_equal_tokens() {
        assert!(tokens_match("secret", "secret"));
    }

    #[test]
    fn tokens_match_rejects_different_tokens() {
        assert!(!tokens_match("secret", "wrong"));
    }

    #[test]
    fn tokens_match_rejects_different_length_tokens() {
        assert!(!tokens_match("short", "much-longer-token"));
    }

    /// Spin up a real poem server exposing only the sled-uplink handler on an
    /// ephemeral loopback port, returning its base `ws://` URL and the state
    /// (so callers can inspect e.g. `sled_connections` after connecting).
    async fn start_test_gateway(config: GatewayConfig) -> (String, Arc<GatewayState>) {
        let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        let route = poem::Route::new()
            .at("/api/sled-uplink", poem::get(gateway_sled_uplink_handler))
            .data(state.clone());
        tokio::spawn(async move {
            let acceptor = TcpAcceptor::from_tokio(listener).unwrap();
            let _ = poem::Server::new_with_acceptor(acceptor).run(route).await;
        });
        (format!("ws://{addr}"), state)
    }

    /// Attempt a WS upgrade with an optional `Authorization: Bearer` header.
    /// Returns `Ok(())` on a successful upgrade (101) or `Err(status)` for a
    /// rejected pre-upgrade response.
    async fn connect_with_bearer(url: &str, token: Option<&str>) -> Result<(), u16> {
        let mut request = url.into_client_request().unwrap();
        if let Some(t) = token {
            request.headers_mut().insert(
                AUTHORIZATION,
                HeaderValue::from_str(&format!("Bearer {t}")).unwrap(),
            );
        }
        match tokio_tungstenite::connect_async(request).await {
            Ok(_) => Ok(()),
            Err(tokio_tungstenite::tungstenite::Error::Http(resp)) => Err(resp.status().as_u16()),
            Err(e) => panic!("unexpected connect error: {e}"),
        }
    }

    fn config_with_project_token(project: &str, token: &str) -> GatewayConfig {
        let mut projects = BTreeMap::new();
        projects.insert(
            project.to_string(),
            ProjectEntry {
                url: None,
                auth_token: Some(token.to_string()),
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );
        GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        }
    }

    #[tokio::test]
    async fn valid_token_via_header_is_accepted() {
        let config = config_with_project_token("myproj", "secret-token");
        let (base_url, _state) = start_test_gateway(config).await;
        let url = format!("{base_url}/api/sled-uplink?project=myproj");
        assert!(
            connect_with_bearer(&url, Some("secret-token"))
                .await
                .is_ok(),
            "valid bearer token must be accepted"
        );
    }

    #[tokio::test]
    async fn wrong_token_is_rejected_with_401() {
        let config = config_with_project_token("myproj", "secret-token");
        let (base_url, _state) = start_test_gateway(config).await;
        let url = format!("{base_url}/api/sled-uplink?project=myproj");
        let result = connect_with_bearer(&url, Some("wrong-token")).await;
        assert_eq!(result, Err(401), "wrong token must be rejected with 401");
    }

    #[tokio::test]
    async fn missing_token_is_rejected_when_configured() {
        let config = config_with_project_token("myproj", "secret-token");
        let (base_url, _state) = start_test_gateway(config).await;
        let url = format!("{base_url}/api/sled-uplink?project=myproj");
        let result = connect_with_bearer(&url, None).await;
        assert_eq!(
            result,
            Err(401),
            "missing token must be rejected when a token is configured for the project"
        );
    }

    #[tokio::test]
    async fn no_sled_tokens_configured_is_accepted_unauthenticated() {
        let mut projects = BTreeMap::new();
        projects.insert(
            "myproj".to_string(),
            ProjectEntry::with_url("http://myproj:3001"),
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let (base_url, _state) = start_test_gateway(config).await;
        let url = format!("{base_url}/api/sled-uplink?project=myproj");
        assert!(
            connect_with_bearer(&url, None).await.is_ok(),
            "with no sled tokens configured anywhere, the uplink must accept unauthenticated"
        );
    }

    #[tokio::test]
    async fn unconfigured_project_name_logs_warning() {
        let mut projects = BTreeMap::new();
        projects.insert(
            "other-project".to_string(),
            ProjectEntry::with_url("http://other-project:3001"),
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let (base_url, _state) = start_test_gateway(config).await;
        let url = format!("{base_url}/api/sled-uplink?project=ghost-project");

        let (mut ws_stream, _) = tokio_tungstenite::connect_async(url.as_str())
            .await
            .expect("uplink connect should succeed when no tokens are configured");

        let identity = crate::sled_uplink::UplinkEnvelope {
            msg_type: "identity".to_string(),
            req_id: String::new(),
            payload: serde_json::json!({ "project": "ghost-project" }),
        };
        ws_stream
            .send(tokio_tungstenite::tungstenite::Message::Text(
                serde_json::to_string(&identity).unwrap().into(),
            ))
            .await
            .unwrap();

        // Give the server task time to process the identity frame and register.
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;

        let warnings = crate::log_buffer::global().get_recent(
            50,
            Some("ghost-project"),
            Some(&crate::log_buffer::LogLevel::Warn),
        );
        assert!(
            warnings.iter().any(|w| w.contains("matches no project")),
            "expected an unconfigured-project warning, got: {warnings:?}"
        );
    }
}
