//! The IdeA MCP **tool catalogue** and the tool → [`OrchestratorRequest`] mapping.
//!
//! Each tool here is a thin, typed face over an **already-existing**
//! [`OrchestratorCommand`] (cadrage Décision 4, principe directeur : « le serveur
//! MCP n'invente aucune sémantique »). The mapping builds an
//! [`OrchestratorRequest`] from the tool arguments and lets
//! [`OrchestratorRequest::validate`] be the **single source of truth** for
//! validation — the very same path the filesystem watcher takes. No tool
//! validates anything itself; no tool reaches a use case directly.
//!
//! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`)
//!
//! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is
//! served through the **same** `OrchestratorService::dispatch` as every other tool
//! (no use-case is reached directly). Its success carries the agent list inline as
//! a JSON array in the outcome's reply — see [`tool_returns_reply`].
//!
//! Delegation maps to [`OrchestratorCommand::AskAgent`]. The MCP tool is only an
//! ergonomic IdeA entrypoint: the application orchestrator drives the target's
//! structured/headless session and returns the target turn's `Final` inline. The
//! target never receives a model-managed ticket and never has to call `idea_reply`.

use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
use serde_json::{json, Value};

/// A tool the IdeA MCP server advertises, with the JSON Schema MCP clients read
/// from `tools/list`.
pub struct ToolDef {
    /// Tool name as seen by the agent (`idea_ask_agent`, …).
    pub name: &'static str,
    /// One-line human description shown in the client.
    pub description: &'static str,
    /// JSON Schema (object) for the tool's arguments.
    pub input_schema: Value,
}

/// Coarse MCP tool access class used to derive default permissions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpToolAccess {
    /// Read-only application capability, allowed by default.
    Read,
    /// Write/action/execution capability, denied by default.
    WriteAction,
}

/// Canonical read-only MCP tools allowed by default.
pub const READ_ONLY_TOOLS: &[&str] = &[
    "idea_list_agents",
    "idea_context_read",
    "idea_memory_read",
    "idea_skill_read",
    "idea_workstate_read",
    "idea_ticket_read",
    "idea_ticket_list",
    "idea_ticket_read_carnet",
    "idea_ticket_attachment_read",
    "idea_sprint_list",
    "idea_template_list",
    "idea_template_read",
];

/// Canonical write/action MCP tools denied by default.
pub const WRITE_ACTION_TOOLS: &[&str] = &[
    "idea_ask_agent",
    "idea_ask_agents",
    "idea_run_in_background",
    "idea_launch_agent",
    "idea_stop_agent",
    "idea_update_context",
    "idea_context_propose",
    "idea_context_update",
    "idea_memory_write",
    "idea_workstate_set",
    "idea_create_skill",
    "idea_ticket_create",
    "idea_ticket_update",
    "idea_ticket_update_status",
    "idea_ticket_update_priority",
    "idea_ticket_bulk_update_status",
    "idea_ticket_bulk_update_priority",
    "idea_ticket_bulk_delete",
    "idea_ticket_update_carnet",
    "idea_ticket_attachment_add",
    "idea_ticket_attachment_mark_summarized",
    "idea_ticket_link",
    "idea_ticket_unlink",
    "idea_template_create",
    "idea_template_update",
    "idea_template_delete",
];

/// All MCP tool names that have an explicit access classification.
#[must_use]
pub fn classified_tool_names() -> Vec<&'static str> {
    READ_ONLY_TOOLS
        .iter()
        .chain(WRITE_ACTION_TOOLS.iter())
        .copied()
        .collect()
}

/// Returns the explicit access class for `tool`.
#[must_use]
pub fn tool_access(tool: &str) -> Option<McpToolAccess> {
    if READ_ONLY_TOOLS.contains(&tool) {
        Some(McpToolAccess::Read)
    } else if WRITE_ACTION_TOOLS.contains(&tool) {
        Some(McpToolAccess::WriteAction)
    } else {
        None
    }
}

/// Errors mapping a tool call into a validated [`OrchestratorCommand`].
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ToolMapError {
    /// The tool name is not one this server exposes.
    #[error("unknown tool: {0}")]
    UnknownTool(String),
    /// The `arguments` object was absent or not a JSON object.
    #[error("tool `{0}` arguments must be a JSON object")]
    BadArguments(String),
    /// The arguments did not satisfy the underlying command's invariants.
    #[error(transparent)]
    Invalid(#[from] OrchestratorError),
}

/// Whether a successful call of `tool` carries an inline reply payload back to the
/// caller. `idea_ask_agent` is a synchronous delegation tool: its payload is the
/// target agent's captured final answer.
#[must_use]
pub fn tool_returns_reply(tool: &str) -> bool {
    matches!(
        tool,
        "idea_ask_agent"
            | "idea_ask_agents"
            | "idea_list_agents"
            | "idea_context_read"
            | "idea_memory_read"
            | "idea_skill_read"
            | "idea_run_in_background"
            | "idea_workstate_read"
    ) || is_ticket_tool(tool)
        || is_template_tool(tool)
}

/// Whether `tool` is a public ticket MCP tool.
#[must_use]
pub fn is_ticket_tool(tool: &str) -> bool {
    super::tickets::is_ticket_tool(tool)
}

/// Whether `tool` is a public template MCP tool.
#[must_use]
pub fn is_template_tool(tool: &str) -> bool {
    super::templates::is_template_tool(tool)
}

/// The full catalogue advertised on `tools/list`.
///
/// Exactly the tools whose mapping target already exists as an
/// [`OrchestratorCommand`].
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
    let mut tools = vec![
        ToolDef {
            name: "idea_list_agents",
            description: "List the IdeA agents declared in the project's manifest. Returns the \
                          agents inline as a JSON array (id, name, profile, origin, raw skills, \
                          resolved capabilities, and isOrchestrator).",
            input_schema: json!({
                "type": "object",
                "properties": {},
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_ask_agent",
            description: "Ask another IdeA agent to handle a task and wait for its final answer. \
                          IdeA launches or reattaches the target through its structured/headless \
                          profile, captures the target turn's Final, and returns it inline. The \
                          target does not call a reply tool or manage tickets.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "target": { "type": "string", "description": "Target agent display name." },
                    "task": { "type": "string", "description": "Task or question to send to the target agent." }
                },
                "required": ["target", "task"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_ask_agents",
            description: "Ask several IdeA agents in parallel and return one result per request, \
                          in the same order. Each request is equivalent to one idea_ask_agent \
                          call; duplicate targets are rejected before dispatch.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "requests": {
                        "type": "array",
                        "minItems": 1,
                        "items": {
                            "type": "object",
                            "properties": {
                                "target": { "type": "string", "description": "Target agent display name." },
                                "task": { "type": "string", "description": "Task or question to send to the target agent." }
                            },
                            "required": ["target", "task"],
                            "additionalProperties": false
                        }
                    }
                },
                "required": ["requests"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_run_in_background",
            description: "Run a command as a first-class IdeA background task. Returns the task id \
                          immediately; completion is delivered later to your inbox.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "label": { "type": "string", "description": "Human-facing label for the background task." },
                    "command": { "type": "string", "description": "Executable to run." },
                    "args": {
                        "type": "array",
                        "items": { "type": "string" },
                        "description": "Command arguments."
                    },
                    "cwd": { "type": "string", "description": "Working directory under the project root. Defaults to the project root." },
                    "deadline_ms": { "type": "number", "description": "Optional absolute deadline, epoch milliseconds." }
                },
                "required": ["label", "command"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_launch_agent",
            description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
                          has started the agent, without waiting for any work.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "target": { "type": "string", "description": "Target agent display name." },
                    "profile": { "type": "string", "description": "Optional profile reference for a not-yet-created agent." },
                    "task": { "type": "string", "description": "Optional initial task/context for the launched agent." },
                    "visibility": { "type": "string", "enum": ["background", "visible"], "description": "Where to place the session (default background)." },
                    "nodeId": { "type": "string", "description": "Target layout cell id, required when visibility is \"visible\"." }
                },
                "required": ["target"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_stop_agent",
            description: "Stop a running IdeA agent by killing its terminal session.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "target": { "type": "string", "description": "Target agent display name." }
                },
                "required": ["target"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_update_context",
            description: "Overwrite an IdeA agent's `.md` context with a new Markdown body.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "target": { "type": "string", "description": "Target agent display name." },
                    "context": { "type": "string", "description": "New Markdown body." }
                },
                "required": ["target", "context"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_context_read",
            description: "Read an IdeA-owned context (under IdeA's reader/writer file guard). \
                          Omit `target` for the global project context, or pass an agent's display \
                          name for that agent's `.md`. Reading is shared (never blocks other readers).",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "target": { "type": "string", "description": "Agent display name. Omit for the global project context." }
                },
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_context_propose",
            description: "Propose new Markdown content for an IdeA-owned context (under the file \
                          guard). For an agent's `.md` this writes directly; for the **global** \
                          project context it is only a *proposal* (the global context is \
                          single-writer — reserved to the orchestrator — so your change is recorded \
                          for validation, not applied).",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "target": { "type": "string", "description": "Agent display name. Omit to target the global project context (proposal only)." },
                    "content": { "type": "string", "description": "The proposed Markdown body." }
                },
                "required": ["content"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_context_update",
            description: "Directly update the global project context. Strict, orchestrator-only \
                          counterpart to idea_context_propose: if you are not the project's \
                          current orchestrator (see isOrchestrator on idea_list_agents), this call \
                          fails and never silently files a proposal. Pass ifMatch from a prior \
                          idea_context_read to reject the write if the context changed.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "content": { "type": "string", "description": "The new Markdown body." },
                    "ifMatch": { "type": "string", "description": "Optional version from a prior idea_context_read; rejected if stale." }
                },
                "required": ["content"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_memory_read",
            description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
                          index, or pass a note slug for one note.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "slug": { "type": "string", "description": "Memory note slug. Omit for the aggregated index." }
                },
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_memory_write",
            description: "Write (create or replace) a project memory note under the file guard. \
                          Memory is shared across the project's agents.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "slug": { "type": "string", "description": "Memory note slug (kebab-case)." },
                    "content": { "type": "string", "description": "The Markdown body to store." }
                },
                "required": ["slug", "content"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_skill_read",
            description: "Read the full body of an IdeA skill assigned to you, by name — call this \
                          to actually execute/follow a skill listed in your « Skills disponibles » \
                          section. Resolves project scope first, then global. Returns the skill's \
                          Markdown inline.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "name": { "type": "string", "description": "Skill name (as listed in your context)." }
                },
                "required": ["name"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_workstate_read",
            description: "Read what every IdeA agent is doing right now (the project live-state). \
                          Returns a JSON array, one row per agent: name, status \
                          (idle|working|blocked|waiting|done), short intent, and optional \
                          current/last-delegation ticket. Last-writer-wins, not real-time.",
            input_schema: json!({
                "type": "object",
                "properties": {},
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_workstate_set",
            description: "Publish your own current status to the project live-state so other \
                          agents can coordinate. Writes only YOUR row (keyed on your identity). \
                          `status` is required; `intent`/`progress`/`ticket`/`lastDelegation` are \
                          optional. Acknowledged only (no reply).",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "status": { "type": "string", "enum": ["idle", "working", "blocked", "waiting", "done"], "description": "Your coarse status right now." },
                    "intent": { "type": "string", "description": "Short description of what you are doing." },
                    "progress": { "type": "string", "description": "Optional finer-grained progress note." },
                    "ticket": { "type": "string", "description": "Optional id of the ticket you are currently handling." },
                    "lastDelegation": { "type": "string", "description": "Optional id of the most recent delegation you issued." }
                },
                "required": ["status"],
                "additionalProperties": false
            }),
        },
        ToolDef {
            name: "idea_create_skill",
            description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
            input_schema: json!({
                "type": "object",
                "properties": {
                    "name": { "type": "string", "description": "Skill name (also the .md stem)." },
                    "context": { "type": "string", "description": "Markdown body of the skill." },
                    "scope": { "type": "string", "enum": ["project", "global"], "description": "Scope (default project)." }
                },
                "required": ["name", "context"],
                "additionalProperties": false
            }),
        },
    ];
    tools.extend(super::tickets::catalogue());
    tools.extend(super::templates::catalogue());
    tools
}

/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated
/// [`OrchestratorCommand`], reusing [`OrchestratorRequest::validate`] as the one
/// validation authority.
///
/// `arguments` is the raw object an MCP client passes under `params.arguments`.
/// `requester` is the **connected peer's agent id** (from the loopback handshake,
/// cadrage v5 §1.4): `idea_ask_agent` carries it as `requestedBy` for A↔B routing
/// and cycle detection. Self-keyed tools also use it as their identity; the model
/// never supplies another agent's id.
///
/// # Errors
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
/// - [`ToolMapError::BadArguments`] when `arguments` is not an object,
/// - [`ToolMapError::Invalid`] when the underlying command's invariants fail.
pub fn map_tool_call(
    name: &str,
    arguments: &Value,
    requester: &str,
) -> Result<OrchestratorCommand, ToolMapError> {
    let args = arguments
        .as_object()
        .ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?;

    // Small helpers to pull optional string fields out of the arguments object.
    let s =
        |key: &str| -> Option<String> { args.get(key).and_then(Value::as_str).map(str::to_owned) };

    // Translate the tool into the wire-level request shape the watcher already
    // uses (v2 `type`), then let `validate` enforce the invariants once.
    let request = match name {
        "idea_list_agents" => OrchestratorRequest {
            request_type: Some("agent.list".to_owned()),
            ..base()
        },
        "idea_launch_agent" => OrchestratorRequest {
            request_type: Some("agent.run".to_owned()),
            target_agent: s("target"),
            profile: s("profile"),
            task: s("task"),
            visibility: s("visibility"),
            node_id: parse_node_id(args.get("nodeId")),
            ..base()
        },
        "idea_ask_agent" => OrchestratorRequest {
            request_type: Some("agent.message".to_owned()),
            requested_by: Some(requester.to_owned()),
            target_agent: s("target"),
            task: s("task"),
            ..base()
        },
        "idea_run_in_background" => OrchestratorRequest {
            request_type: Some("background.run".to_owned()),
            requested_by: Some(requester.to_owned()),
            label: s("label"),
            command: s("command"),
            args: string_array(args.get("args"), name)?,
            cwd: s("cwd"),
            deadline_ms: optional_u64(args.get("deadline_ms"), name)?,
            ..base()
        },
        "idea_stop_agent" => OrchestratorRequest {
            request_type: Some("agent.stop".to_owned()),
            target_agent: s("target"),
            ..base()
        },
        "idea_update_context" => OrchestratorRequest {
            request_type: Some("agent.update_context".to_owned()),
            target_agent: s("target"),
            context: s("context"),
            ..base()
        },
        "idea_context_read" => OrchestratorRequest {
            request_type: Some("context.read".to_owned()),
            // The requester (handshake identity) drives the read lease's `who`.
            requested_by: Some(requester.to_owned()),
            target_agent: s("target"),
            ..base()
        },
        "idea_context_propose" => OrchestratorRequest {
            request_type: Some("context.propose".to_owned()),
            requested_by: Some(requester.to_owned()),
            target_agent: s("target"),
            content: s("content"),
            ..base()
        },
        "idea_context_update" => {
            reject_unknown_keys(name, args, &["content", "ifMatch"])?;
            OrchestratorRequest {
                request_type: Some("context.update".to_owned()),
                requested_by: Some(requester.to_owned()),
                content: s("content"),
                if_match: s("ifMatch"),
                ..base()
            }
        }
        "idea_memory_read" => OrchestratorRequest {
            request_type: Some("memory.read".to_owned()),
            requested_by: Some(requester.to_owned()),
            slug: s("slug"),
            ..base()
        },
        "idea_memory_write" => OrchestratorRequest {
            request_type: Some("memory.write".to_owned()),
            requested_by: Some(requester.to_owned()),
            slug: s("slug"),
            content: s("content"),
            ..base()
        },
        "idea_skill_read" => OrchestratorRequest {
            request_type: Some("skill.read".to_owned()),
            // The requester (handshake identity) is carried for symmetry with the
            // other read tools; `validate` derives the requester party from it.
            requested_by: Some(requester.to_owned()),
            name: s("name"),
            ..base()
        },
        "idea_workstate_read" => OrchestratorRequest {
            request_type: Some("workstate.read".to_owned()),
            // The requester (handshake identity) is carried for symmetry with the
            // other read tools; the read is project-wide.
            requested_by: Some(requester.to_owned()),
            ..base()
        },
        "idea_workstate_set" => OrchestratorRequest {
            request_type: Some("workstate.set".to_owned()),
            // The written key is the connected peer's handshake identity (never a tool
            // argument): injected as `requestedBy` so `validate` keys the row on it.
            requested_by: Some(requester.to_owned()),
            status: s("status"),
            intent: s("intent"),
            progress: s("progress"),
            ticket: s("ticket"),
            last_delegation: s("lastDelegation"),
            ..base()
        },
        "idea_create_skill" => OrchestratorRequest {
            request_type: Some("skill.create".to_owned()),
            name: s("name"),
            context: s("context"),
            scope: s("scope"),
            ..base()
        },
        other => return Err(ToolMapError::UnknownTool(other.to_owned())),
    };

    request.validate().map_err(ToolMapError::Invalid)
}

/// An all-`None` request to spread over; keeps each arm above to just its fields.
fn base() -> OrchestratorRequest {
    OrchestratorRequest {
        action: None,
        request_type: None,
        requested_by: None,
        name: None,
        target_agent: None,
        profile: None,
        context: None,
        task: None,
        visibility: None,
        node_id: None,
        attach_to_cell: None,
        scope: None,
        result: None,
        ticket: None,
        content: None,
        if_match: None,
        slug: None,
        status: None,
        intent: None,
        progress: None,
        last_delegation: None,
        label: None,
        command: None,
        args: Vec::new(),
        cwd: None,
        deadline_ms: None,
    }
}

fn reject_unknown_keys(
    tool: &str,
    args: &serde_json::Map<String, Value>,
    allowed: &[&str],
) -> Result<(), ToolMapError> {
    if args
        .keys()
        .any(|key| !allowed.iter().any(|allowed_key| allowed_key == key))
    {
        return Err(ToolMapError::BadArguments(tool.to_owned()));
    }
    Ok(())
}

/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
/// dropping a malformed/absent one (validation then rejects a `visible` launch
/// missing its node, with a precise field error).
fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
    let raw = value?.as_str()?;
    uuid::Uuid::parse_str(raw)
        .ok()
        .map(domain::NodeId::from_uuid)
}

fn string_array(value: Option<&Value>, tool: &str) -> Result<Vec<String>, ToolMapError> {
    let Some(value) = value else {
        return Ok(Vec::new());
    };
    let items = value
        .as_array()
        .ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))?;
    items
        .iter()
        .map(|item| {
            item.as_str()
                .map(str::to_owned)
                .ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))
        })
        .collect()
}

fn optional_u64(value: Option<&Value>, tool: &str) -> Result<Option<u64>, ToolMapError> {
    let Some(value) = value else {
        return Ok(None);
    };
    value
        .as_u64()
        .map(Some)
        .ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::OrchestratorVisibility;
    use std::collections::HashSet;

    /// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
    const REQ: &str = "11111111-1111-1111-1111-111111111111";

    /// Maps a tool call with an empty requester (the default for tools that ignore it).
    fn map(name: &str, args: &Value) -> Result<OrchestratorCommand, ToolMapError> {
        map_tool_call(name, args, "")
    }

    #[test]
    fn catalogue_tools_have_explicit_read_or_write_access() {
        let catalogue_names = catalogue()
            .into_iter()
            .map(|tool| tool.name)
            .collect::<HashSet<_>>();

        for tool in &catalogue_names {
            assert!(
                tool_access(tool).is_some(),
                "MCP catalogue tool `{tool}` must choose Read or WriteAction explicitly"
            );
        }

        for tool in classified_tool_names() {
            assert!(
                catalogue_names.contains(tool),
                "classified MCP tool `{tool}` is not advertised by the catalogue"
            );
        }
    }

    #[test]
    fn read_and_write_tool_classifications_are_disjoint_and_unique() {
        let mut seen = HashSet::new();
        for tool in READ_ONLY_TOOLS {
            assert!(seen.insert(*tool), "duplicate read-only MCP tool `{tool}`");
            assert_eq!(tool_access(tool), Some(McpToolAccess::Read));
        }
        for tool in WRITE_ACTION_TOOLS {
            assert!(
                seen.insert(*tool),
                "MCP tool `{tool}` is classified more than once"
            );
            assert_eq!(tool_access(tool), Some(McpToolAccess::WriteAction));
        }
    }

    #[test]
    fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
        let requester = uuid::Uuid::from_u128(42).to_string();
        assert_eq!(
            map_tool_call(
                "idea_ask_agent",
                &json!({ "target": "Architect", "task": "Analyse §17" }),
                &requester,
            )
            .unwrap(),
            OrchestratorCommand::AskAgent {
                target: "Architect".to_owned(),
                task: "Analyse §17".to_owned(),
                requester: Some(domain::AgentId::from_uuid(
                    uuid::Uuid::parse_str(&requester).unwrap()
                )),
            }
        );
        assert_eq!(
            map_tool_call(
                "idea_ask_agent",
                &json!({ "target": "Architect", "task": "Analyse §17" }),
                "",
            ),
            Ok(OrchestratorCommand::AskAgent {
                target: "Architect".to_owned(),
                task: "Analyse §17".to_owned(),
                requester: None,
            })
        );
        assert_eq!(
            map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ),
            Err(ToolMapError::UnknownTool("idea_reply".to_owned()))
        );
    }

    #[test]
    fn ask_agents_is_classified_and_returns_inline_reply() {
        assert_eq!(
            tool_access("idea_ask_agents"),
            Some(McpToolAccess::WriteAction)
        );
        assert!(catalogue()
            .into_iter()
            .any(|tool| tool.name == "idea_ask_agents"));
        assert!(tool_returns_reply("idea_ask_agents"));
    }

    #[test]
    fn run_in_background_maps_to_background_command_with_handshake_owner() {
        let cmd = map_tool_call(
            "idea_run_in_background",
            &json!({
                "label": "B8 T1",
                "command": "bash",
                "args": ["-lc", "sleep 45 && echo DONE"],
                "cwd": "crates",
                "deadline_ms": 123456
            }),
            REQ,
        )
        .unwrap();

        assert_eq!(
            cmd,
            OrchestratorCommand::RunInBackground {
                owner: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
                label: "B8 T1".to_owned(),
                command: "bash".to_owned(),
                args: vec!["-lc".to_owned(), "sleep 45 && echo DONE".to_owned()],
                cwd: Some("crates".to_owned()),
                deadline_ms: Some(123456),
            }
        );
        assert!(tool_returns_reply("idea_run_in_background"));
    }

    #[test]
    fn run_in_background_rejects_absent_requester_and_malformed_args() {
        let no_requester = map_tool_call(
            "idea_run_in_background",
            &json!({ "label": "x", "command": "bash" }),
            "",
        );
        assert!(matches!(no_requester, Err(ToolMapError::Invalid(_))));

        assert_eq!(
            map_tool_call(
                "idea_run_in_background",
                &json!({ "label": "x", "command": "bash", "args": "nope" }),
                REQ,
            ),
            Err(ToolMapError::BadArguments(
                "idea_run_in_background".to_owned()
            ))
        );
        assert_eq!(
            map_tool_call(
                "idea_run_in_background",
                &json!({ "label": "x", "command": "bash", "args": [1] }),
                REQ,
            ),
            Err(ToolMapError::BadArguments(
                "idea_run_in_background".to_owned()
            ))
        );
    }

    #[test]
    fn launch_agent_maps_to_spawn_background_by_default() {
        let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
        assert_eq!(
            cmd,
            OrchestratorCommand::SpawnAgent {
                name: "Dev".to_owned(),
                profile: None,
                context: None,
                visibility: OrchestratorVisibility::Background,
            }
        );
    }

    #[test]
    fn stop_update_and_skill_map_to_their_commands() {
        assert_eq!(
            map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
            OrchestratorCommand::StopAgent {
                name: "Dev".to_owned()
            }
        );
        assert_eq!(
            map(
                "idea_update_context",
                &json!({ "target": "Dev", "context": "# body" })
            )
            .unwrap(),
            OrchestratorCommand::UpdateAgentContext {
                name: "Dev".to_owned(),
                context: "# body".to_owned(),
            }
        );
        assert_eq!(
            map(
                "idea_create_skill",
                &json!({ "name": "deploy", "context": "# steps" })
            )
            .unwrap(),
            OrchestratorCommand::CreateSkill {
                name: "deploy".to_owned(),
                content: "# steps".to_owned(),
                scope: domain::SkillScope::Project,
            }
        );
    }

    #[test]
    fn missing_required_field_surfaces_validation_error() {
        let err = map("idea_update_context", &json!({ "target": "Architect" }));
        assert!(matches!(err, Err(ToolMapError::Invalid(_))));
    }

    #[test]
    fn list_agents_maps_to_list_command() {
        let cmd = map("idea_list_agents", &json!({})).unwrap();
        assert_eq!(cmd, OrchestratorCommand::ListAgents);
    }

    #[test]
    fn unknown_tool_is_rejected() {
        let err = map("idea_made_up_tool", &json!({}));
        assert_eq!(
            err,
            Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned()))
        );
    }

    #[test]
    fn non_object_arguments_are_rejected() {
        let err = map("idea_launch_agent", &json!("nope"));
        assert_eq!(
            err,
            Err(ToolMapError::BadArguments("idea_launch_agent".to_owned()))
        );
    }

    #[test]
    fn context_read_maps_with_requester_party() {
        // Global (no target) with a uuid requester ⇒ Agent party.
        let cmd = map_tool_call("idea_context_read", &json!({}), REQ).unwrap();
        assert_eq!(
            cmd,
            OrchestratorCommand::ReadContext {
                target: None,
                requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
                    uuid::Uuid::parse_str(REQ).unwrap()
                )),
            }
        );
    }

    #[test]
    fn context_propose_requires_content() {
        let ok = map_tool_call(
            "idea_context_propose",
            &json!({ "target": "Dev", "content": "# body" }),
            REQ,
        )
        .unwrap();
        assert_eq!(
            ok,
            OrchestratorCommand::ProposeContext {
                target: Some("Dev".to_owned()),
                content: "# body".to_owned(),
                requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
                    uuid::Uuid::parse_str(REQ).unwrap()
                )),
            }
        );
        let err = map_tool_call("idea_context_propose", &json!({ "target": "Dev" }), REQ);
        assert!(matches!(err, Err(ToolMapError::Invalid(_))));
    }

    #[test]
    fn idea_context_update_tool_registered_and_maps_to_context_update_command() {
        assert_eq!(
            tool_access("idea_context_update"),
            Some(McpToolAccess::WriteAction)
        );
        assert!(catalogue()
            .into_iter()
            .any(|tool| tool.name == "idea_context_update"));

        let cmd = map_tool_call(
            "idea_context_update",
            &json!({ "content": "# body", "ifMatch": "abc123" }),
            REQ,
        )
        .unwrap();

        assert_eq!(
            cmd,
            OrchestratorCommand::UpdateProjectContext {
                content: "# body".to_owned(),
                if_match: Some("abc123".to_owned()),
                requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
                    uuid::Uuid::parse_str(REQ).unwrap()
                )),
            }
        );
    }

    #[test]
    fn idea_context_update_requires_content_and_rejects_unknown_fields() {
        let missing = map_tool_call("idea_context_update", &json!({ "ifMatch": "abc123" }), REQ);
        assert!(matches!(missing, Err(ToolMapError::Invalid(_))));

        let unknown = map_tool_call(
            "idea_context_update",
            &json!({ "content": "# body", "target": "Dev" }),
            REQ,
        );
        assert_eq!(
            unknown,
            Err(ToolMapError::BadArguments("idea_context_update".to_owned()))
        );
    }

    #[test]
    fn memory_read_and_write_map_to_their_commands() {
        assert_eq!(
            map_tool_call("idea_memory_read", &json!({ "slug": "note-a" }), REQ).unwrap(),
            OrchestratorCommand::ReadMemory {
                slug: Some("note-a".to_owned()),
                requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
                    uuid::Uuid::parse_str(REQ).unwrap()
                )),
            }
        );
        assert_eq!(
            map_tool_call(
                "idea_memory_write",
                &json!({ "slug": "note-a", "content": "hi" }),
                REQ
            )
            .unwrap(),
            OrchestratorCommand::WriteMemory {
                slug: "note-a".to_owned(),
                content: "hi".to_owned(),
                requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
                    uuid::Uuid::parse_str(REQ).unwrap()
                )),
            }
        );
        // memory.write without content ⇒ validation error.
        let err = map_tool_call("idea_memory_write", &json!({ "slug": "note-a" }), REQ);
        assert!(matches!(err, Err(ToolMapError::Invalid(_))));
    }

    #[test]
    fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() {
        assert!(tool_returns_reply("idea_ask_agent"));
        assert!(!tool_returns_reply("idea_reply"));
    }

    #[test]
    fn workstate_read_maps_with_requester_party() {
        let cmd = map_tool_call("idea_workstate_read", &json!({}), REQ).unwrap();
        assert_eq!(
            cmd,
            OrchestratorCommand::ReadWorkState {
                requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
                    uuid::Uuid::parse_str(REQ).unwrap()
                )),
            }
        );
        // Read carries an inline reply (the live-state JSON array); set does not.
        assert!(tool_returns_reply("idea_workstate_read"));
        assert!(!tool_returns_reply("idea_workstate_set"));
    }

    #[test]
    fn workstate_set_keys_on_handshake_requester() {
        let tkt = "22222222-2222-2222-2222-222222222222";
        let cmd = map_tool_call(
            "idea_workstate_set",
            &json!({ "status": "working", "intent": "ship", "ticket": tkt }),
            REQ,
        )
        .unwrap();
        assert_eq!(
            cmd,
            OrchestratorCommand::SetWorkState {
                agent: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
                status: domain::live_state::WorkStatus::Working,
                intent: Some("ship".to_owned()),
                progress: None,
                ticket: Some(domain::mailbox::TicketId::from_uuid(
                    uuid::Uuid::parse_str(tkt).unwrap()
                )),
                last_delegation: None,
            }
        );
        // Missing status ⇒ validation error.
        let err = map_tool_call("idea_workstate_set", &json!({ "intent": "x" }), REQ);
        assert!(matches!(err, Err(ToolMapError::Invalid(_))));
        // Status outside the enum ⇒ validation error too (UnknownWorkStatus surfaces
        // as the generic Invalid map error).
        let bad = map_tool_call("idea_workstate_set", &json!({ "status": "busy" }), REQ);
        assert!(
            matches!(bad, Err(ToolMapError::Invalid(_))),
            "an out-of-enum status must be rejected, got {bad:?}"
        );
    }

    /// The catalogue advertises the two live-state tools (lot LS4) with schemas
    /// matching the frozen contract: `set` requires `status` constrained to the exact
    /// five-label enum; `read` takes no parameter.
    #[test]
    fn catalogue_advertises_workstate_tools_with_conformant_schemas() {
        let cat = catalogue();
        let by_name = |name: &str| {
            cat.iter()
                .find(|t| t.name == name)
                .unwrap_or_else(|| panic!("catalogue must contain {name}"))
        };

        // read — object schema, no declared properties, no required fields.
        let read = by_name("idea_workstate_read");
        assert_eq!(read.input_schema["type"], json!("object"));
        assert_eq!(
            read.input_schema["properties"],
            json!({}),
            "read takes no parameter"
        );
        assert!(
            read.input_schema.get("required").is_none(),
            "read requires nothing"
        );

        // set — `status` required, enum exactly the five canonical labels.
        let set = by_name("idea_workstate_set");
        assert_eq!(set.input_schema["type"], json!("object"));
        assert_eq!(set.input_schema["required"], json!(["status"]));
        assert_eq!(
            set.input_schema["properties"]["status"]["enum"],
            json!(["idle", "working", "blocked", "waiting", "done"]),
            "status enum must match WorkStatus labels exactly"
        );
    }
}
