//! MCP JSON-RPC POST/GET handlers and gateway tool dispatch.

use super::jsonrpc::{JsonRpcRequest, JsonRpcResponse, to_json_response};
use crate::service::gateway::{self, GatewayState};
use poem::handler;
use poem::http::StatusCode;
use poem::web::Data;
use poem::web::sse::{Event, SSE};
use poem::{Body, IntoResponse, Request, Response};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;

// ── MCP tool definitions ─────────────────────────────────────────────────────

/// Gateway-specific MCP tools exposed alongside the proxied tools.
const GATEWAY_TOOLS: &[&str] = &[
    "switch_project",
    "gateway_status",
    "gateway_health",
    "list_projects",
    "init_project",
    "adopt_project",
    "aggregate_pipeline_status",
    "agents.list",
    // Handled at the gateway so the Matrix bot's permission listener is used
    // rather than the container's (which has no interactive session attached).
    "prompt_permission",
    // One-shot container rebuild: build fresh image, swap container, preserve state.
    "project_rebuild",
    // On-demand host + per-container disk/load/CPU/mem snapshot (story 1207).
    "fleet_resources",
    // Read sled identity pins vs. live signed identity, and TOFU re-pin.
    "fleet_identity",
];

/// Gateway tool definitions.
pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
    vec![
        json!({
            "name": "switch_project",
            "description": "Switch the active project. All subsequent MCP tool calls will be proxied to this project's container.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "project": {
                        "type": "string",
                        "description": "Name of the project to switch to (must exist in projects.toml)"
                    }
                },
                "required": ["project"]
            }
        }),
        json!({
            "name": "gateway_status",
            "description": "Show pipeline status for the active project by proxying the get_pipeline_status tool call.",
            "inputSchema": {
                "type": "object",
                "properties": {}
            }
        }),
        json!({
            "name": "gateway_health",
            "description": "Health check aggregation across all registered projects. Returns the health status of every project container.",
            "inputSchema": {
                "type": "object",
                "properties": {}
            }
        }),
        json!({
            "name": "list_projects",
            "description": "List every registered gateway project with its name, url, ssh_port (if set), host_path (if set), and an adopted/built-in marker. The active project is prefixed with *. No liveness checks are performed.",
            "inputSchema": {
                "type": "object",
                "properties": {}
            }
        }),
        json!({
            "name": "init_project",
            "description": "Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init <path>`. Prefer this tool over asking the user to run the CLI. If `name` and `url` are supplied the project is also registered in projects.toml so switch_project can reach it immediately.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Absolute filesystem path to the project directory to initialise. The directory is created if it does not exist."
                    },
                    "name": {
                        "type": "string",
                        "description": "Optional: short name to register the project under in projects.toml (e.g. 'my-app'). Requires `url`."
                    },
                    "url": {
                        "type": "string",
                        "description": "Optional: base URL of the huskies container that will serve this project (e.g. 'http://my-app:3001'). Required when `name` is given."
                    }
                },
                "required": ["path"]
            }
        }),
        json!({
            "name": "adopt_project",
            "description": "Wrap a Docker container around an existing host checkout — the same as `new project <name> --adopt <path>`. No git clone or git init is performed; the directory is bind-mounted at /workspace. Launches the appropriate stack-specific image, generates an SSH keypair, and registers the project in projects.toml. Returns the SSH connection command and detected stack.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "name": {
                        "type": "string",
                        "description": "Short project name (letters, digits, hyphens, underscores). Must be unique across registered projects."
                    },
                    "path": {
                        "type": "string",
                        "description": "Absolute host filesystem path to the existing checkout to adopt. Must be an existing directory."
                    },
                    "stack": {
                        "type": "string",
                        "description": "Optional: override stack detection (e.g. 'rust', 'node', 'python'). Auto-detected from directory contents when omitted."
                    }
                },
                "required": ["name", "path"]
            }
        }),
        json!({
            "name": "aggregate_pipeline_status",
            "description": "Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call.",
            "inputSchema": {
                "type": "object",
                "properties": {}
            }
        }),
        json!({
            "name": "agents.list",
            "description": "List all alive build agents currently registered with this gateway. Returns an array of agent objects with id, label, address, registered_at, last_seen, and assigned_project fields.",
            "inputSchema": {
                "type": "object",
                "properties": {}
            }
        }),
        json!({
            "name": "project_rebuild",
            "description": "Rebuild a project's Docker image from its Dockerfile.fragment, swap the container, and preserve all CRDT and pipeline state. In-flight coder/merge work is drained before the swap; if not drainable within the timeout the command refuses. On success returns the new image hash and container ID.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "name": {
                        "type": "string",
                        "description": "Name of the project to rebuild (must exist in projects.toml with host_path set)."
                    },
                    "drain_timeout_secs": {
                        "type": "integer",
                        "description": "Seconds to wait for active agents to stop before rebuilding (default: 60). Pass 0 to skip the drain check."
                    },
                    "force": {
                        "type": "boolean",
                        "description": "If true, skip the drain check and rebuild immediately even if agents are running."
                    }
                },
                "required": ["name"]
            }
        }),
        json!({
            "name": "fleet_resources",
            "description": "On-demand host and per-container resource snapshot: host disk free/total, load averages, core count, and memory; per-container CPU%/mem sourced via `docker stats`; and per-project `target/`+`.huskies/worktrees/` directory sizes sourced via `docker exec ... find` (bounded, skips node_modules/.git, cached briefly per container+path). Flags disk/load conditions past thresholds first so problems lead the response.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "disk_warn_gb": {
                        "type": "integer",
                        "description": "Host free-disk warn threshold in GB (default: 50)."
                    },
                    "disk_critical_gb": {
                        "type": "integer",
                        "description": "Host free-disk critical threshold in GB (default: 20)."
                    },
                    "load_warn_per_core": {
                        "type": "number",
                        "description": "1-minute load average per core above which a warn flag fires (default: 1.0)."
                    },
                    "load_critical_per_core": {
                        "type": "number",
                        "description": "1-minute load average per core above which a critical flag fires (default: 2.0)."
                    }
                }
            }
        }),
        json!({
            "name": "fleet_identity",
            "description": "Read mode (default): for every registered sled, report project, url, connected, the recorded pin (expected_node_id), the live signature-verified node_id from a signed challenge-response (never the unsigned /identity display field), and whether they match. Repin mode: capture a sled's live verified identity via TOFU and persist it as the new pin, refusing when the signature is missing or does not verify.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["read", "repin"],
                        "description": "\"read\" (default) reports every sled's pin vs. live identity. \"repin\" re-pins one sled via TOFU; requires `project`."
                    },
                    "project": {
                        "type": "string",
                        "description": "Required when action is \"repin\": the project/sled name to re-pin."
                    }
                }
            }
        }),
    ]
}

// ── MCP POST handler ─────────────────────────────────────────────────────────

/// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and
/// proxies everything else to the active project's container.
#[handler]
pub async fn gateway_mcp_post_handler(
    req: &Request,
    body: Body,
    state: Data<&Arc<GatewayState>>,
) -> Response {
    let content_type = req.header("content-type").unwrap_or("");
    if !content_type.is_empty() && !content_type.contains("application/json") {
        return to_json_response(JsonRpcResponse::error(
            None,
            -32700,
            "Unsupported Content-Type; expected application/json".into(),
        ));
    }

    let bytes = match body.into_bytes().await {
        Ok(b) => b,
        Err(_) => {
            return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into()));
        }
    };

    let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) {
        Ok(r) => r,
        Err(_) => {
            return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into()));
        }
    };

    if rpc.jsonrpc != "2.0" {
        return to_json_response(JsonRpcResponse::error(
            rpc.id,
            -32600,
            "Invalid JSON-RPC version".into(),
        ));
    }

    if rpc.id.is_none() || rpc.id.as_ref() == Some(&Value::Null) {
        if rpc.method.starts_with("notifications/") {
            return Response::builder()
                .status(StatusCode::ACCEPTED)
                .body(Body::empty());
        }
        return to_json_response(JsonRpcResponse::error(None, -32600, "Missing id".into()));
    }

    // SSE proxy: tools/call with Accept: text/event-stream + progressToken for
    // non-gateway tools is forwarded to the sled's SSE endpoint so progress
    // notifications flow through to the gateway client unchanged.
    if rpc.method == "tools/call" {
        let accepts_sse = req
            .header("accept")
            .map(|h| h.contains("text/event-stream"))
            .unwrap_or(false);
        let has_progress_token = rpc
            .params
            .get("_meta")
            .and_then(|m| m.get("progressToken"))
            .is_some();
        if accepts_sse && has_progress_token {
            let tool_name = rpc
                .params
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            if !GATEWAY_TOOLS.contains(&tool_name) {
                return proxy_and_respond_sse(&state, &bytes, rpc.id).await;
            }
        }
    }

    match rpc.method.as_str() {
        "initialize" => to_json_response(handle_initialize(rpc.id)),
        "tools/list" => match handle_tools_list(&state, rpc.id.clone()).await {
            Ok(resp) => to_json_response(resp),
            Err(e) => to_json_response(JsonRpcResponse::error(rpc.id, -32603, e)),
        },
        "pipeline.get" => to_json_response(handle_pipeline_get(&state, rpc.id).await),
        "tools/call" => {
            let tool_name = rpc
                .params
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or("");

            if GATEWAY_TOOLS.contains(&tool_name) {
                to_json_response(
                    handle_gateway_tool(tool_name, &rpc.params, &state, rpc.id.clone()).await,
                )
            } else {
                // Story 1208 AC 1: an explicit `project` argument on any
                // proxied tool call targets that project directly, without
                // requiring a prior `switch_project`.
                let explicit_project = rpc
                    .params
                    .get("arguments")
                    .and_then(|a| a.get("project"))
                    .and_then(|v| v.as_str())
                    .filter(|p| !p.is_empty());
                match explicit_project {
                    Some(project) => {
                        proxy_and_respond_for_project(&state, project, &bytes, rpc.id).await
                    }
                    None => proxy_and_respond(&state, &bytes, rpc.id).await,
                }
            }
        }
        _ => proxy_and_respond(&state, &bytes, rpc.id).await,
    }
}

/// Proxy a request to the active project and format the response.
///
/// Prefers the live sled-uplink WebSocket when one is attached (story 899
/// AC 2); falls back to the legacy HTTP proxy otherwise.
async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option<Value>) -> Response {
    match state.proxy_active_mcp(bytes).await {
        Ok(resp_body) => Response::builder()
            .status(StatusCode::OK)
            .header("Content-Type", "application/json")
            .body(Body::from(resp_body)),
        Err(e) => to_json_response(JsonRpcResponse::error(
            id,
            -32603,
            format!("proxy error: {e}"),
        )),
    }
}

/// Proxy a request to an explicitly named project (story 1208 AC 1) rather
/// than whatever project is currently active, so a single ops/LLM session
/// can address any registered project per-call without a prior
/// `switch_project`. Returns a JSON-RPC error when `project` is not a
/// registered project name.
async fn proxy_and_respond_for_project(
    state: &GatewayState,
    project: &str,
    bytes: &[u8],
    id: Option<Value>,
) -> Response {
    {
        let projects = state.projects.read().await;
        if let Err(e) = gateway::config::validate_project_exists(&projects, project) {
            return to_json_response(JsonRpcResponse::error(id, -32602, e));
        }
    }
    match state.proxy_mcp_for_project(project, bytes).await {
        Ok(resp_body) => Response::builder()
            .status(StatusCode::OK)
            .header("Content-Type", "application/json")
            .body(Body::from(resp_body)),
        Err(e) => to_json_response(JsonRpcResponse::error(
            id,
            -32603,
            format!("proxy error: {e}"),
        )),
    }
}

/// Stream an MCP tool call to the active sled as SSE, re-emitting each `data:`
/// event from the sled to the originating gateway client without buffering.
///
/// On sled disconnect mid-stream a JSON-RPC error event is emitted so the
/// client does not hang forever.
#[allow(clippy::string_slice)] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries
async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option<Value>) -> Response {
    let url = match state.active_url().await {
        Ok(u) => u,
        Err(e) => return sse_error_response(id, -32603, e.to_string()),
    };

    let resp = match gateway::io::proxy_mcp_call_sse(&state.client, &url, bytes).await {
        Ok(r) => r,
        Err(e) => return sse_error_response(id, -32603, format!("proxy error: {e}")),
    };

    let id_for_error = id;
    let stream = async_stream::stream! {
        use futures::StreamExt as _;
        let mut buf = String::new();
        let byte_stream = resp.bytes_stream();
        tokio::pin!(byte_stream);

        while let Some(chunk) = byte_stream.next().await {
            match chunk {
                Ok(bytes) => {
                    if let Ok(text) = std::str::from_utf8(&bytes) {
                        buf.push_str(text);
                        // Emit a gateway SSE event for each complete `data:` line.
                        while let Some(pos) = buf.find('\n') {
                            let line = buf[..pos].trim_end_matches('\r').to_string();
                            buf = buf[pos + 1..].to_string();
                            if let Some(data) = line.strip_prefix("data: ") {
                                yield Event::message(data.to_string());
                            }
                        }
                    }
                }
                Err(e) => {
                    let err = JsonRpcResponse::error(
                        id_for_error.clone(),
                        -32603,
                        format!("upstream disconnected: {e}"),
                    );
                    let data = serde_json::to_string(&err).unwrap_or_default();
                    yield Event::message(data);
                    break;
                }
            }
        }
    };

    SSE::new(stream)
        .keep_alive(Duration::from_secs(15))
        .into_response()
}

/// Build a minimal SSE response containing a single JSON-RPC error event.
fn sse_error_response(id: Option<Value>, code: i64, msg: String) -> Response {
    let err = JsonRpcResponse::error(id, code, msg);
    let data = serde_json::to_string(&err).unwrap_or_default();
    let stream = async_stream::stream! {
        yield Event::message(data);
    };
    SSE::new(stream).into_response()
}

/// GET handler — method not allowed.
#[handler]
pub async fn gateway_mcp_get_handler() -> Response {
    Response::builder()
        .status(StatusCode::METHOD_NOT_ALLOWED)
        .body(Body::empty())
}

// ── Protocol handlers ────────────────────────────────────────────────────────

fn handle_initialize(id: Option<Value>) -> JsonRpcResponse {
    JsonRpcResponse::success(
        id,
        json!({
            "protocolVersion": "2025-03-26",
            "capabilities": { "tools": {} },
            "serverInfo": {
                "name": "huskies-gateway",
                "version": "1.0.0"
            }
        }),
    )
}

/// Fetch tools/list from the active project and merge in gateway tools.
///
/// Routes via the sled-uplink WS when one is attached (story 899 AC 2);
/// falls back to HTTP otherwise.
async fn handle_tools_list(
    state: &GatewayState,
    id: Option<Value>,
) -> Result<JsonRpcResponse, String> {
    let rpc_body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/list",
        "params": {}
    });
    let bytes = serde_json::to_vec(&rpc_body).map_err(|e| e.to_string())?;
    let resp_bytes = state.proxy_active_mcp(&bytes).await?;
    let resp_json: Value =
        serde_json::from_slice(&resp_bytes).map_err(|e| format!("invalid tools/list JSON: {e}"))?;

    let mut tools: Vec<Value> = resp_json
        .get("result")
        .and_then(|r| r.get("tools"))
        .and_then(|t| t.as_array())
        .cloned()
        .unwrap_or_default();

    inject_project_arg_schema(&mut tools);

    let mut all_tools = gateway_tool_definitions();
    all_tools.append(&mut tools);

    Ok(JsonRpcResponse::success(id, json!({ "tools": all_tools })))
}

/// Advertise the optional per-call `project` argument (story 1208 AC 1) on
/// every proxied tool's `inputSchema.properties`, so MCP clients that
/// validate call arguments against the declared schema don't reject it.
/// Leaves any tool without an object `inputSchema.properties` untouched.
fn inject_project_arg_schema(tools: &mut [Value]) {
    for tool in tools.iter_mut() {
        if let Some(props) = tool
            .get_mut("inputSchema")
            .and_then(|s| s.get_mut("properties"))
            .and_then(|p| p.as_object_mut())
        {
            props.entry("project".to_string()).or_insert_with(|| {
                json!({
                    "type": "string",
                    "description": "Optional: name of a registered gateway project to target instead of the currently active one (see list_projects). Omit to use the active project."
                })
            });
        }
    }
}

// ── Gateway tool dispatch ────────────────────────────────────────────────────

/// Dispatch a gateway-specific tool call.
async fn handle_gateway_tool(
    tool_name: &str,
    params: &Value,
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    match tool_name {
        "switch_project" => handle_switch_project_tool(params, state, id).await,
        "gateway_status" => handle_gateway_status_tool(state, id).await,
        "gateway_health" => handle_gateway_health_tool(state, id).await,
        "list_projects" => handle_list_projects_tool(state, id).await,
        "init_project" => handle_init_project_tool(params, state, id).await,
        "adopt_project" => handle_adopt_project_tool(params, state, id).await,
        "aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await,
        "agents.list" => handle_agents_list_tool(id),
        "prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
        "project_rebuild" => handle_project_rebuild_tool(params, state, id).await,
        "fleet_resources" => handle_fleet_resources_tool(params, state, id).await,
        "fleet_identity" => handle_fleet_identity_tool(params, state, id).await,
        _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
    }
}

async fn handle_switch_project_tool(
    params: &Value,
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    let project = params
        .get("arguments")
        .and_then(|a| a.get("project"))
        .or_else(|| params.get("project"))
        .and_then(|v| v.as_str())
        .unwrap_or("");

    match gateway::switch_project(state, project).await {
        Ok(url) => JsonRpcResponse::success(
            id,
            json!({
                "content": [{
                    "type": "text",
                    "text": format!("Switched to project '{project}' ({url})")
                }]
            }),
        ),
        Err(e) => JsonRpcResponse::error(id, -32602, e.to_string()),
    }
}

async fn handle_gateway_status_tool(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
    let active = state.active_project.read().await.clone();
    let url = match state.active_url().await {
        Ok(u) => u,
        Err(e) => return JsonRpcResponse::error(id.clone(), -32603, e.to_string()),
    };

    match gateway::io::fetch_pipeline_status_for_project(&state.client, &url).await {
        Ok(upstream) => {
            let pipeline = upstream.get("result").cloned().unwrap_or(json!(null));
            JsonRpcResponse::success(
                id,
                json!({
                    "content": [{
                        "type": "text",
                        "text": format!(
                            "Pipeline status for '{active}':\n{}",
                            serde_json::to_string_pretty(&pipeline).unwrap_or_default()
                        )
                    }]
                }),
            )
        }
        Err(e) => JsonRpcResponse::error(id, -32603, e),
    }
}

/// Returns `"ok"`, `"silent"`, or `"never"` for a project based on its most recent
/// CRDT event log entry.  The gateway appends events using the project name as `sled_id`,
/// so filtering by project name identifies all events received from that sled.
fn relay_status(
    entries: &[crate::crdt_state::EventLogEntryRaw],
    project: &str,
    now_secs: f64,
) -> &'static str {
    let latest = entries
        .iter()
        .filter(|e| e.sled_id == project)
        .map(|e| e.timestamp)
        .fold(f64::NEG_INFINITY, f64::max);
    if latest == f64::NEG_INFINITY {
        "never"
    } else if now_secs - latest <= crate::service::gateway::RELAY_MAX_AGE_SECS {
        "ok"
    } else {
        "silent"
    }
}

async fn handle_gateway_health_tool(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
    let mut results = BTreeMap::new();

    // Build the project list, preferring the WS-uplink heartbeat as the
    // source of truth for liveness (story 899 AC 3). HTTP polls are used
    // only as a fallback when no live sled is connected.
    let project_names: Vec<(String, Option<String>)> = state
        .projects
        .read()
        .await
        .iter()
        .map(|(n, e)| (n.clone(), e.url.clone()))
        .collect();
    let event_entries = crate::crdt_state::read_all_event_log_entries();
    let now_secs = chrono::Utc::now().timestamp() as f64;
    let sled_conns = state.sled_connections.read().await;
    for (name, url_opt) in &project_names {
        let status = if let Some(conn) = sled_conns.get(name) {
            if conn.is_alive(crate::service::gateway::HEARTBEAT_MAX_AGE_MS) {
                "healthy (ws)".to_string()
            } else {
                "stale (ws heartbeat overdue)".to_string()
            }
        } else if let Some(url) = url_opt {
            match gateway::io::check_project_health(&state.client, url).await {
                Ok(true) => "healthy".to_string(),
                Ok(false) => "unhealthy".to_string(),
                Err(e) => e,
            }
        } else {
            "no uplink and no url configured".to_string()
        };
        let relay = relay_status(&event_entries, name, now_secs);
        results.insert(name.clone(), format!("{status} relay={relay}"));
    }
    drop(sled_conns);

    let active = state.active_project.read().await.clone();
    JsonRpcResponse::success(
        id,
        json!({
            "content": [{
                "type": "text",
                "text": format!(
                    "Health check (active: '{active}'):\n{}",
                    results.iter()
                        .map(|(name, status)| format!("  {name}: {status}"))
                        .collect::<Vec<_>>()
                        .join("\n")
                )
            }]
        }),
    )
}

/// Handle the `list_projects` gateway tool.
///
/// Returns one row per registered project: name, url, ssh_port (if set),
/// host_path (if set), and an `[adopted]`/`[built-in]` marker.  Output is
/// alphabetised by project name (BTreeMap order).  The active project is
/// prefixed with `*`.  No liveness checks are performed.
async fn handle_list_projects_tool(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
    let active = state.active_project.read().await.clone();
    let projects = state.projects.read().await;

    if projects.is_empty() {
        return JsonRpcResponse::success(
            id,
            json!({ "content": [{ "type": "text", "text": "No projects registered." }] }),
        );
    }

    let mut lines = Vec::with_capacity(projects.len() + 1);
    lines.push(format!("Projects ({} registered):", projects.len()));
    for (name, entry) in projects.iter() {
        let marker = if *name == active { "*" } else { " " };
        let mut parts = vec![name.clone()];
        if let Some(ref url) = entry.url {
            parts.push(url.clone());
        }
        if let Some(port) = entry.ssh_port {
            parts.push(format!("ssh:{port}"));
        }
        if let Some(ref path) = entry.host_path {
            parts.push(path.clone());
        }
        let adopted = if entry.host_path.is_some() {
            "[adopted]"
        } else {
            "[built-in]"
        };
        parts.push(adopted.to_string());
        lines.push(format!("{marker} {}", parts.join("  ")));
    }
    let text = lines.join("\n");

    JsonRpcResponse::success(id, json!({ "content": [{ "type": "text", "text": text }] }))
}

async fn handle_init_project_tool(
    params: &Value,
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    let args = params.get("arguments").unwrap_or(params);

    let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
    let name = args.get("name").and_then(|v| v.as_str());
    let url = args.get("url").and_then(|v| v.as_str());

    match gateway::init_project(state, path_str, name, url).await {
        Ok(registered_name) => {
            let next_steps = if let Some(ref n) = registered_name {
                format!(
                    "Project registered as '{n}' in projects.toml.\n\
                     Next steps:\n\
                     1. Start a huskies server at '{path_str}' \
                        (e.g. `huskies {path_str}` or via Docker).\n\
                     2. Call switch_project with name='{n}' to make it active.\n\
                     3. Call wizard_status to begin the setup wizard."
                )
            } else {
                format!(
                    "Next steps:\n\
                     1. Start a huskies server at '{path_str}' \
                        (e.g. `huskies {path_str}` or via Docker).\n\
                     2. Register the project: call init_project again with name and url \
                        parameters, or add it to projects.toml manually.\n\
                     3. Call switch_project and then wizard_status to begin the setup wizard.\n\n\
                     Note: wizard_* MCP tools require a running huskies server for the project."
                )
            };

            JsonRpcResponse::success(
                id,
                json!({
                    "content": [{
                        "type": "text",
                        "text": format!("Successfully initialised huskies project at '{path_str}'.\n\n{next_steps}")
                    }]
                }),
            )
        }
        Err(e) => {
            let code = match &e {
                gateway::Error::Config(_) => -32602,
                gateway::Error::DuplicateToken(_) => -32602,
                _ => -32603,
            };
            JsonRpcResponse::error(id, code, e.to_string())
        }
    }
}

/// Handle the `adopt_project` gateway tool.
///
/// Wraps a Docker container around an existing host checkout — the MCP
/// equivalent of the `new project <name> --adopt <path>` chat command.
/// Validates that `path` exists and is a directory before delegating to
/// `handle_new_project`, which performs stack detection, container launch,
/// SSH keypair generation, and project registration.
async fn handle_adopt_project_tool(
    params: &Value,
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    use crate::chat::transport::matrix::new_project::handle_new_project;

    let args = params.get("arguments").unwrap_or(params);
    let name = args
        .get("name")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .trim();
    let path_str = args
        .get("path")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .trim();
    let stack = args.get("stack").and_then(|v| v.as_str());

    if name.is_empty() {
        return JsonRpcResponse::error(id, -32602, "missing required parameter: name".into());
    }
    if path_str.is_empty() {
        return JsonRpcResponse::error(id, -32602, "missing required parameter: path".into());
    }

    let path = std::path::Path::new(path_str);
    if !path.exists() {
        return JsonRpcResponse::error(
            id,
            -32602,
            format!(
                "Adopt path `{path_str}` does not exist — specify the path to an existing checkout."
            ),
        );
    }
    if !path.is_dir() {
        return JsonRpcResponse::error(
            id,
            -32602,
            format!("Adopt path `{path_str}` is not a directory."),
        );
    }

    let result = handle_new_project(
        name,
        stack,
        None,
        None,
        None,
        Some(path_str),
        false,
        &state.projects,
        &state.config_dir,
    )
    .await;

    JsonRpcResponse::success(
        id,
        json!({
            "content": [{
                "type": "text",
                "text": result
            }]
        }),
    )
}

async fn handle_aggregate_pipeline_status_tool(
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    let project_urls: BTreeMap<String, String> = state
        .projects
        .read()
        .await
        .iter()
        .filter_map(|(name, entry)| entry.url.as_ref().map(|u| (name.clone(), u.clone())))
        .collect();

    let statuses =
        gateway::io::fetch_all_project_pipeline_statuses(&project_urls, &state.client).await;
    let active = state.active_project.read().await.clone();

    JsonRpcResponse::success(
        id,
        json!({
            "content": [{
                "type": "text",
                "text": format!(
                    "Aggregate pipeline status (active: '{active}'):\n{}",
                    serde_json::to_string_pretty(&statuses).unwrap_or_default()
                )
            }],
            "projects": statuses,
            "active": active,
        }),
    )
}

/// Handle the `prompt_permission` tool at the gateway level.
///
/// Mirrors `tool_prompt_permission` in `http/mcp/diagnostics/permission.rs` but
/// uses the gateway's `perm_tx`/`permission_registry` so requests reach the
/// Matrix bot that is listening on the gateway, not the proxied container
/// (which has no interactive session and would auto-deny immediately).
async fn handle_prompt_permission_tool(
    params: &Value,
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    use crate::http::context::PermissionDecision;
    use crate::http::context::PermissionForward;

    let args = params.get("arguments").unwrap_or(params);
    let tool_name = args
        .get("tool_name")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string();
    let tool_input = args.get("input").cloned().unwrap_or(json!({}));

    // Auto-approve huskies MCP tools — mirrors the standard server's allowlist.
    if tool_name.starts_with("mcp__huskies__") {
        crate::slog!(
            "[gateway/permission] Auto-approved '{tool_name}' (matches mcp__huskies__* allowlist)"
        );
        let text = json!({"behavior": "allow", "updatedInput": tool_input}).to_string();
        return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]}));
    }

    // Auto-deny when no responder is registered (i.e. no Matrix bot listener
    // is running).
    if state.permission_registry.is_empty() {
        crate::slog!("[gateway/permission] Auto-denied '{tool_name}' (no interactive session)");
        let text = json!({
            "behavior": "deny",
            "message": format!("Permission denied for '{tool_name}'. No interactive session active.")
        })
        .to_string();
        return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]}));
    }

    let request_id = uuid::Uuid::new_v4().to_string();
    let (response_tx, response_rx) = tokio::sync::oneshot::channel();

    if state
        .perm_tx
        .send(PermissionForward {
            request_id,
            tool_name: tool_name.clone(),
            tool_input: tool_input.clone(),
            response_tx,
        })
        .is_err()
    {
        crate::slog!("[gateway/permission] Auto-denied '{tool_name}' (perm_tx send failed)");
        let text =
            json!({"behavior": "deny", "message": format!("Permission denied for '{tool_name}'.")})
                .to_string();
        return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]}));
    }

    let decision =
        match tokio::time::timeout(std::time::Duration::from_secs(300), response_rx).await {
            Ok(Ok(d)) => d,
            Ok(Err(_)) => {
                return JsonRpcResponse::error(
                    id,
                    -32603,
                    "Permission response channel closed unexpectedly".into(),
                );
            }
            Err(_) => {
                return JsonRpcResponse::error(
                    id,
                    -32603,
                    format!("Permission request for '{tool_name}' timed out after 5 minutes"),
                );
            }
        };

    let text = if matches!(
        decision,
        PermissionDecision::Approve | PermissionDecision::AlwaysAllow
    ) {
        json!({"behavior": "allow", "updatedInput": tool_input}).to_string()
    } else {
        crate::slog_warn!("[gateway/permission] User denied permission for '{tool_name}'");
        json!({"behavior": "deny", "message": format!("User denied permission for '{tool_name}'")})
            .to_string()
    };

    JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]}))
}

/// Handle the `agents.list` gateway tool — returns all alive build agents from the CRDT.
fn handle_agents_list_tool(id: Option<Value>) -> JsonRpcResponse {
    let agents = gateway::list_agents();
    let agents_json = serde_json::to_value(&agents).unwrap_or(json!([]));
    JsonRpcResponse::success(
        id,
        json!({
            "content": [{
                "type": "text",
                "text": serde_json::to_string_pretty(&agents).unwrap_or_default()
            }],
            "agents": agents_json,
        }),
    )
}

/// Handle the `project_rebuild` gateway tool.
///
/// Rebuilds a project's Docker image, swaps the container, and preserves all
/// CRDT and pipeline state.  Delegates to `handle_project_rebuild` in the chat
/// transport module so the logic is shared between the chat and MCP entry points.
async fn handle_project_rebuild_tool(
    params: &Value,
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    use crate::chat::transport::matrix::project_rebuild::handle_project_rebuild;

    let args = params.get("arguments").unwrap_or(params);
    let name = args
        .get("name")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .trim();

    if name.is_empty() {
        return JsonRpcResponse::error(id, -32602, "missing required parameter: name".into());
    }

    let drain_timeout_secs = args
        .get("drain_timeout_secs")
        .and_then(|v| v.as_u64())
        .unwrap_or(60);
    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);

    let result = handle_project_rebuild(
        name,
        drain_timeout_secs,
        force,
        &state.projects,
        &state.config_dir,
    )
    .await;

    JsonRpcResponse::success(
        id,
        json!({
            "content": [{
                "type": "text",
                "text": result
            }]
        }),
    )
}

/// Handle the `fleet_resources` gateway tool (story 1207).
///
/// Collects host disk/load/cpu/mem, per-container CPU%/mem (via `docker
/// stats`), and per-project `target/`/`worktrees/` sizes (via `docker exec
/// ... find`) — all sourced gateway-side rather than requiring each project's
/// sled to self-report. Thresholds for the leading `flags` list are supplied
/// as optional arguments (see `gateway_tool_definitions`), defaulting to
/// `ResourceThresholds::default()`.
async fn handle_fleet_resources_tool(
    params: &Value,
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    use crate::service::gateway::resources::ResourceThresholds;

    let args = params.get("arguments").unwrap_or(params);
    let defaults = ResourceThresholds::default();
    let thresholds = ResourceThresholds {
        disk_warn_gb: args
            .get("disk_warn_gb")
            .and_then(|v| v.as_u64())
            .unwrap_or(defaults.disk_warn_gb),
        disk_critical_gb: args
            .get("disk_critical_gb")
            .and_then(|v| v.as_u64())
            .unwrap_or(defaults.disk_critical_gb),
        load_warn_per_core: args
            .get("load_warn_per_core")
            .and_then(|v| v.as_f64())
            .unwrap_or(defaults.load_warn_per_core),
        load_critical_per_core: args
            .get("load_critical_per_core")
            .and_then(|v| v.as_f64())
            .unwrap_or(defaults.load_critical_per_core),
    };

    let project_names: Vec<String> = state.projects.read().await.keys().cloned().collect();

    match crate::service::gateway::resources::io::collect_fleet_resources(
        &state.config_dir,
        &project_names,
        &thresholds,
    )
    .await
    {
        Ok(resources) => JsonRpcResponse::success(
            id,
            json!({
                "content": [{
                    "type": "text",
                    "text": serde_json::to_string_pretty(&resources).unwrap_or_default()
                }],
                "resources": serde_json::to_value(&resources).unwrap_or(json!(null)),
            }),
        ),
        Err(e) => JsonRpcResponse::error(id, -32603, format!("fleet_resources failed: {e}")),
    }
}

/// Handle the `fleet_identity` gateway tool.
///
/// Dispatches on the `action` argument: `"read"` (default) reports every
/// sled's pin vs. live signed identity; `"repin"` captures and persists a
/// single sled's live verified identity via TOFU.
async fn handle_fleet_identity_tool(
    params: &Value,
    state: &GatewayState,
    id: Option<Value>,
) -> JsonRpcResponse {
    let args = params.get("arguments").unwrap_or(params);
    let action = args
        .get("action")
        .and_then(|v| v.as_str())
        .unwrap_or("read");

    match action {
        "read" => {
            let reports = gateway::fleet_identity_read(state).await;
            let text = gateway::format_identity_reports(&reports);
            let reports_json = serde_json::to_value(&reports).unwrap_or(json!([]));
            JsonRpcResponse::success(
                id,
                json!({
                    "content": [{ "type": "text", "text": text }],
                    "reports": reports_json,
                }),
            )
        }
        "repin" => {
            let project = args.get("project").and_then(|v| v.as_str()).unwrap_or("");
            if project.is_empty() {
                return JsonRpcResponse::error(
                    id,
                    -32602,
                    "missing required parameter for action=\"repin\": project".into(),
                );
            }
            match gateway::fleet_identity_repin(state, project).await {
                Ok(node_id) => JsonRpcResponse::success(
                    id,
                    json!({
                        "content": [{
                            "type": "text",
                            "text": format!(
                                "Re-pinned `{project}` to verified node_id `{node_id}`."
                            )
                        }]
                    }),
                ),
                Err(e) => JsonRpcResponse::error(id, -32602, e.to_string()),
            }
        }
        other => JsonRpcResponse::error(
            id,
            -32602,
            format!("unknown fleet_identity action \"{other}\"; expected \"read\" or \"repin\""),
        ),
    }
}

/// Handle the `pipeline.get` read-RPC — returns per-project item lists in the
/// shape expected by the gateway web UI:
/// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`.
async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpcResponse {
    let project_urls: BTreeMap<String, String> = state
        .projects
        .read()
        .await
        .iter()
        .filter_map(|(n, e)| e.url.as_ref().map(|u| (n.clone(), u.clone())))
        .collect();

    let results = gateway::io::fetch_all_project_pipeline_items(&project_urls, &state.client).await;
    let active = state.active_project.read().await.clone();

    JsonRpcResponse::success(id, json!({ "active": active, "projects": results }))
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::service::gateway::config::{GatewayConfig, ProjectEntry};
    use std::collections::BTreeMap;
    use std::sync::Arc;

    fn make_test_state(config_dir: &std::path::Path) -> Arc<GatewayState> {
        let mut projects = BTreeMap::new();
        projects.insert(
            "test-project".to_string(),
            ProjectEntry::with_url("http://127.0.0.1:3001"),
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        Arc::new(GatewayState::new(config, config_dir.to_path_buf(), 3000).unwrap())
    }

    #[tokio::test]
    async fn gateway_health_relay_status_distinguishes_active_and_silent_sleds() {
        crate::crdt_state::init_for_test();

        let dir = tempfile::tempdir().unwrap();
        let mut projects = BTreeMap::new();
        projects.insert(
            "project-a".to_string(),
            ProjectEntry::with_url("http://127.0.0.1:9001"),
        );
        projects.insert(
            "project-b".to_string(),
            ProjectEntry::with_url("http://127.0.0.1:9002"),
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let state = Arc::new(GatewayState::new(config, dir.path().to_path_buf(), 3000).unwrap());

        // Fire a recent StageTransition for project-a only.
        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
        gateway::broadcast_status_event(
            &state,
            "project-a".to_string(),
            crate::service::events::StoredEvent::StageTransition {
                story_id: "1_story_test".to_string(),
                story_name: String::new(),
                from_stage: "Backlog".to_string(),
                to_stage: "Current".to_string(),
                timestamp_ms: now_ms,
            },
        );

        let resp = handle_gateway_health_tool(&state, Some(json!(1))).await;
        assert!(resp.result.is_some(), "expected result, got error");
        let text = resp.result.unwrap()["content"][0]["text"]
            .as_str()
            .unwrap()
            .to_string();
        assert!(
            text.contains("relay=ok"),
            "project-a should report relay=ok; got:\n{text}"
        );
        assert!(
            text.contains("relay=never") || text.contains("relay=silent"),
            "project-b should report relay=never or relay=silent; got:\n{text}"
        );
    }

    #[tokio::test]
    async fn adopt_project_tool_missing_name_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": { "path": "/some/path" } });
        let resp = handle_adopt_project_tool(&params, &state, Some(json!(1))).await;
        assert!(resp.error.is_some(), "expected error for missing name");
        let msg = resp.error.unwrap().message;
        assert!(msg.contains("name"), "expected 'name' in error, got: {msg}");
    }

    #[tokio::test]
    async fn adopt_project_tool_missing_path_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": { "name": "myapp" } });
        let resp = handle_adopt_project_tool(&params, &state, Some(json!(1))).await;
        assert!(resp.error.is_some(), "expected error for missing path");
        let msg = resp.error.unwrap().message;
        assert!(msg.contains("path"), "expected 'path' in error, got: {msg}");
    }

    #[tokio::test]
    async fn adopt_project_tool_nonexistent_path_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": { "name": "myapp", "path": "/nonexistent/xyz/abc123" } });
        let resp = handle_adopt_project_tool(&params, &state, Some(json!(1))).await;
        assert!(resp.error.is_some(), "expected error for nonexistent path");
        let msg = resp.error.unwrap().message;
        assert!(
            msg.contains("does not exist"),
            "expected 'does not exist' in error, got: {msg}"
        );
    }

    #[tokio::test]
    async fn adopt_project_tool_file_path_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("not_a_dir.txt");
        std::fs::write(&file, "content").unwrap();
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": { "name": "myapp", "path": file.to_str().unwrap() } });
        let resp = handle_adopt_project_tool(&params, &state, Some(json!(1))).await;
        assert!(resp.error.is_some(), "expected error for file path");
        let msg = resp.error.unwrap().message;
        assert!(
            msg.contains("not a directory"),
            "expected 'not a directory' in error, got: {msg}"
        );
    }

    /// The MCP entry point produces the same validation outcome as the chat-routed call.
    ///
    /// Both paths ultimately run the same checks: path-doesn't-exist and
    /// path-is-file are tested here to verify the MCP layer is consistent
    /// with `handle_new_project` in `new_project.rs`.
    #[tokio::test]
    async fn adopt_project_tool_matches_chat_routed_call() {
        use crate::chat::transport::matrix::new_project::handle_new_project;
        use tokio::sync::RwLock;

        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("a_file.txt");
        std::fs::write(&file, "not a dir").unwrap();
        let file_path = file.to_str().unwrap();

        // Chat-routed: handle_new_project returns a text string with the error.
        let store = Arc::new(RwLock::new(BTreeMap::new()));
        let chat_result = handle_new_project(
            "myapp",
            None,
            None,
            None,
            None,
            Some(file_path),
            false,
            &store,
            dir.path(),
        )
        .await;
        assert!(
            chat_result.contains("not a directory"),
            "chat path should report 'not a directory', got: {chat_result}"
        );

        // MCP-routed: handle_adopt_project_tool returns a JSON-RPC error.
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": { "name": "myapp2", "path": file_path } });
        let mcp_resp = handle_adopt_project_tool(&params, &state, Some(json!(1))).await;
        assert!(mcp_resp.error.is_some(), "MCP path should return an error");
        let mcp_msg = mcp_resp.error.unwrap().message;
        assert!(
            mcp_msg.contains("not a directory"),
            "MCP path should report 'not a directory', got: {mcp_msg}"
        );
    }

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

    #[tokio::test]
    async fn fleet_identity_read_default_action_lists_projects() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": {} });
        let resp = handle_fleet_identity_tool(&params, &state, Some(json!(1))).await;
        assert!(resp.error.is_none(), "expected success: {:?}", resp.error);
        let text = resp.result.unwrap()["content"][0]["text"]
            .as_str()
            .unwrap()
            .to_string();
        assert!(text.contains("test-project"));
    }

    #[tokio::test]
    async fn fleet_identity_repin_missing_project_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": { "action": "repin" } });
        let resp = handle_fleet_identity_tool(&params, &state, Some(json!(1))).await;
        assert!(resp.error.is_some(), "expected error for missing project");
        let msg = resp.error.unwrap().message;
        assert!(
            msg.contains("project"),
            "expected 'project' in error, got: {msg}"
        );
    }

    #[tokio::test]
    async fn fleet_identity_repin_unknown_project_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": { "action": "repin", "project": "nonexistent" } });
        let resp = handle_fleet_identity_tool(&params, &state, Some(json!(1))).await;
        assert!(resp.error.is_some(), "expected error for unknown project");
    }

    #[tokio::test]
    async fn fleet_identity_unknown_action_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let params = json!({ "arguments": { "action": "bogus" } });
        let resp = handle_fleet_identity_tool(&params, &state, Some(json!(1))).await;
        assert!(resp.error.is_some(), "expected error for unknown action");
        let msg = resp.error.unwrap().message;
        assert!(msg.contains("unknown fleet_identity action"), "got: {msg}");
    }

    #[test]
    fn fleet_identity_is_in_gateway_tools() {
        assert!(GATEWAY_TOOLS.contains(&"fleet_identity"));
    }

    // ── explicit per-call `project` targeting (story 1208 AC 1) ─────────────

    #[tokio::test]
    async fn proxy_for_unknown_project_returns_invalid_params_error() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let resp =
            proxy_and_respond_for_project(&state, "nonexistent", b"{}", Some(json!(1))).await;
        let bytes = resp.into_body().into_bytes().await.unwrap();
        let parsed: Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(parsed["error"]["code"], -32602);
        assert!(
            parsed["error"]["message"]
                .as_str()
                .unwrap()
                .contains("unknown project"),
            "got: {parsed}"
        );
    }

    #[tokio::test]
    async fn proxy_for_known_project_without_live_connection_returns_proxy_error() {
        let dir = tempfile::tempdir().unwrap();
        let state = make_test_state(dir.path());
        let resp =
            proxy_and_respond_for_project(&state, "test-project", b"{}", Some(json!(1))).await;
        let bytes = resp.into_body().into_bytes().await.unwrap();
        let parsed: Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(parsed["error"]["code"], -32603);
        assert!(
            parsed["error"]["message"]
                .as_str()
                .unwrap()
                .contains("no live WS uplink"),
            "got: {parsed}"
        );
    }

    #[test]
    fn inject_project_arg_schema_adds_property_to_object_schema() {
        let mut tools = vec![json!({
            "name": "create_story",
            "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}}}
        })];
        inject_project_arg_schema(&mut tools);
        assert_eq!(
            tools[0]["inputSchema"]["properties"]["project"]["type"],
            "string"
        );
        assert_eq!(
            tools[0]["inputSchema"]["properties"]["name"]["type"],
            "string"
        );
    }

    #[test]
    fn inject_project_arg_schema_does_not_overwrite_existing_project_property() {
        let mut tools = vec![json!({
            "name": "weird_tool",
            "inputSchema": {"type": "object", "properties": {"project": {"type": "integer"}}}
        })];
        inject_project_arg_schema(&mut tools);
        assert_eq!(
            tools[0]["inputSchema"]["properties"]["project"]["type"],
            "integer"
        );
    }

    #[test]
    fn inject_project_arg_schema_skips_tool_without_properties() {
        let mut tools = vec![json!({"name": "no_schema"})];
        inject_project_arg_schema(&mut tools);
        assert_eq!(tools[0], json!({"name": "no_schema"}));
    }
}
