//! Functional unit tests for the IdeA **MCP server** adapter (lot **M2**,
//! cadrage `orchestration-v3` §3).
//!
//! These drive [`McpServer`] **entirely off-network** — no socket, no child
//! process — over either [`McpServer::handle_raw`] (one raw JSON-RPC message →
//! its response) or [`MemoryTransport`] (a scripted in-memory session). The server
//! is wired over the **same** in-memory fakes the filesystem-watcher tests already
//! use (`orchestrator_watcher.rs`), so we assert MCP behaviour against a real
//! [`OrchestratorService`] without any I/O:
//!
//! 1. `tools/list` advertises the MCP `idea_*` tools, each with an input schema.
//! 2. `tools/call` maps every tool to the right `OrchestratorCommand` (observed
//!    through the fakes: spawn creates an agent, stop closes the session, …).
//! 3. `idea_list_agents` returns the agent list **inline** as a JSON array.
//! 4. A failed IdeA command ⇒ tool result `isError: true` (not a transport error,
//!    not a panic); the server stays alive for the next call.
//! 5. Malformed JSON-RPC ⇒ a typed `PARSE_ERROR`, never a panic.
//! 6. Unknown method / unknown tool ⇒ typed errors, never a panic.
//! 7. `initialize` answers the minimal handshake.
//! 8. A `tools/call` missing a required argument is rejected by the **same**
//!    `OrchestratorRequest::validate` (typed error, **no** dispatch).

use std::collections::HashMap;
use std::str::FromStr;
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::NodeId;
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
    AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
    ExitStatus, FileSystem, FsError, IdGenerator, McpToolPermissionStore, OutputStream,
    PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
    SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{
    AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
    StructuredAdapter,
};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{
    AgentMcpToolPolicyOverride, AgentToolPolicy, IssueRef, McpToolPolicy,
    ProjectMcpToolPermissions, PtySize, SessionId,
};
use uuid::Uuid;

use application::{
    CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
    OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
use infrastructure::orchestrator::mcp::{
    TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
};
use infrastructure::{
    InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
    SystemMillisClock, ToolPolicyRegistry,
};

use serde_json::{json, Value};

// ---------------------------------------------------------------------------
// Fakes — mirrored from `orchestrator_watcher.rs` so the MCP adapter is tested
// against the exact same OrchestratorService wiring (no use-case is re-invented).
// ---------------------------------------------------------------------------

#[derive(Default)]
struct ContextsInner {
    manifest: AgentManifest,
    contents: HashMap<String, String>,
}
#[derive(Clone)]
struct FakeContexts(Arc<Mutex<ContextsInner>>);
impl FakeContexts {
    fn new() -> Self {
        Self(Arc::new(Mutex::new(ContextsInner {
            manifest: AgentManifest {
                version: 1,
                entries: Vec::new(),
                orchestrator: None,
            },
            contents: HashMap::new(),
        })))
    }
    fn entries(&self) -> Vec<ManifestEntry> {
        self.0.lock().unwrap().manifest.entries.clone()
    }
    /// Seeds the manifest with an already-existing agent so `find_agent_id_by_name`
    /// resolves it without going through `CreateAgentFromScratch`.
    fn seed_agent(&self, name: &str) -> AgentId {
        let id = AgentId::from_uuid(Uuid::new_v4());
        let mut inner = self.0.lock().unwrap();
        inner.manifest.entries.push(ManifestEntry {
            agent_id: id,
            name: name.to_owned(),
            md_path: format!("agents/{name}.md"),
            profile_id: ProfileId::from_uuid(Uuid::from_u128(9)),
            template_id: None,
            synchronized: false,
            synced_template_version: None,
            skills: Vec::new(),
        });
        id
    }
    fn md_path_of(&self, agent: &AgentId) -> Option<String> {
        self.0
            .lock()
            .unwrap()
            .manifest
            .entries
            .iter()
            .find(|e| &e.agent_id == agent)
            .map(|e| e.md_path.clone())
    }
}
#[async_trait]
impl AgentContextStore for FakeContexts {
    async fn read_context(
        &self,
        _project: &Project,
        agent: &AgentId,
    ) -> Result<MarkdownDoc, StoreError> {
        let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
        Ok(MarkdownDoc::new(
            self.0
                .lock()
                .unwrap()
                .contents
                .get(&md)
                .cloned()
                .unwrap_or_default(),
        ))
    }
    async fn write_context(
        &self,
        _project: &Project,
        agent: &AgentId,
        md: &MarkdownDoc,
    ) -> Result<(), StoreError> {
        let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
        self.0
            .lock()
            .unwrap()
            .contents
            .insert(path, md.as_str().to_owned());
        Ok(())
    }
    async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
        Ok(self.0.lock().unwrap().manifest.clone())
    }
    async fn save_manifest(
        &self,
        _project: &Project,
        manifest: &AgentManifest,
    ) -> Result<(), StoreError> {
        self.0.lock().unwrap().manifest = manifest.clone();
        Ok(())
    }
}

#[derive(Clone)]
struct FakeProfiles(Arc<Vec<AgentProfile>>);
#[async_trait]
impl ProfileStore for FakeProfiles {
    async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
        Ok((*self.0).clone())
    }
    async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> {
        Ok(())
    }
    async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
        Ok(())
    }
    async fn is_configured(&self) -> Result<bool, StoreError> {
        Ok(true)
    }
    async fn mark_configured(&self) -> Result<(), StoreError> {
        Ok(())
    }
}

#[derive(Default)]
struct FakeSkills;
#[async_trait]
impl SkillStore for FakeSkills {
    async fn list(
        &self,
        _scope: SkillScope,
        _root: &ProjectPath,
    ) -> Result<Vec<Skill>, StoreError> {
        Ok(Vec::new())
    }
    async fn get(
        &self,
        _scope: SkillScope,
        _root: &ProjectPath,
        _id: SkillId,
    ) -> Result<Skill, StoreError> {
        Err(StoreError::NotFound)
    }
    async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
        Ok(())
    }
    async fn delete(
        &self,
        _scope: SkillScope,
        _root: &ProjectPath,
        _id: SkillId,
    ) -> Result<(), StoreError> {
        Ok(())
    }
}

#[derive(Default)]
struct FakeRecall;
#[async_trait]
impl domain::ports::MemoryRecall for FakeRecall {
    async fn recall(
        &self,
        _root: &ProjectPath,
        _query: &domain::ports::MemoryQuery,
    ) -> Result<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
        Ok(Vec::new())
    }
}

struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime {
    async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
        Ok(true)
    }
    fn prepare_invocation(
        &self,
        profile: &AgentProfile,
        _ctx: &PreparedContext,
        cwd: &ProjectPath,
        _session: &SessionPlan,
    ) -> Result<SpawnSpec, RuntimeError> {
        Ok(SpawnSpec {
            command: profile.command.clone(),
            args: profile.args.clone(),
            cwd: cwd.clone(),
            env: Vec::new(),
            context_plan: Some(ContextInjectionPlan::Stdin),
            sandbox: None,
        })
    }
}

#[derive(Clone, Default)]
struct FakeFs;
#[async_trait]
impl FileSystem for FakeFs {
    async fn read(&self, p: &RemotePath) -> Result<Vec<u8>, FsError> {
        Err(FsError::NotFound(p.as_str().to_owned()))
    }
    async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> {
        Ok(())
    }
    async fn exists(&self, _p: &RemotePath) -> Result<bool, FsError> {
        Ok(false)
    }
    async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
        Ok(())
    }
    async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
        Ok(Vec::new())
    }
    async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> {
        Ok(())
    }
}

#[derive(Clone)]
struct FakeMcpToolPermissionStore {
    doc: Arc<Mutex<ProjectMcpToolPermissions>>,
}

impl FakeMcpToolPermissionStore {
    fn new(doc: ProjectMcpToolPermissions) -> Self {
        Self {
            doc: Arc::new(Mutex::new(doc)),
        }
    }
}

#[async_trait]
impl McpToolPermissionStore for FakeMcpToolPermissionStore {
    async fn load_mcp_tool_permissions(
        &self,
        _project: &Project,
    ) -> Result<ProjectMcpToolPermissions, StoreError> {
        Ok(self.doc.lock().unwrap().clone())
    }

    async fn save_mcp_tool_permissions(
        &self,
        _project: &Project,
        permissions: &ProjectMcpToolPermissions,
    ) -> Result<(), StoreError> {
        *self.doc.lock().unwrap() = permissions.clone();
        Ok(())
    }
}

#[derive(Clone)]
struct FakePty;
#[async_trait]
impl PtyPort for FakePty {
    async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result<PtyHandle, PtyError> {
        Ok(PtyHandle {
            session_id: SessionId::from_uuid(Uuid::from_u128(777)),
        })
    }
    fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> {
        Ok(())
    }
    fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> {
        Ok(())
    }
    fn subscribe_output(&self, _h: &PtyHandle) -> Result<OutputStream, PtyError> {
        Ok(Box::new(std::iter::empty()))
    }
    fn scrollback(&self, _h: &PtyHandle) -> Result<Vec<u8>, PtyError> {
        Ok(Vec::new())
    }
    async fn wait(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
        Ok(ExitStatus { code: Some(0) })
    }
    fn try_wait(&self, _h: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
        Ok(Some(ExitStatus { code: Some(0) }))
    }
    async fn kill(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
        Ok(ExitStatus { code: Some(0) })
    }
}

#[derive(Default, Clone)]
struct NoopBus;
impl EventBus for NoopBus {
    fn publish(&self, _e: DomainEvent) {}
    fn subscribe(&self) -> EventStream {
        Box::new(std::iter::empty())
    }
}

struct SeqIds(Mutex<u128>);
impl IdGenerator for SeqIds {
    fn new_uuid(&self) -> Uuid {
        let mut n = self.0.lock().unwrap();
        let id = Uuid::from_u128(*n);
        *n += 1;
        id
    }
}

fn project() -> Project {
    Project::new(
        ProjectId::from_uuid(Uuid::from_u128(1000)),
        "demo",
        ProjectPath::new("/home/me/proj").unwrap(),
        RemoteRef::local(),
        1_700_000_000_000,
    )
    .unwrap()
}

/// Builds an [`OrchestratorService`] over the in-memory fakes (no structured
/// registry). Returns the shared `TerminalSessions` so a test can pre-bind a live
/// PTY session for an agent (needed to make `stop_agent` succeed).
fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<TerminalSessions>) {
    // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
    // seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
    // d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`.
    let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
        ProfileId::from_uuid(Uuid::from_u128(9)),
        "Claude Code",
        "claude",
        Vec::new(),
        ContextInjection::stdin(),
        None,
        "{agentRunDir}",
        None,
    )
    .unwrap()
    .with_structured_adapter(StructuredAdapter::Claude)
    .with_mcp(McpCapability::new(
        McpConfigStrategy::config_file(".mcp.json").unwrap(),
        McpTransport::Stdio,
    ))])));
    let sessions = Arc::new(TerminalSessions::new());
    let bus = Arc::new(NoopBus);
    let create = Arc::new(CreateAgentFromScratch::new(
        Arc::new(contexts.clone()),
        Arc::new(SeqIds(Mutex::new(1))),
        bus.clone(),
    ));
    let launch = Arc::new(LaunchAgent::new(
        Arc::new(contexts.clone()),
        Arc::clone(&profiles) as Arc<dyn ProfileStore>,
        Arc::new(FakeRuntime),
        Arc::new(FakeFs),
        Arc::new(FakePty),
        Arc::new(FakeSkills),
        Arc::clone(&sessions),
        bus.clone(),
        Arc::new(SeqIds(Mutex::new(1))),
        Arc::new(FakeRecall),
        None,
    ));
    let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
    let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
    let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
    let create_skill = Arc::new(CreateSkill::new(
        Arc::new(FakeSkills) as Arc<dyn SkillStore>,
        Arc::new(SeqIds(Mutex::new(1))),
    ));
    let service = Arc::new(OrchestratorService::new(
        create,
        launch,
        list,
        close,
        update,
        create_skill,
        Arc::clone(&profiles) as Arc<dyn ProfileStore>,
        Arc::clone(&sessions),
    ));
    (service, sessions)
}

/// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1
/// « Terminal + MCP », B-3/B-4), so `idea_ask_agent` blocks on the mailbox and
/// `idea_reply` resolves it. Returns the service, the shared mailbox (to observe
/// pending tickets) and the PTY session registry (to pre-seed a live target).
fn build_service_with_mailbox(
    contexts: FakeContexts,
) -> (
    Arc<OrchestratorService>,
    Arc<InMemoryMailbox>,
    Arc<TerminalSessions>,
) {
    let mailbox = Arc::new(InMemoryMailbox::new());
    let (base, sessions) = build_service(contexts);
    let input = Arc::new(MediatedInbox::with_pty(
        Arc::clone(&mailbox),
        Arc::new(SystemMillisClock),
        Arc::new(FakePty) as Arc<dyn PtyPort>,
    )) as Arc<dyn domain::input::InputMediator>;
    let conversations = Arc::new(InMemoryConversationRegistry::new())
        as Arc<dyn domain::conversation::ConversationRegistry>;
    let service = Arc::try_unwrap(base)
        .unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
        .with_input_mediator(
            input,
            Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
        )
        .with_conversations(conversations);
    (Arc::new(service), mailbox, sessions)
}

fn server(service: Arc<OrchestratorService>) -> McpServer {
    McpServer::new(service, project())
}

fn server_with_mcp_permissions(
    service: Arc<OrchestratorService>,
    permissions: ProjectMcpToolPermissions,
) -> McpServer {
    McpServer::new(service, project())
        .with_mcp_tool_permissions(Arc::new(FakeMcpToolPermissionStore::new(permissions)))
}

fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions {
    let known_tools = classified_tool_names();
    ProjectMcpToolPermissions::new(
        None,
        vec![AgentMcpToolPolicyOverride::new(
            agent,
            McpToolPolicy::new(
                allowed_tools
                    .iter()
                    .map(|tool| (*tool).to_owned())
                    .collect(),
                &known_tools,
            )
            .unwrap(),
        )],
        &known_tools,
    )
    .unwrap()
}

/// A capturing event sink (the MCP twin of the file watcher's publish closure):
/// records every [`DomainEvent`] the server emits so a test can assert the
/// `OrchestratorRequestProcessed` source tag. Returns the closure to wire via
/// `with_events` and the shared buffer to inspect afterwards.
fn capturing_events() -> (
    Arc<dyn Fn(DomainEvent) + Send + Sync>,
    Arc<Mutex<Vec<DomainEvent>>>,
) {
    let captured = Arc::new(Mutex::new(Vec::new()));
    let sink = captured.clone();
    let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
        Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e));
    (publish, captured)
}

// --- JSON-RPC framing helpers ---------------------------------------------

/// A `tools/call` request envelope for `tool` with `arguments`.
fn tools_call(id: i64, tool: &str, arguments: Value) -> Vec<u8> {
    serde_json::to_vec(&json!({
        "jsonrpc": "2.0",
        "id": id,
        "method": "tools/call",
        "params": { "name": tool, "arguments": arguments }
    }))
    .unwrap()
}

/// Extracts the single text content block of a successful `tools/call` result.
fn result_text(result: &Value) -> &str {
    result["content"][0]["text"]
        .as_str()
        .expect("text content block")
}

#[derive(Clone, Default)]
struct FakeTicketTools {
    calls: Arc<Mutex<Vec<String>>>,
    mutation_attempts: Arc<Mutex<usize>>,
    sprints: Arc<Mutex<Vec<Value>>>,
}

impl FakeTicketTools {
    fn seed_sprint(&self, order: u32, name: &str) {
        self.sprints.lock().unwrap().push(json!({
            "id": Uuid::new_v4().to_string(),
            "order": order,
            "name": name,
            "status": "planned",
            "ticketCount": 0,
            "version": 1
        }));
    }

    fn calls(&self) -> Vec<String> {
        self.calls.lock().unwrap().clone()
    }

    fn mutation_attempts(&self) -> usize {
        *self.mutation_attempts.lock().unwrap()
    }
}

#[derive(Clone, Default)]
struct FakeTemplateTools {
    calls: Arc<Mutex<Vec<String>>>,
    mutation_attempts: Arc<Mutex<usize>>,
}

impl FakeTemplateTools {
    fn calls(&self) -> Vec<String> {
        self.calls.lock().unwrap().clone()
    }

    fn mutation_attempts(&self) -> usize {
        *self.mutation_attempts.lock().unwrap()
    }
}

#[async_trait]
impl TemplateToolProvider for FakeTemplateTools {
    async fn handle_template_tool(
        &self,
        _project: &Project,
        _requester: &str,
        name: &str,
        _arguments: Value,
    ) -> Result<Value, TemplateToolError> {
        self.calls.lock().unwrap().push(name.to_owned());
        match name {
            "idea_template_list" => Ok(json!({ "items": [] })),
            "idea_template_read" => Ok(json!({
                "id": Uuid::from_u128(7).to_string(),
                "name": "Seeded template",
                "contentMd": "body",
                "version": 1,
                "defaultProfileId": Uuid::from_u128(9).to_string()
            })),
            _ => {
                *self.mutation_attempts.lock().unwrap() += 1;
                Err(TemplateToolError::new(
                    "unexpectedMutation",
                    format!("unexpected mutable template tool {name}"),
                ))
            }
        }
    }
}

#[async_trait]
impl TicketToolProvider for FakeTicketTools {
    async fn handle_ticket_tool(
        &self,
        _project: &Project,
        _requester: &str,
        name: &str,
        _arguments: Value,
    ) -> Result<Value, TicketToolError> {
        self.calls.lock().unwrap().push(name.to_owned());
        match name {
            "idea_ticket_read" => Ok(json!({
                "item": { "ref": "#7", "title": "Seeded ticket", "version": 1 }
            })),
            "idea_ticket_list" => Ok(json!({ "items": [] })),
            "idea_ticket_read_carnet" => Ok(json!({ "ref": "#7", "carnet": "" })),
            "idea_sprint_list" => Ok(json!({
                "items": self.sprints.lock().unwrap().clone()
            })),
            _ => {
                *self.mutation_attempts.lock().unwrap() += 1;
                Err(TicketToolError::new(
                    "unexpectedMutation",
                    format!("unexpected mutable ticket tool {name}"),
                ))
            }
        }
    }
}

// ---------------------------------------------------------------------------
// 1. tools/list catalogue
// ---------------------------------------------------------------------------

#[tokio::test]
async fn tools_list_advertises_the_idea_tools_with_schemas() {
    let (service, _s) = build_service(FakeContexts::new());
    let server = server(service);

    let raw = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 1, "method": "tools/list"
    }))
    .unwrap();

    let response = server
        .handle_raw(&raw)
        .await
        .expect("tools/list owes a reply");
    assert!(response.error.is_none(), "got error: {:?}", response.error);
    let result = response.result.expect("result");

    let tools = result["tools"].as_array().expect("tools array");
    let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();

    for expected in [
        "idea_list_agents",
        "idea_ask_agent",
        "idea_run_in_background",
        "idea_launch_agent",
        "idea_stop_agent",
        "idea_update_context",
        "idea_create_skill",
        // Read a skill's body by name (feature « skills à la MCP », T6).
        "idea_skill_read",
        // FileGuard-mediated context/memory tools (cadrage C7).
        "idea_context_read",
        "idea_context_propose",
        "idea_memory_read",
        "idea_memory_write",
        // Live-state tools (programme live-state, lot LS4).
        "idea_workstate_read",
        "idea_workstate_set",
        // Public ticket tools (Issue domain).
        "idea_ticket_create",
        "idea_ticket_read",
        "idea_ticket_list",
        "idea_ticket_update",
        "idea_ticket_update_status",
        "idea_ticket_update_priority",
        "idea_ticket_read_carnet",
        "idea_ticket_update_carnet",
        "idea_ticket_link",
        "idea_ticket_unlink",
        "idea_sprint_list",
        // Public template tools.
        "idea_template_list",
        "idea_template_read",
        "idea_template_create",
        "idea_template_update",
        "idea_template_delete",
    ] {
        assert!(
            names.contains(&expected),
            "missing tool {expected}; got {names:?}"
        );
    }
    assert!(!names.contains(&"idea_reply"));
    assert_eq!(
        tools.len(),
        30,
        "exactly the thirty exposed idea_* tools; got {names:?}"
    );

    // Every tool advertises an object input schema.
    for t in tools {
        assert_eq!(
            t["inputSchema"]["type"],
            json!("object"),
            "tool {} must declare an object input schema",
            t["name"]
        );
    }
}

#[tokio::test]
async fn tools_list_is_filtered_for_requester_with_tool_policy() {
    let (service, _s) = build_service(FakeContexts::new());
    let registry = Arc::new(ToolPolicyRegistry::new());
    registry.set(
        "assistant-req",
        AgentToolPolicy::new(
            vec![
                "idea_ticket_read".to_owned(),
                "idea_ticket_update".to_owned(),
                "idea_ticket_update_carnet".to_owned(),
            ],
            Some(IssueRef::from_str("#7").unwrap()),
            true,
        ),
    );
    let server = server(service)
        .with_tool_policies(registry)
        .for_requester("assistant-req");

    let raw = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 1, "method": "tools/list"
    }))
    .unwrap();
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert!(response.error.is_none(), "got error: {:?}", response.error);
    let result = response.result.expect("result");
    let tools = result["tools"].as_array().expect("tools array");
    let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();

    assert_eq!(
        names,
        vec![
            "idea_ticket_read",
            "idea_ticket_update",
            "idea_ticket_update_carnet"
        ]
    );
}

#[tokio::test]
async fn requester_without_tool_policy_keeps_the_full_tools_list() {
    let (service, _s) = build_service(FakeContexts::new());
    let registry = Arc::new(ToolPolicyRegistry::new());
    let server = server(service)
        .with_tool_policies(registry)
        .for_requester("ordinary-agent");

    let raw = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 1, "method": "tools/list"
    }))
    .unwrap();
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert!(response.error.is_none(), "got error: {:?}", response.error);
    let result = response.result.expect("result");
    let names: Vec<&str> = result["tools"]
        .as_array()
        .expect("tools array")
        .iter()
        .map(|t| t["name"].as_str().unwrap())
        .collect();

    assert!(names.contains(&"idea_ask_agent"));
    assert!(names.contains(&"idea_ticket_update"));
    assert!(names.len() > 3, "unfiltered requester got {names:?}");
}

#[tokio::test]
async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
    let (service, _s) = build_service(FakeContexts::new());
    let agent = AgentId::from_uuid(Uuid::from_u128(82));
    let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
        .for_requester(agent.to_string());

    let raw = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 1, "method": "tools/list"
    }))
    .unwrap();
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert!(response.error.is_none(), "got error: {:?}", response.error);
    let result = response.result.expect("result");
    let names: Vec<&str> = result["tools"]
        .as_array()
        .expect("tools array")
        .iter()
        .map(|t| t["name"].as_str().unwrap())
        .collect();

    assert!(names.contains(&"idea_memory_read"));
    assert!(names.contains(&"idea_ticket_list"));
    assert!(names.contains(&"idea_template_list"));
    assert!(names.contains(&"idea_template_read"));
    assert!(!names.contains(&"idea_memory_write"));
    assert!(!names.contains(&"idea_ask_agent"));
    assert!(!names.contains(&"idea_ticket_update_carnet"));
    assert!(!names.contains(&"idea_run_in_background"));
    assert!(!names.contains(&"idea_template_create"));
    assert!(!names.contains(&"idea_template_update"));
    assert!(!names.contains(&"idea_template_delete"));
}

// ---------------------------------------------------------------------------
// 2. tools/call → the right OrchestratorCommand (observed through the fakes)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
    let contexts = FakeContexts::new();
    contexts.seed_agent("architect");
    let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
    let ticket_tools = Arc::new(FakeTicketTools::default());
    let template_tools = Arc::new(FakeTemplateTools::default());
    let agent = AgentId::from_uuid(Uuid::from_u128(83));
    let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
        .with_ticket_tools(ticket_tools.clone())
        .with_template_tools(template_tools.clone())
        .for_requester(agent.to_string());

    for (id, tool, arguments) in [
        (101, "idea_memory_read", json!({})),
        (102, "idea_ticket_list", json!({})),
        (103, "idea_template_list", json!({})),
        (
            104,
            "idea_template_read",
            json!({ "templateId": Uuid::from_u128(7) }),
        ),
    ] {
        let response = server
            .handle_raw(&tools_call(id, tool, arguments))
            .await
            .expect("reply owed");
        assert!(
            response.error.is_none(),
            "read tool {tool} must not be rejected by MCP policy: {:?}",
            response.error
        );
        assert!(response.result.is_some(), "read tool {tool} should run");
    }

    for (id, tool, arguments) in [
        (
            111,
            "idea_memory_write",
            json!({ "slug": "note-a", "content": "body" }),
        ),
        (
            112,
            "idea_ask_agent",
            json!({ "target": "architect", "task": "do it" }),
        ),
        (
            113,
            "idea_ticket_update_carnet",
            json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }),
        ),
        (
            114,
            "idea_run_in_background",
            json!({ "label": "task", "command": "echo" }),
        ),
        (
            115,
            "idea_template_create",
            json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
        ),
        (
            116,
            "idea_template_update",
            json!({ "templateId": Uuid::new_v4(), "content": "body" }),
        ),
        (
            117,
            "idea_template_delete",
            json!({ "templateId": Uuid::new_v4() }),
        ),
    ] {
        let response = server
            .handle_raw(&tools_call(id, tool, arguments))
            .await
            .expect("reply owed");
        let error = response.error.expect("MCP policy rejection expected");
        assert_eq!(error.code, error_codes::INVALID_PARAMS, "tool {tool}");
        assert!(
            error.message.contains("not permitted"),
            "message should be readable for {tool}; got {}",
            error.message
        );
        assert!(response.result.is_none(), "denied {tool} must not run");
    }

    assert_eq!(
        ticket_tools.calls(),
        vec!["idea_ticket_list".to_owned()],
        "denied ticket mutation must not reach the provider"
    );
    assert_eq!(ticket_tools.mutation_attempts(), 0);
    assert_eq!(
        template_tools.calls(),
        vec![
            "idea_template_list".to_owned(),
            "idea_template_read".to_owned()
        ],
        "denied template mutations must not reach the provider"
    );
    assert_eq!(template_tools.mutation_attempts(), 0);
}

#[tokio::test]
async fn legacy_mcp_requester_with_durable_store_is_read_only() {
    let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());
    let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
        .for_requester("mcp");

    let response = server
        .handle_raw(&tools_call(
            121,
            "idea_run_in_background",
            json!({ "label": "task", "command": "echo" }),
        ))
        .await
        .expect("reply owed");

    let error = response.error.expect("legacy requester must fail closed");
    assert_eq!(error.code, error_codes::INVALID_PARAMS);
    assert!(error.message.contains("not permitted"));
}

#[tokio::test]
async fn empty_requester_with_durable_store_is_read_only() {
    let (service, _s) = build_service(FakeContexts::new());
    let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
        .for_requester("");

    let response = server
        .handle_raw(&tools_call(
            122,
            "idea_memory_write",
            json!({ "slug": "note-a", "content": "body" }),
        ))
        .await
        .expect("reply owed");

    let error = response.error.expect("empty requester must fail closed");
    assert_eq!(error.code, error_codes::INVALID_PARAMS);
    assert!(error.message.contains("not permitted"));
    assert!(response.result.is_none());
}

#[tokio::test]
async fn durable_agent_override_allows_an_explicit_write_tool() {
    let (service, _s) = build_service(FakeContexts::new());
    let ticket_tools = Arc::new(FakeTicketTools::default());
    let agent = AgentId::from_uuid(Uuid::from_u128(84));
    let server =
        server_with_mcp_permissions(service, allow_doc(agent, &["idea_ticket_update_carnet"]))
            .with_ticket_tools(ticket_tools.clone())
            .for_requester(agent.to_string());

    let response = server
        .handle_raw(&tools_call(
            131,
            "idea_ticket_update_carnet",
            json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }),
        ))
        .await
        .expect("reply owed");

    assert!(
        response.error.is_none(),
        "durable override should pass MCP policy, got {:?}",
        response.error
    );
    let result = response.result.expect("tool result");
    assert_eq!(
        result["isError"],
        json!(true),
        "fake provider reports execution error after policy passes"
    );
    assert_eq!(ticket_tools.calls(), vec!["idea_ticket_update_carnet"]);
    assert_eq!(ticket_tools.mutation_attempts(), 1);
}

#[tokio::test]
async fn durable_agent_override_allows_only_explicit_template_write_tool() {
    let (service, _s) = build_service(FakeContexts::new());
    let template_tools = Arc::new(FakeTemplateTools::default());
    let agent = AgentId::from_uuid(Uuid::from_u128(85));
    let template_id = Uuid::from_u128(7);
    let server = server_with_mcp_permissions(service, allow_doc(agent, &["idea_template_update"]))
        .with_template_tools(template_tools.clone())
        .for_requester(agent.to_string());

    let response = server
        .handle_raw(&tools_call(
            132,
            "idea_template_update",
            json!({ "templateId": template_id, "content": "body" }),
        ))
        .await
        .expect("reply owed");

    assert!(
        response.error.is_none(),
        "durable override should pass MCP policy, got {:?}",
        response.error
    );
    let result = response.result.expect("tool result");
    assert_eq!(
        result["isError"],
        json!(true),
        "fake provider reports execution error after policy passes"
    );

    for (id, tool, arguments) in [
        (
            133,
            "idea_template_create",
            json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
        ),
        (
            134,
            "idea_template_delete",
            json!({ "templateId": template_id }),
        ),
    ] {
        let response = server
            .handle_raw(&tools_call(id, tool, arguments))
            .await
            .expect("reply owed");
        let error = response.error.expect("non-allowlisted write rejected");
        assert_eq!(error.code, error_codes::INVALID_PARAMS);
        assert!(
            error.message.contains(tool),
            "message should name rejected tool; got {}",
            error.message
        );
        assert!(response.result.is_none());
    }

    assert_eq!(template_tools.calls(), vec!["idea_template_update"]);
    assert_eq!(template_tools.mutation_attempts(), 1);
}

#[tokio::test]
async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() {
    let (service, _s) = build_service(FakeContexts::new());
    let registry = Arc::new(ToolPolicyRegistry::new());
    registry.set(
        "ticket-assistant:demo:7",
        AgentToolPolicy::new(
            vec!["idea_ticket_update_carnet".to_owned()],
            Some(IssueRef::from_str("#7").unwrap()),
            true,
        ),
    );
    let ticket_tools = Arc::new(FakeTicketTools::default());
    let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
        .with_ticket_tools(ticket_tools.clone())
        .with_tool_policies(registry)
        .for_requester("ticket-assistant:demo:7");

    let rejected = server
        .handle_raw(&tools_call(
            141,
            "idea_ticket_update_carnet",
            json!({ "ref": "#8", "expectedVersion": 1, "carnet": "body" }),
        ))
        .await
        .expect("reply owed");
    let error = rejected.error.expect("bound ticket rejection expected");
    assert_eq!(error.code, error_codes::INVALID_PARAMS);
    assert!(error.message.contains("#8"));
    assert!(ticket_tools.calls().is_empty());

    let allowed = server
        .handle_raw(&tools_call(
            142,
            "idea_ticket_update_carnet",
            json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }),
        ))
        .await
        .expect("reply owed");
    assert!(
        allowed.error.is_none(),
        "bound ticket should pass policy, got {:?}",
        allowed.error
    );
    assert_eq!(ticket_tools.calls(), vec!["idea_ticket_update_carnet"]);
}

#[tokio::test]
async fn launch_agent_call_creates_and_launches_the_agent() {
    let contexts = FakeContexts::new();
    let (service, _s) = build_service(contexts.clone());
    let server = server(service);

    // Agent unknown → SpawnAgent creates it from scratch then launches it.
    let raw = tools_call(
        1,
        "idea_launch_agent",
        json!({ "target": "dev-backend", "profile": "claude-code" }),
    );
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert!(
        response.error.is_none(),
        "transport error: {:?}",
        response.error
    );
    let result = response.result.expect("result");
    assert_eq!(result["isError"], json!(false), "got {result}");

    // The command really reached the use cases: the agent now exists in the manifest.
    assert_eq!(contexts.entries().len(), 1);
    assert_eq!(contexts.entries()[0].name, "dev-backend");
}

#[tokio::test]
async fn stop_agent_call_closes_the_live_session() {
    let contexts = FakeContexts::new();
    let agent_id = contexts.seed_agent("dev");
    let (service, sessions) = build_service(contexts);
    // Pre-bind a live PTY session for the agent so StopAgent has something to close.
    let session_id = SessionId::from_uuid(Uuid::from_u128(555));
    sessions.insert(
        PtyHandle { session_id },
        TerminalSession::starting(
            session_id,
            NodeId::from_uuid(Uuid::from_u128(8)),
            ProjectPath::new("/home/me/proj").unwrap(),
            SessionKind::Agent { agent_id },
            PtySize { rows: 24, cols: 80 },
        ),
    );
    assert!(sessions.session_for_agent(&agent_id).is_some());
    let server = server(service);

    let raw = tools_call(1, "idea_stop_agent", json!({ "target": "dev" }));
    let response = server.handle_raw(&raw).await.expect("reply owed");
    let result = response.result.expect("result");
    assert_eq!(result["isError"], json!(false), "got {result}");

    // CloseTerminal ran → the agent no longer has a live session.
    assert!(
        sessions.session_for_agent(&agent_id).is_none(),
        "stop_agent should have removed the session"
    );
}

#[tokio::test]
async fn update_context_and_create_skill_calls_succeed() {
    let contexts = FakeContexts::new();
    let agent_id = contexts.seed_agent("dev");
    // Seed an existing body so write_context finds the md path.
    contexts
        .0
        .lock()
        .unwrap()
        .contents
        .insert(format!("agents/dev.md"), "# old".to_owned());
    let _ = agent_id;
    let (service, _s) = build_service(contexts.clone());
    let server = server(service);

    let raw = tools_call(
        1,
        "idea_update_context",
        json!({ "target": "dev", "context": "# new body" }),
    );
    let r = server.handle_raw(&raw).await.expect("reply owed");
    assert_eq!(r.result.expect("result")["isError"], json!(false));

    let raw = tools_call(
        2,
        "idea_create_skill",
        json!({ "name": "deploy", "context": "# steps" }),
    );
    let r = server.handle_raw(&raw).await.expect("reply owed");
    assert_eq!(r.result.expect("result")["isError"], json!(false));
}

#[tokio::test]
async fn tools_call_outside_allowlist_is_rejected_for_policy_requester() {
    let contexts = FakeContexts::new();
    contexts.seed_agent("architect");
    let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
    let registry = Arc::new(ToolPolicyRegistry::new());
    registry.set(
        "assistant-req",
        AgentToolPolicy::new(
            vec!["idea_ticket_read".to_owned()],
            Some(IssueRef::from_str("#7").unwrap()),
            true,
        ),
    );
    let server = server(service)
        .with_tool_policies(registry)
        .for_requester("assistant-req");

    let raw = tools_call(
        11,
        "idea_ask_agent",
        json!({ "target": "architect", "task": "please handle this" }),
    );
    let response = server.handle_raw(&raw).await.expect("reply owed");
    let error = response.error.expect("policy rejection expected");
    assert_eq!(error.code, error_codes::INVALID_PARAMS);
    assert!(
        error.message.contains("not permitted"),
        "got {}",
        error.message
    );
    assert!(response.result.is_none());
}

#[tokio::test]
async fn ticket_update_on_other_issue_is_rejected_before_ticket_provider() {
    let (service, _s) = build_service(FakeContexts::new());
    let registry = Arc::new(ToolPolicyRegistry::new());
    registry.set(
        "assistant-req",
        AgentToolPolicy::new(
            vec!["idea_ticket_update".to_owned()],
            Some(IssueRef::from_str("#7").unwrap()),
            true,
        ),
    );
    let ticket_tools = Arc::new(FakeTicketTools::default());
    let server = server(service)
        .with_ticket_tools(ticket_tools.clone())
        .with_tool_policies(registry)
        .for_requester("assistant-req");

    let raw = tools_call(
        12,
        "idea_ticket_update",
        json!({
            "ref": "#8",
            "expectedVersion": 1,
            "title": "must not pass"
        }),
    );
    let response = server.handle_raw(&raw).await.expect("reply owed");
    let error = response.error.expect("policy rejection expected");
    assert_eq!(error.code, error_codes::INVALID_PARAMS);
    assert!(
        error.message.contains("#8"),
        "message should name the rejected issue; got {}",
        error.message
    );
    assert!(
        ticket_tools.calls().is_empty(),
        "policy rejection must happen before ticket provider dispatch"
    );
    assert_eq!(ticket_tools.mutation_attempts(), 0);
}

#[tokio::test]
async fn bounded_ticket_mutation_tools_reject_other_issue_before_ticket_provider() {
    for (id, tool, arguments) in [
        (
            21,
            "idea_ticket_update_status",
            json!({ "ref": "#8", "expectedVersion": 1, "status": "closed" }),
        ),
        (
            22,
            "idea_ticket_update_priority",
            json!({ "ref": "#8", "expectedVersion": 1, "priority": "high" }),
        ),
        (
            23,
            "idea_ticket_link",
            json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
        ),
        (
            24,
            "idea_ticket_unlink",
            json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
        ),
    ] {
        let (service, _s) = build_service(FakeContexts::new());
        let registry = Arc::new(ToolPolicyRegistry::new());
        registry.set(
            "assistant-req",
            AgentToolPolicy::new(
                vec![tool.to_owned()],
                Some(IssueRef::from_str("#7").unwrap()),
                true,
            ),
        );
        let ticket_tools = Arc::new(FakeTicketTools::default());
        let server = server(service)
            .with_ticket_tools(ticket_tools.clone())
            .with_tool_policies(registry)
            .for_requester("assistant-req");

        let raw = tools_call(id, tool, arguments);
        let response = server.handle_raw(&raw).await.expect("reply owed");
        let error = response.error.expect("policy rejection expected");
        assert_eq!(error.code, error_codes::INVALID_PARAMS, "tool {tool}");
        assert!(
            error.message.contains("#8"),
            "message should name the rejected issue for {tool}; got {}",
            error.message
        );
        assert!(
            ticket_tools.calls().is_empty(),
            "policy rejection for {tool} must happen before ticket provider dispatch"
        );
        assert_eq!(
            ticket_tools.mutation_attempts(),
            0,
            "no mutation attempt expected for rejected {tool}"
        );
    }
}

#[tokio::test]
async fn bounded_ticket_mutation_tools_allow_bound_issue_to_reach_ticket_provider() {
    for (id, tool, arguments) in [
        (
            31,
            "idea_ticket_update_status",
            json!({ "ref": "#7", "expectedVersion": 1, "status": "closed" }),
        ),
        (
            32,
            "idea_ticket_update_priority",
            json!({ "ref": "#7", "expectedVersion": 1, "priority": "high" }),
        ),
        (
            33,
            "idea_ticket_link",
            json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
        ),
        (
            34,
            "idea_ticket_unlink",
            json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }),
        ),
    ] {
        let (service, _s) = build_service(FakeContexts::new());
        let registry = Arc::new(ToolPolicyRegistry::new());
        registry.set(
            "assistant-req",
            AgentToolPolicy::new(
                vec![tool.to_owned()],
                Some(IssueRef::from_str("#7").unwrap()),
                true,
            ),
        );
        let ticket_tools = Arc::new(FakeTicketTools::default());
        let server = server(service)
            .with_ticket_tools(ticket_tools.clone())
            .with_tool_policies(registry)
            .for_requester("assistant-req");

        let raw = tools_call(id, tool, arguments);
        let response = server.handle_raw(&raw).await.expect("reply owed");
        assert!(
            response.error.is_none(),
            "bound issue should pass policy for {tool}, got {:?}",
            response.error
        );
        let result = response.result.expect("tool result");
        assert_eq!(
            result["isError"],
            json!(true),
            "fake provider intentionally returns a tool error after policy passes"
        );
        assert_eq!(ticket_tools.calls(), vec![tool.to_owned()]);
        assert_eq!(
            ticket_tools.mutation_attempts(),
            1,
            "bound {tool} should reach the ticket provider exactly once"
        );
    }
}

// ---------------------------------------------------------------------------
// 3. Inter-agent delegation is exposed through MCP; reply protocol is not
// ---------------------------------------------------------------------------

#[tokio::test]
async fn ask_agent_tool_reaches_shared_validation_before_dispatch() {
    let contexts = FakeContexts::new();
    contexts.seed_agent("architect");
    let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
    let server = server(service);

    let raw = tools_call(7, "idea_ask_agent", json!({ "target": "architect" }));
    let response = server.handle_raw(&raw).await.expect("reply owed");
    let error = response.error.expect("validation error expected");
    assert_eq!(error.code, error_codes::INVALID_PARAMS);
    assert!(
        error.message.contains("task"),
        "shared validation should reject the missing task, got {error:?}"
    );
    assert!(
        response.result.is_none(),
        "no dispatch on validation failure"
    );
}

#[tokio::test]
async fn reply_tool_is_not_exposed_over_mcp() {
    let contexts = FakeContexts::new();
    let agent_id = contexts.seed_agent("architect");
    let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
    let reply_server = server(service).for_requester(agent_id.to_string());

    let raw = tools_call(9, "idea_reply", json!({ "result": "orphan" }));
    let resp = reply_server.handle_raw(&raw).await.expect("reply owed");
    let error = resp.error.expect("unknown tool error expected");
    assert_eq!(error.code, error_codes::METHOD_NOT_FOUND);
    assert!(resp.result.is_none());
}

// ---------------------------------------------------------------------------
// 4. idea_list_agents returns the agent list inline (JSON array)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn list_agents_returns_the_agents_inline_as_json_array() {
    let contexts = FakeContexts::new();
    contexts.seed_agent("architect");
    contexts.seed_agent("dev-backend");
    let (service, _s) = build_service(contexts);
    let server = server(service);

    let raw = tools_call(3, "idea_list_agents", json!({}));
    let response = server.handle_raw(&raw).await.expect("reply owed");
    let result = response.result.expect("result");
    assert_eq!(result["isError"], json!(false), "got {result}");

    // The inline text is the JSON array of the project's agents.
    let text = result_text(&result);
    let agents: Value = serde_json::from_str(text).expect("reply must be a JSON array");
    let arr = agents.as_array().expect("array");
    assert_eq!(arr.len(), 2, "two seeded agents expected; got {text}");
    let names: Vec<&str> = arr.iter().map(|a| a["name"].as_str().unwrap()).collect();
    assert!(names.contains(&"architect"));
    assert!(names.contains(&"dev-backend"));
}

#[tokio::test]
async fn sprint_list_tool_call_returns_seeded_sprints_read_only() {
    let (service, _s) = build_service(FakeContexts::new());
    let ticket_tools = Arc::new(FakeTicketTools::default());
    ticket_tools.seed_sprint(1, "Sprint Alpha");
    ticket_tools.seed_sprint(2, "Sprint Beta");
    let server = server(service).with_ticket_tools(ticket_tools.clone());

    let raw = tools_call(41, "idea_sprint_list", json!({}));
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert!(
        response.error.is_none(),
        "transport error: {:?}",
        response.error
    );
    let result = response.result.expect("result");
    assert_eq!(result["isError"], json!(false), "got {result}");

    let payload: Value =
        serde_json::from_str(result_text(&result)).expect("sprint list payload is JSON");
    let items = payload["items"].as_array().expect("SprintListDto.items");
    assert_eq!(items.len(), 2, "got payload {payload}");
    assert_eq!(items[0]["order"], json!(1));
    assert_eq!(items[0]["name"], json!("Sprint Alpha"));
    assert_eq!(items[1]["order"], json!(2));
    assert_eq!(items[1]["name"], json!("Sprint Beta"));
    assert_eq!(ticket_tools.calls(), vec!["idea_sprint_list".to_owned()]);
    assert_eq!(
        ticket_tools.mutation_attempts(),
        0,
        "idea_sprint_list must be a read-only provider call"
    );

    let raw = tools_call(42, "idea_sprint_create", json!({ "name": "Not exposed" }));
    let response = server.handle_raw(&raw).await.expect("reply owed");
    let error = response.error.expect("unknown sprint mutation expected");
    assert_eq!(error.code, error_codes::METHOD_NOT_FOUND);
    assert!(
        error.message.contains("unknown tool"),
        "message should name the cause; got {}",
        error.message
    );
    assert_eq!(
        ticket_tools.calls(),
        vec!["idea_sprint_list".to_owned()],
        "no sprint mutation tool should be routed to the provider"
    );
    assert_eq!(ticket_tools.mutation_attempts(), 0);
}

// ---------------------------------------------------------------------------
// 5. A failed IdeA command ⇒ isError: true (not a transport error, no panic)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn failed_command_is_tool_error_not_transport_error_and_server_survives() {
    // stop_agent on a seeded-but-not-running agent → AppError::NotFound from the
    // use case → the tool result is an execution error, the JSON-RPC layer is fine.
    let contexts = FakeContexts::new();
    contexts.seed_agent("idle");
    let (service, _s) = build_service(contexts);
    let server = server(service);

    let raw = tools_call(1, "idea_stop_agent", json!({ "target": "idle" }));
    let response = server.handle_raw(&raw).await.expect("reply owed");
    // Healthy connection: no JSON-RPC error...
    assert!(
        response.error.is_none(),
        "a failed command must NOT be a JSON-RPC transport error: {:?}",
        response.error
    );
    // ...but the tool result is flagged as an error.
    let result = response.result.expect("result");
    assert_eq!(result["isError"], json!(true), "got {result}");
    assert!(
        !result_text(&result).is_empty(),
        "error text should describe the failure"
    );

    // The server is still alive: a subsequent valid call still answers.
    let raw = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 2, "method": "tools/list"
    }))
    .unwrap();
    let again = server.handle_raw(&raw).await.expect("server still serves");
    assert!(
        again.result.is_some(),
        "server must survive a failed command"
    );
}

// ---------------------------------------------------------------------------
// 6. Malformed JSON-RPC ⇒ typed PARSE_ERROR, never a panic
// ---------------------------------------------------------------------------

#[tokio::test]
async fn malformed_json_yields_parse_error_no_panic() {
    let (service, _s) = build_service(FakeContexts::new());
    let server = server(service);

    let response = server
        .handle_raw(b"{ this is not json")
        .await
        .expect("a parse error still owes a response");
    let error = response.error.expect("parse error expected");
    assert_eq!(error.code, error_codes::PARSE_ERROR);
    // JSON-RPC mandates a null id when the request could not be correlated.
    assert_eq!(response.id, Value::Null);
    assert!(response.result.is_none());
}

// ---------------------------------------------------------------------------
// 7. Unknown method / unknown tool ⇒ typed errors, no panic
// ---------------------------------------------------------------------------

#[tokio::test]
async fn unknown_method_yields_method_not_found() {
    let (service, _s) = build_service(FakeContexts::new());
    let server = server(service);

    let raw = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 9, "method": "resources/list"
    }))
    .unwrap();
    let response = server.handle_raw(&raw).await.expect("reply owed");
    let error = response.error.expect("method-not-found expected");
    assert_eq!(error.code, error_codes::METHOD_NOT_FOUND);
    assert_eq!(response.id, json!(9), "id echoed even on error");
}

#[tokio::test]
async fn unknown_tool_yields_typed_error() {
    let (service, _s) = build_service(FakeContexts::new());
    let server = server(service);

    let raw = tools_call(4, "idea_made_up_tool", json!({}));
    let response = server.handle_raw(&raw).await.expect("reply owed");
    let error = response.error.expect("unknown-tool expected");
    // An unknown tool maps to METHOD_NOT_FOUND (see server::map_err_to_jsonrpc).
    assert_eq!(error.code, error_codes::METHOD_NOT_FOUND);
    assert!(
        error.message.contains("unknown tool"),
        "message should name the cause; got {}",
        error.message
    );
}

// ---------------------------------------------------------------------------
// 8. initialize handshake
// ---------------------------------------------------------------------------

#[tokio::test]
async fn initialize_answers_minimal_handshake() {
    let (service, _s) = build_service(FakeContexts::new());
    let server = server(service);

    let raw = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 0, "method": "initialize",
        "params": { "protocolVersion": "2024-11-05" }
    }))
    .unwrap();
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert!(response.error.is_none());
    let result = response.result.expect("result");
    assert!(
        result["protocolVersion"].is_string(),
        "handshake must advertise a protocol version; got {result}"
    );
    // Capability for tools is advertised, and the server identifies itself.
    assert!(result["capabilities"]["tools"].is_object());
    assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator"));
}

/// Readiness de démarrage (fix race cold-launch, signal MCP) : un `initialize` reçu sur
/// un serveur per-connexion (`for_requester(id)`) déclenche le `ready_sink` avec l'id du
/// peer. Et un serveur SANS requester (anonyme) ne déclenche PAS le sink (peer legacy).
#[tokio::test]
async fn initialize_fires_ready_sink_with_requester() {
    let (service, _s) = build_service(FakeContexts::new());
    let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
    let sink_buf = Arc::clone(&captured);
    let ready_sink: Arc<dyn Fn(&str) + Send + Sync> =
        Arc::new(move |req: &str| sink_buf.lock().unwrap().push(req.to_owned()));

    let base = McpServer::new(service, project()).with_ready_sink(ready_sink);

    let init = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 0, "method": "initialize",
        "params": { "protocolVersion": "2024-11-05" }
    }))
    .unwrap();

    // Cas 1 : peer identifié (requester non vide) ⇒ le sink reçoit son id.
    let peer = base.for_requester("agent-42");
    let response = peer.handle_raw(&init).await.expect("reply owed");
    assert!(response.error.is_none(), "initialize must still answer");
    assert_eq!(
        *captured.lock().unwrap(),
        vec!["agent-42".to_owned()],
        "initialize d'un peer identifié ⇒ ready_sink appelé avec son requester"
    );

    // Cas 2 : peer anonyme (requester vide, le serveur de base) ⇒ sink NON appelé.
    captured.lock().unwrap().clear();
    let response = base.handle_raw(&init).await.expect("reply owed");
    assert!(response.error.is_none());
    assert!(
        captured.lock().unwrap().is_empty(),
        "requester vide ⇒ ready_sink jamais appelé (peer legacy/anonyme)"
    );
}

// ---------------------------------------------------------------------------
// 9. Shared validation: a tools/call missing a required argument is rejected by
//    the SAME OrchestratorRequest::validate, with no dispatch.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn ask_agent_missing_required_argument_is_rejected_before_dispatch() {
    let contexts = FakeContexts::new();
    contexts.seed_agent("architect");
    let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
    let server = server(service);

    let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" }));
    let response = server.handle_raw(&raw).await.expect("reply owed");

    let error = response.error.expect("validation error expected");
    assert_eq!(error.code, error_codes::INVALID_PARAMS, "got {error:?}");
    assert!(error.message.contains("task"), "got {error:?}");
    assert!(response.result.is_none(), "no dispatch ⇒ no tool result");
}

// ---------------------------------------------------------------------------
// Bonus: drive a whole scripted session over MemoryTransport (zero I/O), proving
// the serve loop handles a sequence (notification + calls) and closes cleanly.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn scripted_session_over_memory_transport_round_trips() {
    let contexts = FakeContexts::new();
    contexts.seed_agent("architect");
    let (service, _s) = build_service(contexts);
    let server = server(service);

    let init = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 1, "method": "initialize"
    }))
    .unwrap();
    // A notification (no id) must produce NO reply.
    let note = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "method": "notifications/initialized"
    }))
    .unwrap();
    let list = serde_json::to_vec(&json!({
        "jsonrpc": "2.0", "id": 2, "method": "tools/list"
    }))
    .unwrap();

    let (mut transport, mut rx) = MemoryTransport::scripted(vec![init, note, list]);
    server.serve(&mut transport).await;
    // The serve loop has returned (clean EOF), but `transport` still owns the
    // outbound sender; drop it so the channel closes and `rx` can reach EOF.
    // Without this, `rx.recv()` below would block forever on the single-thread
    // test runtime (the sender would still be alive).
    drop(transport);

    // Exactly two responses (init + list); the notification produced none.
    let first = rx.recv().await.expect("init response");
    let second = rx.recv().await.expect("list response");
    assert!(
        rx.recv().await.is_none(),
        "notification must not be answered"
    );

    let first: Value = serde_json::from_slice(&first).unwrap();
    let second: Value = serde_json::from_slice(&second).unwrap();
    assert_eq!(first["id"], json!(1));
    assert!(first["result"]["protocolVersion"].is_string());
    assert_eq!(second["id"], json!(2));
    assert!(second["result"]["tools"].is_array());
}

// ---------------------------------------------------------------------------
// M4 — observability: a processed `tools/call` publishes
// OrchestratorRequestProcessed tagged source = Mcp when an event sink is wired.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn processed_tools_call_publishes_event_tagged_mcp_source() {
    let contexts = FakeContexts::new();
    let (service, _s) = build_service(contexts.clone());
    let (publish, captured) = capturing_events();
    let server = McpServer::new(service, project()).with_events(publish);

    // A successful launch (creates + launches an agent from scratch).
    let raw = tools_call(
        1,
        "idea_launch_agent",
        json!({ "target": "dev-backend", "profile": "claude-code" }),
    );
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert!(
        response.error.is_none(),
        "transport error: {:?}",
        response.error
    );
    assert_eq!(response.result.expect("result")["isError"], json!(false));

    // Exactly one OrchestratorRequestProcessed event, tagged as the MCP door.
    let events = captured.lock().unwrap();
    let processed: Vec<&DomainEvent> = events
        .iter()
        .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
        .collect();
    assert_eq!(
        processed.len(),
        1,
        "expected exactly one processed event; got {events:?}"
    );
    match processed[0] {
        DomainEvent::OrchestratorRequestProcessed {
            requester_id,
            action,
            ok,
            source,
        } => {
            assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp");
            assert_eq!(action, "idea_launch_agent", "action mirrors the tool name");
            assert!(*ok, "the launch succeeded");
            assert_eq!(
                requester_id, "mcp",
                "stable mcp label until per-session bind"
            );
        }
        other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
    }
}

#[tokio::test]
async fn failed_tools_call_still_publishes_event_with_ok_false_and_mcp_source() {
    // stop_agent on a seeded-but-not-running agent → command fails (isError), yet
    // the observability beacon is still emitted, tagged Mcp with ok = false.
    let contexts = FakeContexts::new();
    contexts.seed_agent("idle");
    let (service, _s) = build_service(contexts);
    let (publish, captured) = capturing_events();
    let server = McpServer::new(service, project()).with_events(publish);

    let raw = tools_call(1, "idea_stop_agent", json!({ "target": "idle" }));
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert_eq!(response.result.expect("result")["isError"], json!(true));

    let events = captured.lock().unwrap();
    let processed: Vec<&DomainEvent> = events
        .iter()
        .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
        .collect();
    assert_eq!(
        processed.len(),
        1,
        "a failed call still beacons; got {events:?}"
    );
    match processed[0] {
        DomainEvent::OrchestratorRequestProcessed {
            ok, source, action, ..
        } => {
            assert!(!*ok, "the dispatch failed → ok = false");
            assert_eq!(*source, OrchestrationSource::Mcp);
            assert_eq!(action, "idea_stop_agent");
        }
        other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
    }
}

#[tokio::test]
async fn server_without_event_sink_emits_nothing_and_does_not_panic() {
    // Non-regression: McpServer::new (no with_events) keeps working and publishes
    // no event — existing call sites stay valid.
    let contexts = FakeContexts::new();
    let (service, _s) = build_service(contexts.clone());
    let server = McpServer::new(service, project()); // no event sink

    let raw = tools_call(
        1,
        "idea_launch_agent",
        json!({ "target": "dev-backend", "profile": "claude-code" }),
    );
    let response = server.handle_raw(&raw).await.expect("reply owed");
    assert!(
        response.error.is_none(),
        "transport error: {:?}",
        response.error
    );
    assert_eq!(response.result.expect("result")["isError"], json!(false));
    // The command really ran (agent created) — proof the no-op sink path is live.
    assert_eq!(contexts.entries().len(), 1);
}

// ---------------------------------------------------------------------------
// Diagnostics capture (instrumentation des blocages de délégation)
//
// Best-effort diag sink (`application::diag`) mirrored to a temp file. The sink's
// path is a process-wide OnceLock, so every capture test in this binary points it
// at the SAME canonical file and asserts on lines carrying tokens unique to the
// test (a distinctive target name / agent id), immune to parallelism and re-runs.
// ---------------------------------------------------------------------------

/// Points the process-wide diag sink at one canonical temp file (idempotent) and
/// returns it.
fn diag_log_path() -> std::path::PathBuf {
    let path = std::env::temp_dir().join("idea-diag-mcp-server-test.log");
    application::diag::set_log_path(path.clone());
    path
}

fn read_diag(path: &std::path::Path) -> String {
    std::fs::read_to_string(path).unwrap_or_default()
}

/// Proves the `[rendezvous] idea_reply received` beacon fires BEFORE correlation and
/// is followed by `UNMATCHED` when no in-flight ask exists for the emitter.
#[tokio::test]
async fn diag_capture_idea_reply_received_then_unmatched() {
    let log = diag_log_path();
    let contexts = FakeContexts::new();
    let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);

    // A distinctive emitter id ⇒ match only this test's reply beacons in the log.
    let from = AgentId::from_uuid(Uuid::from_u128(0xD1A6_0000_0000_0000_0000_0000_0000_0042));
    let cmd = domain::OrchestratorCommand::Reply {
        from,
        ticket: None,
        result: "orphan reply".to_owned(),
    };
    // No ask is in flight for `from` ⇒ dispatch returns an Invalid error (unmatched).
    let outcome = service.dispatch(&project(), cmd).await;
    assert!(outcome.is_err(), "an orphan idea_reply is a typed error");

    let body = read_diag(&log);
    let from = from.to_string();
    assert!(
        body.contains(&format!(
            "[rendezvous] idea_reply received: from agent {from}"
        )),
        "received beacon missing: {body}"
    );
    assert!(
        body.contains(&format!(
            "[rendezvous] idea_reply UNMATCHED: from agent {from}"
        )),
        "UNMATCHED beacon missing: {body}"
    );
}
