//! Integration tests for the orchestrator filesystem adapter (ARCHITECTURE §14.3).
//!
//! These drive [`process_request_file`] — the standalone "parse + dispatch + write
//! response + delete request" unit — against a real temp directory, with an
//! [`OrchestratorService`] wired over in-memory fakes. We assert:
//!
//! - a **valid** `spawn_agent` request → success response, request deleted, agent
//!   created + launched,
//! - an **invalid JSON** request → error response (no panic, request deleted),
//! - an unknown action → error response carrying the rejection.

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
    AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
    BackgroundTaskPortError, BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, EventBus,
    EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext,
    ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream, 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::{
    BackgroundTask, BackgroundTaskResult, BackgroundTaskState, PtySize, SessionId, TaskId,
};
use uuid::Uuid;

use application::{
    CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
    OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{
    process_request_file, FsOrchestratorWatcher, InMemoryMailbox, OrchestratorResponse,
};

// --- temp dir (mirror local_fs.rs) ---
struct TempDir(PathBuf);
impl TempDir {
    fn new() -> Self {
        let p = std::env::temp_dir().join(format!("idea-orch-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&p).unwrap();
        Self(p)
    }
}
impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

// --- minimal fakes ---
#[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`. Returns the id.
    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(())
    }
}

// Empty skill store: the watcher tests spawn agents with no assigned skills.
#[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(())
    }
}

/// An empty [`MemoryRecall`]: no project memory ⇒ no memory section injected,
/// leaving the watcher-driven launch behaviour unchanged.
#[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 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
    }
}

struct FixedClock(i64);
impl Clock for FixedClock {
    fn now_millis(&self) -> i64 {
        self.0
    }
}

#[derive(Default)]
struct InMemoryBackgroundTaskStore {
    tasks: Mutex<HashMap<TaskId, BackgroundTask>>,
}

impl InMemoryBackgroundTaskStore {
    fn all(&self) -> Vec<BackgroundTask> {
        self.tasks.lock().unwrap().values().cloned().collect()
    }
}

#[async_trait]
impl BackgroundTaskStore for InMemoryBackgroundTaskStore {
    async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
        let mut tasks = self.tasks.lock().unwrap();
        if tasks.contains_key(&task.id) {
            return Err(BackgroundTaskPortError::AlreadyExists);
        }
        tasks.insert(task.id, task.clone());
        Ok(())
    }

    async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError> {
        Ok(self.tasks.lock().unwrap().get(&id).cloned())
    }

    async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
        self.tasks.lock().unwrap().insert(task.id, task.clone());
        Ok(())
    }

    async fn list_open_for_agent(
        &self,
        agent_id: AgentId,
    ) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
        Ok(self
            .tasks
            .lock()
            .unwrap()
            .values()
            .filter(|task| task.owner_agent_id == agent_id && !task.is_terminal())
            .cloned()
            .collect())
    }

    async fn list_undelivered_completions(
        &self,
    ) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
        Ok(self
            .tasks
            .lock()
            .unwrap()
            .values()
            .filter(|task| task.has_pending_completion_delivery())
            .cloned()
            .collect())
    }

    async fn mark_completion_delivered(
        &self,
        task_id: TaskId,
    ) -> Result<(), BackgroundTaskPortError> {
        let mut tasks = self.tasks.lock().unwrap();
        let task = tasks
            .get(&task_id)
            .cloned()
            .ok_or(BackgroundTaskPortError::NotFound)?;
        let delivered = task
            .mark_completion_delivered()
            .map_err(|err| BackgroundTaskPortError::Invalid(err.to_string()))?;
        tasks.insert(task_id, delivered);
        Ok(())
    }
}

#[derive(Clone)]
struct BlockingReplyFactory {
    outcome: Arc<Mutex<BlockingReplyOutcome>>,
    notify: Arc<tokio::sync::Notify>,
}

impl BlockingReplyFactory {
    fn new(reply: impl Into<String>) -> Self {
        Self {
            outcome: Arc::new(Mutex::new(BlockingReplyOutcome::Final(reply.into()))),
            notify: Arc::new(tokio::sync::Notify::new()),
        }
    }

    fn no_reply(&self) {
        *self.outcome.lock().unwrap() = BlockingReplyOutcome::NoReply;
    }

    fn release(&self) {
        self.notify.notify_waiters();
    }
}

#[derive(Clone)]
enum BlockingReplyOutcome {
    Final(String),
    NoReply,
}

struct BlockingReplySession {
    id: SessionId,
    outcome: Arc<Mutex<BlockingReplyOutcome>>,
    notify: Arc<tokio::sync::Notify>,
}

#[async_trait]
impl AgentSession for BlockingReplySession {
    fn id(&self) -> SessionId {
        self.id
    }

    fn conversation_id(&self) -> Option<String> {
        None
    }

    async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
        self.notify.notified().await;
        match self.outcome.lock().unwrap().clone() {
            BlockingReplyOutcome::Final(content) => {
                Ok(Box::new(vec![ReplyEvent::Final { content }].into_iter()))
            }
            BlockingReplyOutcome::NoReply => Err(AgentSessionError::Io(
                "target returned without a structured final".to_owned(),
            )),
        }
    }

    async fn shutdown(&self) -> Result<(), AgentSessionError> {
        Ok(())
    }
}

#[async_trait]
impl AgentSessionFactory for BlockingReplyFactory {
    fn supports(&self, profile: &AgentProfile) -> bool {
        profile.structured_adapter.is_some()
    }

    async fn start(
        &self,
        _profile: &AgentProfile,
        _ctx: &PreparedContext,
        _cwd: &ProjectPath,
        _session: &SessionPlan,
        _requester: Option<&str>,
        _env: &[(String, String)],
        _sandbox: Option<&domain::sandbox::SandboxPlan>,
    ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
        Ok(Arc::new(BlockingReplySession {
            id: SessionId::from_uuid(Uuid::new_v4()),
            outcome: Arc::clone(&self.outcome),
            notify: Arc::clone(&self.notify),
        }))
    }
}

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()
}

fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
    // 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))),
    ));
    Arc::new(OrchestratorService::new(
        create,
        launch,
        list,
        close,
        update,
        create_skill,
        Arc::clone(&profiles) as Arc<dyn ProfileStore>,
        sessions,
    ))
}

/// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1
/// « Terminal + MCP », B-3/B-4), so an `agent.message` blocks on the mailbox and an
/// `agent.reply` resolves it. Returns the service, the shared mailbox (to observe
/// pending tickets) and the PTY registry (to pre-seed a live target).
fn build_service_with_mailbox(
    contexts: FakeContexts,
) -> (
    Arc<OrchestratorService>,
    Arc<InMemoryMailbox>,
    Arc<TerminalSessions>,
    BlockingReplyFactory,
    Arc<InMemoryBackgroundTaskStore>,
) {
    // 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 structured = Arc::new(StructuredSessions::new());
    let structured_factory = BlockingReplyFactory::new("the answer is 42");
    let background_tasks = Arc::new(InMemoryBackgroundTaskStore::default());
    let mailbox = Arc::new(InMemoryMailbox::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,
        )
        .with_structured(
            Arc::new(structured_factory.clone()) as Arc<dyn AgentSessionFactory>,
            Arc::clone(&structured),
        ),
    );
    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),
        )
        .with_input_mediator(
            Arc::new(infrastructure::MediatedInbox::with_pty(
                Arc::clone(&mailbox),
                Arc::new(infrastructure::SystemMillisClock),
                Arc::new(FakePty) as Arc<dyn PtyPort>,
            )) as Arc<dyn domain::input::InputMediator>,
            Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
        )
        .with_conversations(
            Arc::new(infrastructure::InMemoryConversationRegistry::new())
                as Arc<dyn domain::conversation::ConversationRegistry>,
        )
        .with_structured(Arc::clone(&structured))
        .with_background_tasks(
            Arc::clone(&background_tasks) as Arc<dyn BackgroundTaskStore>,
            Arc::new(FixedClock(1_700_000_000_000)) as Arc<dyn Clock>,
        ),
    );
    (
        service,
        mailbox,
        sessions,
        structured_factory,
        background_tasks,
    )
}

fn read_response(request_path: &std::path::Path) -> OrchestratorResponse {
    let mut name = request_path
        .file_name()
        .unwrap()
        .to_string_lossy()
        .into_owned();
    name.push_str(".response.json");
    let response_path = request_path.with_file_name(name);
    let bytes = std::fs::read(&response_path).expect("response file written");
    serde_json::from_slice(&bytes).expect("response parses")
}

/// Reads the raw `*.response.json` bytes as a UTF-8 string, for assertions on the
/// *literal wire shape* (e.g. that the `"reply"` key is absent — not just `None`
/// after deserialisation).
fn read_response_raw(request_path: &std::path::Path) -> String {
    let mut name = request_path
        .file_name()
        .unwrap()
        .to_string_lossy()
        .into_owned();
    name.push_str(".response.json");
    let response_path = request_path.with_file_name(name);
    std::fs::read_to_string(&response_path).expect("response file written")
}

#[tokio::test]
async fn valid_spawn_request_succeeds_and_is_consumed() {
    let tmp = TempDir::new();
    let contexts = FakeContexts::new();
    let service = build_service(contexts.clone());

    let req = tmp.0.join("req-1.json");
    std::fs::write(
        &req,
        br#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code" }"#,
    )
    .unwrap();

    let response = process_request_file(&req, &project(), &service).await;

    assert!(response.ok, "expected ok, got {response:?}");
    assert_eq!(response.action.as_deref(), Some("spawn_agent"));
    // Request consumed; a response sibling written.
    assert!(!req.exists(), "request file must be removed");
    let on_disk = read_response(&req);
    assert!(on_disk.ok);
    // The agent was actually created through the use cases.
    assert_eq!(contexts.entries().len(), 1);
    assert_eq!(contexts.entries()[0].name, "dev-backend");
}

#[tokio::test]
async fn valid_agent_run_request_succeeds_and_reports_type() {
    let tmp = TempDir::new();
    let contexts = FakeContexts::new();
    let service = build_service(contexts.clone());

    let req = tmp.0.join("req-agent-run.json");
    std::fs::write(
        &req,
        br#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "architect", "profile": "claude-code", "task": "Analyse", "visibility": "background" }"#,
    )
    .unwrap();

    let response = process_request_file(&req, &project(), &service).await;

    assert!(response.ok, "expected ok, got {response:?}");
    assert_eq!(response.action.as_deref(), Some("agent.run"));
    assert!(!req.exists(), "request file must be removed");
    let on_disk = read_response(&req);
    assert!(on_disk.ok);
    assert_eq!(contexts.entries().len(), 1);
    assert_eq!(contexts.entries()[0].name, "architect");
}

#[tokio::test]
async fn valid_create_skill_request_succeeds_and_is_consumed() {
    let tmp = TempDir::new();
    let service = build_service(FakeContexts::new());

    let req = tmp.0.join("skill-req.json");
    std::fs::write(
        &req,
        br##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##,
    )
    .unwrap();

    let response = process_request_file(&req, &project(), &service).await;

    assert!(response.ok, "expected ok, got {response:?}");
    assert_eq!(response.action.as_deref(), Some("create_skill"));
    // Request consumed; a response sibling written and marked ok.
    assert!(!req.exists(), "request file must be removed");
    assert!(read_response(&req).ok);
}

#[tokio::test]
async fn invalid_json_request_yields_error_response() {
    let tmp = TempDir::new();
    let service = build_service(FakeContexts::new());

    let req = tmp.0.join("broken.json");
    std::fs::write(&req, b"{ this is not json").unwrap();

    let response = process_request_file(&req, &project(), &service).await;

    assert!(!response.ok);
    assert!(
        response
            .error
            .as_deref()
            .unwrap_or_default()
            .contains("invalid json"),
        "got {response:?}"
    );
    assert!(!req.exists(), "poisoned request must still be removed");
    assert!(!read_response(&req).ok);
}

#[tokio::test]
async fn unknown_action_yields_error_response() {
    let tmp = TempDir::new();
    let service = build_service(FakeContexts::new());

    let req = tmp.0.join("weird.json");
    std::fs::write(&req, br#"{ "action": "explode", "name": "x" }"#).unwrap();

    let response = process_request_file(&req, &project(), &service).await;

    assert!(!response.ok);
    assert_eq!(response.action.as_deref(), Some("explode"));
    assert!(response
        .error
        .as_deref()
        .unwrap_or_default()
        .contains("unknown orchestrator action"));
}

// ---------------------------------------------------------------------------
// LOT D6b — `reply` surfaced into the `*.response.json` wire format.
//
// D6b makes `OrchestratorResponse` carry an optional `reply` (the queried agent's
// content for `ask`/`agent.message`), `#[serde(skip_serializing_if = "Option::is_none")]`
// so a response with no reply keeps the exact legacy shape.
// ---------------------------------------------------------------------------

/// Point 1 — an `agent.message` (`ask`) whose target replies ⇒ the on-disk
/// `*.response.json` carries the `reply` content **and** `detail` (both coexist),
/// alongside `ok: true`.
#[tokio::test]
async fn ask_request_surfaces_reply_alongside_detail() {
    // B6: an `agent.message` request is still synchronous for the caller, but the
    // target turn is driven through the structured/headless session. The mailbox still
    // receives exactly one silent ticket for turn accounting; the response comes from
    // `ReplyEvent::Final`, not from a separate `agent.reply` request.
    let tmp = TempDir::new();
    let contexts = FakeContexts::new();
    let agent_id = contexts.seed_agent("architect");
    let (service, mailbox, _sessions, structured_reply, tasks) =
        build_service_with_mailbox(contexts.clone());

    let req = tmp.0.join("ask-req.json");
    std::fs::write(
        &req,
        br#"{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "architect", "task": "What is the answer?" }"#,
    )
    .unwrap();

    let svc = Arc::clone(&service);
    let proj = project();
    let ask_req = req.clone();
    let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });

    // Wait until the ask has enqueued its ticket (blocked awaiting the reply).
    tokio::time::timeout(std::time::Duration::from_secs(10), async {
        while mailbox.pending(&agent_id) == 0 {
            tokio::task::yield_now().await;
        }
    })
    .await
    .expect("ask must enqueue a ticket");

    structured_reply.release();

    let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
        .await
        .expect("ask completes after reply")
        .expect("join ok");

    assert!(response.ok, "expected ok, got {response:?}");
    assert_eq!(response.action.as_deref(), Some("agent.message"));
    // reply carries the target's rendered result...
    assert_eq!(response.reply.as_deref(), Some("the answer is 42"));
    // ...and detail still summarises what IdeA did (the two coexist).
    assert!(
        response
            .detail
            .as_deref()
            .unwrap_or_default()
            .contains("architect"),
        "detail should still be present: {response:?}"
    );

    // The on-disk wire format actually contains both keys with the right values.
    let on_disk = read_response(&req);
    assert!(on_disk.ok);
    assert_eq!(on_disk.reply.as_deref(), Some("the answer is 42"));
    assert!(on_disk.detail.is_some());

    let raw = read_response_raw(&req);
    let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(json["reply"], serde_json::json!("the answer is 42"));
    assert!(json.get("detail").is_some());
    assert!(!req.exists(), "request file must be removed");

    let stored = tasks.all();
    assert_eq!(stored.len(), 1, "one rendezvous task persisted: {stored:?}");
    let task = &stored[0];
    assert_eq!(task.owner_agent_id, agent_id);
    assert_eq!(task.state, BackgroundTaskState::Completed);
    assert!(matches!(
        task.kind,
        domain::BackgroundTaskKind::HeadlessRendezvous { .. }
    ));
    assert!(matches!(
        task.result,
        Some(BackgroundTaskResult::Success { .. })
    ));
}

#[tokio::test]
async fn ask_request_no_reply_persists_failed_rendezvous_task() {
    let tmp = TempDir::new();
    let contexts = FakeContexts::new();
    let agent_id = contexts.seed_agent("architect");
    let (service, mailbox, _sessions, structured_reply, tasks) =
        build_service_with_mailbox(contexts.clone());
    structured_reply.no_reply();

    let req = tmp.0.join("ask-no-reply.json");
    std::fs::write(
        &req,
        br#"{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "architect", "task": "Please forget to finalise" }"#,
    )
    .unwrap();

    let svc = Arc::clone(&service);
    let proj = project();
    let ask_req = req.clone();
    let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });

    tokio::time::timeout(std::time::Duration::from_secs(10), async {
        while mailbox.pending(&agent_id) == 0 {
            tokio::task::yield_now().await;
        }
    })
    .await
    .expect("ask must enqueue a ticket before no-reply");

    structured_reply.release();

    let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
        .await
        .expect("ask completes after no-reply")
        .expect("join ok");
    assert!(!response.ok, "expected no-reply error, got {response:?}");

    let stored = tasks.all();
    assert_eq!(stored.len(), 1, "one rendezvous task persisted: {stored:?}");
    let task = &stored[0];
    assert_eq!(task.owner_agent_id, agent_id);
    assert_eq!(task.state, BackgroundTaskState::Failed);
    match task.result.as_ref() {
        Some(BackgroundTaskResult::Failure { error, .. }) => {
            assert!(
                error.contains("NoReply"),
                "error should classify no-reply: {error}"
            );
        }
        other => panic!("expected failure result, got {other:?}"),
    }
    assert_eq!(mailbox.pending(&agent_id), 0, "turn-lock mailbox is freed");
}

/// Point 2 — a non-`ask` command (here `spawn_agent`, reply `None`) ⇒ the `reply`
/// key is **absent** from the serialised JSON (`skip_serializing_if`). Anti-always-green:
/// we assert on the *literal absence* of the key in the raw bytes, so the test would
/// fail if `reply: null` were emitted instead.
#[tokio::test]
async fn non_ask_request_omits_reply_key() {
    let tmp = TempDir::new();
    let contexts = FakeContexts::new();
    let service = build_service(contexts.clone());

    let req = tmp.0.join("spawn-no-reply.json");
    std::fs::write(
        &req,
        br#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code" }"#,
    )
    .unwrap();

    let response = process_request_file(&req, &project(), &service).await;

    assert!(response.ok, "expected ok, got {response:?}");
    assert!(response.reply.is_none(), "spawn must not produce a reply");

    // Deserialised: reply is None.
    let on_disk = read_response(&req);
    assert!(on_disk.reply.is_none());

    // Raw wire: the "reply" key must not appear at all. (Guard the assertion itself:
    // confirm we ARE looking at the right file by checking a key we know is present.)
    let raw = read_response_raw(&req);
    assert!(
        raw.contains("\"ok\""),
        "sanity: response JSON should contain ok — got {raw}"
    );
    assert!(
        !raw.contains("reply"),
        "reply key must be skipped when None — got {raw}"
    );
    // And via the parsed tree, the key is structurally absent (not null).
    let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert!(
        json.get("reply").is_none(),
        "reply key must be structurally absent — got {json}"
    );
}

/// Point 3 — round-trip a legacy `*.response.json` written **before** D6b (no
/// `reply` key): it deserialises to `reply == None`, and re-serialising does **not**
/// reintroduce the key (forward/backward wire compatibility).
#[test]
fn legacy_response_without_reply_round_trips_without_reintroducing_key() {
    // A response payload exactly as a pre-D6b IdeA would have written it.
    let legacy =
        r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#;

    let parsed: OrchestratorResponse =
        serde_json::from_str(legacy).expect("legacy response must still parse");
    assert!(parsed.ok);
    assert_eq!(parsed.action.as_deref(), Some("spawn_agent"));
    assert!(
        parsed.reply.is_none(),
        "absent reply key must deserialise to None"
    );

    // Re-serialising must not bring a "reply" key back.
    let reserialised = serde_json::to_string(&parsed).unwrap();
    assert!(
        !reserialised.contains("reply"),
        "round-trip must not reintroduce the reply key — got {reserialised}"
    );
    let json: serde_json::Value = serde_json::from_str(&reserialised).unwrap();
    assert!(json.get("reply").is_none());
}

/// Point 4 — a failure response carries no `reply` (the field is `None` for every
/// `failure(..)`), so the key is absent from the failing `*.response.json` too.
#[tokio::test]
async fn failure_response_omits_reply_key() {
    let tmp = TempDir::new();
    let service = build_service(FakeContexts::new());

    let req = tmp.0.join("broken-no-reply.json");
    std::fs::write(&req, b"{ not json at all").unwrap();

    let response = process_request_file(&req, &project(), &service).await;

    assert!(!response.ok);
    assert!(response.reply.is_none(), "failure must not carry a reply");

    let raw = read_response_raw(&req);
    assert!(
        raw.contains("\"error\""),
        "sanity: a failure JSON should contain error — got {raw}"
    );
    assert!(
        !raw.contains("reply"),
        "reply key must be absent on failure — got {raw}"
    );
}

// ---------------------------------------------------------------------------
// M4 — observability: the filesystem watcher tags processed requests source=File.
//
// Drives the *public* `FsOrchestratorWatcher::start` against a real temp project
// root with a capturing events closure (the same sink shape the composition root
// wires), then polls (bounded) until the OrchestratorRequestProcessed beacon for
// the dropped request lands. Asserts the tag is `OrchestrationSource::File`.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn watcher_publishes_processed_event_tagged_file_source() {
    use std::time::Duration;

    let tmp = TempDir::new();
    // A project rooted at the temp dir so the watcher scans <tmp>/.ideai/requests/.
    let project = Project::new(
        ProjectId::from_uuid(Uuid::from_u128(2000)),
        "demo",
        ProjectPath::new(tmp.0.to_str().unwrap()).unwrap(),
        RemoteRef::local(),
        1_700_000_000_000,
    )
    .unwrap();
    let service = build_service(FakeContexts::new());

    let captured = Arc::new(Mutex::new(Vec::<DomainEvent>::new()));
    let sink = captured.clone();
    let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
        Arc::new(move |e| sink.lock().unwrap().push(e));

    let handle = FsOrchestratorWatcher::start(project, Arc::clone(&service), publish);

    // Drop a valid request under .ideai/requests/<requester>/.
    let req_dir = tmp.0.join(".ideai").join("requests").join("Main");
    std::fs::create_dir_all(&req_dir).unwrap();
    std::fs::write(
        req_dir.join("req-1.json"),
        br#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code" }"#,
    )
    .unwrap();

    // Poll (bounded) until the processed beacon for our request shows up.
    let mut found: Option<DomainEvent> = None;
    for _ in 0..50 {
        if let Some(e) = captured.lock().unwrap().iter().find(|e| {
            matches!(e, DomainEvent::OrchestratorRequestProcessed { action, .. } if action == "spawn_agent")
        }) {
            found = Some(e.clone());
            break;
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    handle.stop();

    let event = found.expect("watcher must publish a processed beacon within the poll budget");
    match event {
        DomainEvent::OrchestratorRequestProcessed {
            source,
            ok,
            requester_id,
            ..
        } => {
            assert_eq!(source, OrchestrationSource::File, "fs door must tag File");
            assert!(ok, "the spawn request succeeded");
            assert_eq!(requester_id, "Main", "requester id = request subdirectory");
        }
        other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
    }
}
