//! L6 tests for the agent lifecycle use cases (`CreateAgentFromScratch`,
//! `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`,
//! `LaunchAgent`).
//!
//! Every port is faked in-memory so the use cases run without real I/O:
//! - [`FakeContexts`] — an [`AgentContextStore`] holding the manifest + a
//!   `md_path → content` map,
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
//! - [`FakeRuntime`] — an [`AgentRuntime`] whose `prepare_invocation` records the
//!   call into a shared **trace** and returns a configurable injection plan,
//! - [`FakeFs`] — a [`FileSystem`] recording writes into the same trace,
//! - [`FakePty`] — a [`PtyPort`] recording `spawn` into the trace,
//! - [`SpyBus`], [`SeqIds`] — event recorder and deterministic id generator.
//!
//! The shared trace lets us assert the **call ordering** contract of
//! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`.

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

use async_trait::async_trait;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::permission::{
    EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
    ProjectionContext, ProjectorKey,
};
use domain::ports::{
    AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
    ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
    IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PermissionStore,
    PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyStream,
    RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
};
use domain::profile::{
    AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
    McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter,
};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType, NetworkPolicy};
use domain::{PermissionSet, ProjectPermissions, ProjectSystemPermissions, SystemPermissionSet};
use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid;

use application::{
    CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
    LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
    ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
    UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
};

// ---------------------------------------------------------------------------
// Shared trace (ordering)
// ---------------------------------------------------------------------------

type Trace = Arc<Mutex<Vec<String>>>;

/// A recorded list of `(target, bytes)` writes, keyed by whatever addresses the
/// target (a path for the fs, a [`SessionId`] for the pty).
type WriteLog<K> = Arc<Mutex<Vec<(K, Vec<u8>)>>>;

fn trace() -> Trace {
    Arc::new(Mutex::new(Vec::new()))
}

// ---------------------------------------------------------------------------
// FakeContexts (AgentContextStore)
// ---------------------------------------------------------------------------

#[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 with_agent(agent: &Agent, content: &str) -> Self {
        let me = Self::new();
        {
            let mut inner = me.0.lock().unwrap();
            inner
                .manifest
                .entries
                .push(ManifestEntry::from_agent(agent));
            inner
                .contents
                .insert(agent.context_path.clone(), content.to_owned());
        }
        me
    }
    fn manifest(&self) -> AgentManifest {
        self.0.lock().unwrap().manifest.clone()
    }
    fn content(&self, md_path: &str) -> Option<String> {
        self.0.lock().unwrap().contents.get(md_path).cloned()
    }
    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_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
        self.content(&md_path)
            .map(MarkdownDoc::new)
            .ok_or(StoreError::NotFound)
    }
    async fn write_context(
        &self,
        _project: &Project,
        agent: &AgentId,
        md: &MarkdownDoc,
    ) -> Result<(), StoreError> {
        let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
        self.0
            .lock()
            .unwrap()
            .contents
            .insert(md_path, md.as_str().to_owned());
        Ok(())
    }
    async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
        Ok(self.manifest())
    }
    async fn save_manifest(
        &self,
        _project: &Project,
        manifest: &AgentManifest,
    ) -> Result<(), StoreError> {
        self.0.lock().unwrap().manifest = manifest.clone();
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// FakeProfiles (ProfileStore)
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct FakeProfiles(Arc<Vec<AgentProfile>>);

impl FakeProfiles {
    fn new(profiles: Vec<AgentProfile>) -> Self {
        Self(Arc::new(profiles))
    }
}

#[async_trait]
impl ProfileStore for FakeProfiles {
    async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
        Ok((*self.0).clone())
    }
    async fn save(&self, _profile: &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(())
    }
}

// ---------------------------------------------------------------------------
// FakeSkills (SkillStore) — an in-memory store seeded with a few skills
// ---------------------------------------------------------------------------

#[derive(Clone, Default)]
struct FakeSkills(Arc<Vec<Skill>>);

impl FakeSkills {
    fn with(skills: Vec<Skill>) -> Self {
        Self(Arc::new(skills))
    }
}

#[async_trait]
impl SkillStore for FakeSkills {
    async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
        Ok(self
            .0
            .iter()
            .filter(|s| s.scope == scope)
            .cloned()
            .collect())
    }
    async fn get(
        &self,
        scope: SkillScope,
        _root: &ProjectPath,
        id: SkillId,
    ) -> Result<Skill, StoreError> {
        self.0
            .iter()
            .find(|s| s.scope == scope && s.id == id)
            .cloned()
            .ok_or(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(())
    }
}

// ---------------------------------------------------------------------------
// FakeRecall (MemoryRecall) — returns a canned index, or fails (best-effort test)
// ---------------------------------------------------------------------------

/// In-memory [`MemoryRecall`] fake for the launch tests: empty by default
/// (⇒ no memory section, behaviour unchanged), configurable with canned entries,
/// or set to fail so the best-effort degradation at activation can be asserted.
#[derive(Clone, Default)]
struct FakeRecall {
    entries: Arc<Vec<MemoryIndexEntry>>,
    fail: bool,
}

impl FakeRecall {
    fn returning(entries: Vec<MemoryIndexEntry>) -> Self {
        Self {
            entries: Arc::new(entries),
            fail: false,
        }
    }
    fn failing() -> Self {
        Self {
            entries: Arc::default(),
            fail: true,
        }
    }
}

#[async_trait]
impl MemoryRecall for FakeRecall {
    async fn recall(
        &self,
        _root: &ProjectPath,
        _query: &MemoryQuery,
    ) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
        if self.fail {
            return Err(MemoryError::Io("recall boom".to_owned()));
        }
        Ok((*self.entries).clone())
    }
}

/// Builds a memory index entry for the launch tests.
fn mem_entry(slug: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry {
    MemoryIndexEntry {
        slug: MemorySlug::new(slug).unwrap(),
        title: title.to_owned(),
        hook: hook.to_owned(),
        r#type: kind,
    }
}

// ---------------------------------------------------------------------------
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
// ---------------------------------------------------------------------------

struct FakeRuntime {
    trace: Trace,
    plan: Option<ContextInjectionPlan>,
    env: Vec<(String, String)>,
    /// The last [`SessionPlan`] handed to `prepare_invocation`, captured so tests
    /// can assert the launch resolved the right Assign/Resume/None intention (T4).
    last_session: Arc<Mutex<Option<SessionPlan>>>,
}

impl FakeRuntime {
    fn new(trace: Trace, plan: Option<ContextInjectionPlan>) -> Self {
        Self {
            trace,
            plan,
            env: Vec::new(),
            last_session: Arc::new(Mutex::new(None)),
        }
    }
    fn with_env(mut self, env: Vec<(String, String)>) -> Self {
        self.env = env;
        self
    }
    /// Shared handle to inspect the captured session plan after a launch.
    fn session_probe(&self) -> Arc<Mutex<Option<SessionPlan>>> {
        Arc::clone(&self.last_session)
    }
}

#[async_trait]
impl AgentRuntime for FakeRuntime {
    async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
        Ok(true)
    }
    fn prepare_invocation(
        &self,
        profile: &AgentProfile,
        _ctx: &PreparedContext,
        cwd: &ProjectPath,
        session: &SessionPlan,
    ) -> Result<SpawnSpec, RuntimeError> {
        *self.last_session.lock().unwrap() = Some(session.clone());
        self.trace.lock().unwrap().push("prepare".to_owned());
        Ok(SpawnSpec {
            command: profile.command.clone(),
            args: profile.args.clone(),
            cwd: cwd.clone(),
            env: self.env.clone(),
            context_plan: self.plan.clone(),
            sandbox: None,
        })
    }
}

// ---------------------------------------------------------------------------
// FakeFs (FileSystem) — records writes into the trace
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct FakeFs {
    trace: Trace,
    writes: WriteLog<String>,
    created_dirs: Arc<Mutex<Vec<String>>>,
    project_context: Arc<Mutex<Option<String>>>,
    /// Paths reported as **already existing** by [`FileSystem::exists`] (the
    /// default empty set keeps the historical `exists == false` behaviour). Used
    /// by the MCP non-clobbering test.
    existing: Arc<Mutex<Vec<String>>>,
    /// Paths whose [`FileSystem::write`] must fail with an I/O error (nothing is
    /// recorded for them). Used by the MCP best-effort test to prove that an MCP
    /// config write failure never fails the launch.
    fail_writes_for: Arc<Mutex<Vec<String>>>,
    /// Canned `path → contents` returned by [`FileSystem::read`] when no later write
    /// to that path exists. Used by the `MergeToml` projection test to seed a
    /// pre-existing `.codex/config.toml` carrying unmanaged user keys.
    seeded_reads: Arc<Mutex<HashMap<String, String>>>,
}

impl FakeFs {
    fn new(trace: Trace) -> Self {
        Self {
            trace,
            writes: Arc::new(Mutex::new(Vec::new())),
            created_dirs: Arc::new(Mutex::new(Vec::new())),
            project_context: Arc::new(Mutex::new(None)),
            existing: Arc::new(Mutex::new(Vec::new())),
            fail_writes_for: Arc::new(Mutex::new(Vec::new())),
            seeded_reads: Arc::new(Mutex::new(HashMap::new())),
        }
    }
    fn set_project_context(&self, content: &str) {
        *self.project_context.lock().unwrap() = Some(content.to_owned());
    }
    /// Seeds a canned content for `path` so [`FileSystem::read`] returns it until a
    /// later [`FileSystem::write`] supersedes it (last-write-wins).
    fn seed_read(&self, path: &str, content: &str) {
        self.seeded_reads
            .lock()
            .unwrap()
            .insert(path.to_owned(), content.to_owned());
    }
    /// All writes whose path ends with `suffix` (e.g. the projected Codex config).
    fn writes_ending_with(&self, suffix: &str) -> Vec<(String, Vec<u8>)> {
        self.writes()
            .into_iter()
            .filter(|(p, _)| p.ends_with(suffix))
            .collect()
    }
    /// Marks a path as already existing on disk, so [`FileSystem::exists`] returns
    /// `true` for it (non-clobbering scenarios).
    fn mark_existing(&self, path: &str) {
        self.existing.lock().unwrap().push(path.to_owned());
    }
    /// Makes any [`FileSystem::write`] whose path ends with `suffix` fail
    /// (best-effort scenarios — e.g. the MCP `.mcp.json` config file).
    fn fail_write_suffix(&self, suffix: &str) {
        self.fail_writes_for.lock().unwrap().push(suffix.to_owned());
    }
    fn writes(&self) -> Vec<(String, Vec<u8>)> {
        self.writes.lock().unwrap().clone()
    }
    fn created_dirs(&self) -> Vec<String> {
        self.created_dirs.lock().unwrap().clone()
    }
    /// Convention-file / context writes only (excludes the Claude permission seed),
    /// so injection assertions stay focused on the agent's `.md`.
    fn context_writes(&self) -> Vec<(String, Vec<u8>)> {
        self.writes()
            .into_iter()
            .filter(|(p, _)| !p.ends_with("/.claude/settings.local.json"))
            .collect()
    }
    /// Only the Claude permission seed writes (`.claude/settings.local.json`).
    fn seed_writes(&self) -> Vec<(String, Vec<u8>)> {
        self.writes()
            .into_iter()
            .filter(|(p, _)| p.ends_with("/.claude/settings.local.json"))
            .collect()
    }
    /// Created run dirs excluding the seed's `.claude` subdir.
    fn created_run_dirs(&self) -> Vec<String> {
        self.created_dirs()
            .into_iter()
            .filter(|p| !p.ends_with("/.claude"))
            .collect()
    }
}

#[async_trait]
impl FileSystem for FakeFs {
    async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
        let p = path.as_str();
        // Last-write-wins: a previously written file reads back its latest content
        // (so the MergeToml merge/idempotence test observes its own prior write).
        if let Some((_, bytes)) = self.writes().into_iter().rev().find(|(wp, _)| wp == p) {
            return Ok(bytes);
        }
        // Then any canned seed for this exact path.
        if let Some(content) = self.seeded_reads.lock().unwrap().get(p) {
            return Ok(content.clone().into_bytes());
        }
        if p.ends_with("/.ideai/CONTEXT.md") {
            return self
                .project_context
                .lock()
                .unwrap()
                .clone()
                .map(|s| s.into_bytes())
                .ok_or_else(|| FsError::NotFound(p.to_owned()));
        }
        Err(FsError::NotFound(p.to_owned()))
    }
    async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
        let p = path.as_str();
        if self
            .fail_writes_for
            .lock()
            .unwrap()
            .iter()
            .any(|suffix| p.ends_with(suffix.as_str()))
        {
            return Err(FsError::Io(format!("forced write failure for {p}")));
        }
        self.trace.lock().unwrap().push("fs.write".to_owned());
        self.writes
            .lock()
            .unwrap()
            .push((p.to_owned(), data.to_vec()));
        Ok(())
    }
    async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
        let p = path.as_str();
        Ok(self.existing.lock().unwrap().iter().any(|e| e == p))
    }
    async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
        self.created_dirs
            .lock()
            .unwrap()
            .push(path.as_str().to_owned());
        Ok(())
    }
    async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
        Ok(Vec::new())
    }
    async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// FakePty (PtyPort) — records spawn into the trace
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct FakePty {
    trace: Trace,
    next_id: SessionId,
    spawns: Arc<Mutex<Vec<SpawnSpec>>>,
    writes: WriteLog<SessionId>,
}

impl FakePty {
    fn new(trace: Trace, next_id: SessionId) -> Self {
        Self {
            trace,
            next_id,
            spawns: Arc::new(Mutex::new(Vec::new())),
            writes: Arc::new(Mutex::new(Vec::new())),
        }
    }
    fn spawns(&self) -> Vec<SpawnSpec> {
        self.spawns.lock().unwrap().clone()
    }
    fn writes(&self) -> Vec<(SessionId, Vec<u8>)> {
        self.writes.lock().unwrap().clone()
    }
}

#[async_trait]
impl PtyPort for FakePty {
    async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
        self.trace.lock().unwrap().push("spawn".to_owned());
        self.spawns.lock().unwrap().push(spec);
        Ok(PtyHandle {
            session_id: self.next_id,
        })
    }
    fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
        self.writes
            .lock()
            .unwrap()
            .push((handle.session_id, data.to_vec()));
        Ok(())
    }
    fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
        Ok(())
    }
    fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
        Ok(Box::new(std::iter::empty()))
    }
    fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
        Ok(Vec::new())
    }
    async fn wait(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
        Ok(ExitStatus { code: Some(0) })
    }
    fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
        Ok(Some(ExitStatus { code: Some(0) }))
    }
    async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
        Ok(ExitStatus { code: Some(0) })
    }
}

// ---------------------------------------------------------------------------
// FakeStructuredFactory (AgentSessionFactory) — records structured starts
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct FakeSession {
    id: SessionId,
    conversation_id: Option<String>,
}

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

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

    async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
        Ok(Box::new(std::iter::empty()))
    }

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

#[derive(Clone)]
struct FakeStructuredFactory {
    trace: Trace,
    starts: Arc<Mutex<Vec<ProfileId>>>,
    efforts: Arc<Mutex<Vec<Option<String>>>>,
    envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
    policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
    next_session: SessionId,
}

impl FakeStructuredFactory {
    fn new(trace: Trace, next_session: SessionId) -> Self {
        Self {
            trace,
            starts: Arc::new(Mutex::new(Vec::new())),
            efforts: Arc::new(Mutex::new(Vec::new())),
            envs: Arc::new(Mutex::new(Vec::new())),
            policies: Arc::new(Mutex::new(Vec::new())),
            next_session,
        }
    }

    fn starts(&self) -> Vec<ProfileId> {
        self.starts.lock().unwrap().clone()
    }

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

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

    fn policies(&self) -> Vec<Option<domain::ports::StructuredProviderLaunchPolicy>> {
        self.policies.lock().unwrap().clone()
    }
}

#[async_trait]
impl AgentSessionFactory for FakeStructuredFactory {
    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>,
        structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>,
    ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
        self.trace
            .lock()
            .unwrap()
            .push("structured.start".to_owned());
        self.starts.lock().unwrap().push(profile.id);
        self.efforts
            .lock()
            .unwrap()
            .push(profile.model_reasoning_effort.clone());
        self.envs.lock().unwrap().push(_env.to_vec());
        self.policies
            .lock()
            .unwrap()
            .push(structured_policy.cloned());
        Ok(Arc::new(FakeSession {
            id: self.next_session,
            conversation_id: Some("engine-session-1".to_owned()),
        }))
    }
}

// ---------------------------------------------------------------------------
// SpyBus + SeqIds
// ---------------------------------------------------------------------------

#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
    fn events(&self) -> Vec<DomainEvent> {
        self.0.lock().unwrap().clone()
    }
}
impl EventBus for SpyBus {
    fn publish(&self, event: DomainEvent) {
        self.0.lock().unwrap().push(event);
    }
    fn subscribe(&self) -> EventStream {
        Box::new(std::iter::empty())
    }
}

struct SeqIds(Mutex<u128>);
impl SeqIds {
    fn new() -> Self {
        Self(Mutex::new(1))
    }
}
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
    }
}

// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------

fn pid(n: u128) -> ProfileId {
    ProfileId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
    AgentId::from_uuid(Uuid::from_u128(n))
}
fn sid(n: u128) -> SessionId {
    SessionId::from_uuid(Uuid::from_u128(n))
}

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 profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
    AgentProfile::new(
        id,
        "Claude Code",
        "claude",
        Vec::new(),
        injection,
        Some("claude --version".to_owned()),
        "{agentRunDir}",
        None,
    )
    .unwrap()
}

fn scratch_agent(id: AgentId, name: &str, md: &str, profile_id: ProfileId) -> Agent {
    Agent::new(id, name, md, profile_id, AgentOrigin::Scratch, false).unwrap()
}

// ---------------------------------------------------------------------------
// CreateAgentFromScratch
// ---------------------------------------------------------------------------

#[tokio::test]
async fn create_persists_manifest_entry_and_initial_context() {
    let contexts = FakeContexts::new();
    let bus = SpyBus::default();
    let create = CreateAgentFromScratch::new(
        Arc::new(contexts.clone()),
        Arc::new(SeqIds::new()),
        Arc::new(bus.clone()),
    );

    let out = create
        .execute(CreateAgentInput {
            project: project(),
            name: "Backend Dev".to_owned(),
            profile_id: pid(9),
            initial_content: Some("# Backend".to_owned()),
        })
        .await
        .expect("create succeeds");

    // md_path is slugified from the name.
    assert_eq!(out.agent.context_path, "agents/backend-dev.md");
    assert_eq!(out.agent.profile_id, pid(9));
    assert!(matches!(out.agent.origin, AgentOrigin::Scratch));
    assert!(!out.agent.synchronized);

    // Manifest has exactly one entry for this agent; context stored under md_path.
    let manifest = contexts.manifest();
    assert_eq!(manifest.entries.len(), 1);
    assert_eq!(manifest.entries[0].agent_id, out.agent.id);
    assert_eq!(
        contexts.content("agents/backend-dev.md").as_deref(),
        Some("# Backend")
    );
}

#[tokio::test]
async fn create_disambiguates_md_path_on_name_collision() {
    // Seed a project that already has `agents/backend.md`.
    let existing = scratch_agent(aid(50), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&existing, "old");
    let create = CreateAgentFromScratch::new(
        Arc::new(contexts.clone()),
        Arc::new(SeqIds::new()),
        Arc::new(SpyBus::default()),
    );

    let out = create
        .execute(CreateAgentInput {
            project: project(),
            name: "Backend".to_owned(),
            profile_id: pid(9),
            initial_content: None,
        })
        .await
        .unwrap();

    assert_eq!(out.agent.context_path, "agents/backend-2.md");
    assert_eq!(contexts.manifest().entries.len(), 2);
}

// ---------------------------------------------------------------------------
// ListAgents / Read / Update / Delete
// ---------------------------------------------------------------------------

#[tokio::test]
async fn list_reconstructs_agents_from_manifest() {
    let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&a, "ctx");
    let list = ListAgents::new(Arc::new(contexts));

    let out = list
        .execute(ListAgentsInput { project: project() })
        .await
        .unwrap();
    assert_eq!(out.agents, vec![a]);
}

#[tokio::test]
async fn list_resolves_agent_capabilities_additively() {
    let mut a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let skill_id = SkillId::from_uuid(uuid::Uuid::from_u128(77));
    a.assign_skill(domain::SkillRef::new(skill_id, domain::SkillScope::Global));
    let contexts = FakeContexts::with_agent(&a, "ctx");
    let skills = FakeSkills::with(vec![Skill::new(
        skill_id,
        "review",
        MarkdownDoc::new("# Review workflow\n\nbody"),
        domain::SkillScope::Global,
    )
    .unwrap()
    .with_description(Some("Reviews changes".to_owned()))
    .with_kind(domain::SkillKind::Reference)]);
    let resolver = Arc::new(application::ResolveAgentCapabilities::new(Arc::new(skills)));
    let list = ListAgents::with_capabilities(Arc::new(contexts), resolver);

    let out = list
        .execute(ListAgentsInput { project: project() })
        .await
        .unwrap();

    assert_eq!(out.agents, vec![a.clone()]);
    let entries = out.discovery_entries();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].agent.id, a.id);
    assert_eq!(entries[0].agent.skills.len(), 1);
    assert_eq!(entries[0].capabilities.len(), 1);
    assert_eq!(entries[0].capabilities[0].name, "review");
    assert_eq!(entries[0].capabilities[0].description, "Reviews changes");
    assert_eq!(
        entries[0].capabilities[0].kind,
        domain::SkillKind::Reference
    );
}

#[tokio::test]
async fn list_agents_marks_effective_orchestrator_as_is_orchestrator_true() {
    let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
    let contexts = FakeContexts::with_agent(&a1, "ctx1");
    {
        let mut inner = contexts.0.lock().unwrap();
        inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
        inner.manifest.designate(a2.id).unwrap();
    }
    let list = ListAgents::new(Arc::new(contexts));

    let out = list
        .execute(ListAgentsInput { project: project() })
        .await
        .unwrap();
    let entries = out.discovery_entries();

    assert_eq!(out.effective_orchestrator, Some(a2.id));
    assert_eq!(entries[0].is_orchestrator, false);
    assert_eq!(entries[1].is_orchestrator, true);
}

#[tokio::test]
async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() {
    let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
    let contexts = FakeContexts::with_agent(&a1, "ctx1");
    contexts
        .0
        .lock()
        .unwrap()
        .manifest
        .entries
        .push(ManifestEntry::from_agent(&a2));
    let list = ListAgents::new(Arc::new(contexts));

    let out = list
        .execute(ListAgentsInput { project: project() })
        .await
        .unwrap();
    let entries = out.discovery_entries();

    assert_eq!(out.effective_orchestrator, Some(a1.id));
    assert_eq!(entries[0].is_orchestrator, true);
    assert_eq!(entries[1].is_orchestrator, false);
}

#[tokio::test]
async fn read_then_update_context_roundtrips() {
    let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&a, "original");
    let read = ReadAgentContext::new(Arc::new(contexts.clone()));
    let update = UpdateAgentContext::new(Arc::new(contexts.clone()));

    let before = read
        .execute(ReadAgentContextInput {
            project: project(),
            agent_id: a.id,
        })
        .await
        .unwrap();
    assert_eq!(before.content.as_str(), "original");

    update
        .execute(UpdateAgentContextInput {
            project: project(),
            agent_id: a.id,
            content: "edited".to_owned(),
        })
        .await
        .unwrap();

    assert_eq!(
        contexts.content("agents/backend.md").as_deref(),
        Some("edited")
    );
}

#[tokio::test]
async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
    let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&a, "ctx");
    let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));

    let out = update
        .execute(UpdateAgentEffortInput {
            project: project(),
            agent_id: a.id,
            effort: Some(EffortSelection::Preset("high".to_owned())),
        })
        .await
        .unwrap();

    assert_eq!(
        out.agent.effort,
        Some(EffortSelection::Preset("high".to_owned()))
    );
    assert_eq!(
        contexts.manifest().entries[0].effort,
        Some(EffortSelection::Preset("high".to_owned()))
    );
}

#[tokio::test]
async fn update_agent_effort_none_clears_existing_override() {
    let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
        .with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
    let contexts = FakeContexts::with_agent(&a, "ctx");
    let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));

    let out = update
        .execute(UpdateAgentEffortInput {
            project: project(),
            agent_id: a.id,
            effort: None,
        })
        .await
        .unwrap();

    assert_eq!(out.agent.effort, None);
    assert_eq!(contexts.manifest().entries[0].effort, None);
}

#[tokio::test]
async fn update_agent_effort_not_found_for_unknown_agent_id() {
    let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&a, "ctx");
    let update = UpdateAgentEffort::new(Arc::new(contexts));

    let err = update
        .execute(UpdateAgentEffortInput {
            project: project(),
            agent_id: aid(404),
            effort: Some(EffortSelection::Preset("medium".to_owned())),
        })
        .await
        .unwrap_err();

    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

#[tokio::test]
async fn delete_removes_entry_then_unknown_is_not_found() {
    let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&a, "ctx");
    let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(SpyBus::default()));

    delete
        .execute(DeleteAgentInput {
            project: project(),
            agent_id: a.id,
        })
        .await
        .unwrap();
    assert!(contexts.manifest().entries.is_empty());

    // Second delete: the agent is gone → NotFound.
    let err = delete
        .execute(DeleteAgentInput {
            project: project(),
            agent_id: a.id,
        })
        .await
        .unwrap_err();
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

// ---------------------------------------------------------------------------
// LaunchAgent
// ---------------------------------------------------------------------------

/// Everything a launch test needs to drive `LaunchAgent` and assert over the
/// fakes: the use case, the seeded agent, the recording fs/pty, the event spy,
/// the session registry and the shared ordering trace.
type LaunchFixture = (
    LaunchAgent,
    Agent,
    FakeFs,
    FakePty,
    SpyBus,
    Arc<TerminalSessions>,
    Trace,
    Arc<Mutex<Option<SessionPlan>>>,
);

/// Wires a LaunchAgent over fakes for a given injection strategy/plan.
fn launch_fixture(
    injection: ContextInjection,
    plan: Option<ContextInjectionPlan>,
) -> LaunchFixture {
    launch_fixture_with_profile(profile(pid(9), injection), plan)
}

/// Like [`launch_fixture`] but takes a fully-built profile (so session-strategy
/// variants can be exercised). The seeded agent uses the profile's id.
fn launch_fixture_with_profile(
    profile: AgentProfile,
    plan: Option<ContextInjectionPlan>,
) -> LaunchFixture {
    launch_fixture_with_profile_and_recall(profile, plan, FakeRecall::default())
}

/// Like [`launch_fixture_with_profile`] but also takes the [`MemoryRecall`] fake,
/// so the memory-injection feature can be exercised. The default callers pass an
/// empty [`FakeRecall`] ⇒ no memory section ⇒ behaviour unchanged.
fn launch_fixture_with_profile_and_recall(
    profile: AgentProfile,
    plan: Option<ContextInjectionPlan>,
    recall: FakeRecall,
) -> LaunchFixture {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
    launch_fixture_with_profile_agent_and_recall(profile, agent, plan, recall)
}

fn launch_fixture_with_profile_agent_and_recall(
    profile: AgentProfile,
    agent: Agent,
    plan: Option<ContextInjectionPlan>,
    recall: FakeRecall,
) -> LaunchFixture {
    let contexts = FakeContexts::with_agent(&agent, "# ctx body");
    let profiles = FakeProfiles::new(vec![profile]);
    let tr = trace();
    let runtime = FakeRuntime::new(Arc::clone(&tr), plan);
    let session_probe = runtime.session_probe();
    let fs = FakeFs::new(Arc::clone(&tr));
    let pty = FakePty::new(Arc::clone(&tr), sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let bus = SpyBus::default();
    let launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(runtime),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills::default()),
        Arc::clone(&sessions),
        Arc::new(bus.clone()),
        Arc::new(SeqIds::new()),
        Arc::new(recall),
        None,
    );
    (launch, agent, fs, pty, bus, sessions, tr, session_probe)
}

fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
    LaunchAgentInput {
        project: project(),
        agent_id,
        rows: 24,
        cols: 80,
        node_id: None,
        conversation_id: None,
        mcp_runtime: None,
        allow_structured_alongside_pty: false,
    }
}

#[tokio::test]
async fn launch_orders_prepare_then_injection_then_spawn() {
    // conventionFile strategy → an fs.write must happen between prepare and spawn.
    let (launch, agent, fs, pty, bus, sessions, tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    let out = launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    // Ordering contract (lot LP3-3): prepare → injection (convention file) → spawn.
    // The permission projection no longer runs here — no projector registry is wired
    // in this fixture (`LaunchAgent::new` without `with_permission_projectors`), so no
    // extra `fs.write` precedes prepare. With a registry, the projection write would
    // appear *after* the injection write and before spawn (see `apply_permission_projection`).
    assert_eq!(
        *tr.lock().unwrap(),
        vec![
            "prepare".to_owned(),
            "fs.write".to_owned(),
            "spawn".to_owned()
        ],
        "prepare → injection → spawn"
    );

    // The conventionFile was written inside the agent's isolated run directory
    // (`.ideai/run/<agent-id>/CLAUDE.md`) — NOT at the project root. Its content
    // is the *composed* document: an absolute project-root header followed by the
    // agent persona `.md`.
    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    let writes = fs.context_writes();
    assert_eq!(writes.len(), 1);
    assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md"));
    let written = String::from_utf8(writes[0].1.clone()).unwrap();
    assert!(
        written.contains("/home/me/proj"),
        "convention file must carry the absolute project root, got: {written}"
    );
    assert!(
        written.contains("# ctx body"),
        "convention file must carry the agent persona, got: {written}"
    );

    // Lot LP3-3: the Claude permission seed is no longer written unconditionally
    // here. It is now produced by the Claude permission projector and written only
    // when a projector registry is wired (`with_permission_projectors`) — absent in
    // this fixture — so no seed is written. (Projection coverage lives in the infra
    // projector tests (LP3-2) and the LP3-3 projection wiring tests, QA.)
    assert!(
        fs.seed_writes().is_empty(),
        "no projector registry wired ⇒ no permission seed written"
    );

    // The run directory was created before spawn.
    assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]);

    // Spawn happened at the isolated run dir with the profile command.
    let spawns = pty.spawns();
    assert_eq!(spawns.len(), 1);
    assert_eq!(spawns[0].command, "claude");
    assert_eq!(spawns[0].cwd.as_str(), run_dir);

    // The session adopts the PTY id, is Running, and is registered as an agent.
    assert_eq!(out.session.id, sid(777));
    assert!(matches!(
        out.session.kind,
        domain::SessionKind::Agent { agent_id } if agent_id == agent.id
    ));
    assert!(sessions.session(&sid(777)).is_some());

    // AgentLaunched announced.
    assert_eq!(
        bus.events(),
        vec![DomainEvent::AgentLaunched {
            agent_id: agent.id,
            session_id: sid(777),
        }]
    );
}

#[tokio::test]
async fn structured_profile_with_factory_routes_to_structured_session_without_pty_spawn() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_structured_adapter(StructuredAdapter::Claude);
    let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
        profile,
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
    let factory_port: Arc<dyn AgentSessionFactory> = Arc::new(factory.clone());
    let structured = Arc::new(StructuredSessions::new());
    let launch = launch
        .with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
        .with_structured(factory_port, Arc::clone(&structured));

    let out = launch.execute(launch_input(agent.id)).await.unwrap();

    assert_eq!(factory.starts(), vec![pid(9)]);
    assert!(
        pty.spawns().is_empty(),
        "structured route must not spawn PTY"
    );
    assert!(out.structured.is_some());
    assert!(structured.session(&sid(888)).is_some());
    assert_eq!(
        *tr.lock().unwrap(),
        vec![
            "prepare".to_owned(),
            "fs.write".to_owned(),
            "structured.start".to_owned()
        ]
    );
}

#[tokio::test]
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_structured_adapter(StructuredAdapter::Codex)
    .with_model_reasoning_effort("low");
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
        .with_effort(Some(EffortSelection::Preset("high".to_owned())));
    let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) =
        launch_fixture_with_profile_agent_and_recall(
            profile,
            agent,
            Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
            FakeRecall::default(),
        );
    let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
    let structured = Arc::new(StructuredSessions::new());
    let launch = launch
        .with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
        .with_structured(Arc::new(factory.clone()), structured);

    launch.execute(launch_input(agent.id)).await.unwrap();

    assert!(pty.spawns().is_empty());
    assert_eq!(factory.efforts(), vec![Some("high".to_owned())]);
}

#[tokio::test]
async fn launch_agent_structured_falls_back_to_profile_default_when_agent_has_no_override() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_structured_adapter(StructuredAdapter::Codex)
    .with_model_reasoning_effort("low");
    let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
        profile,
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
    let structured = Arc::new(StructuredSessions::new());
    let launch = launch
        .with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
        .with_structured(Arc::new(factory.clone()), structured);

    launch.execute(launch_input(agent.id)).await.unwrap();

    assert!(pty.spawns().is_empty());
    assert_eq!(factory.efforts(), vec![Some("low".to_owned())]);
}

#[tokio::test]
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_structured_adapter(StructuredAdapter::Claude);
    let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile,
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    let launch = launch.with_structured_routing_mode(StructuredRoutingMode::RequireStructured);

    let err = launch.execute(launch_input(agent.id)).await.unwrap_err();

    assert!(err
        .to_string()
        .contains("structured profile requires structured session factory"));
    assert!(
        pty.spawns().is_empty(),
        "miswired structured route must not spawn PTY"
    );
}

#[tokio::test]
async fn structured_profile_without_factory_human_fallback_spawns_pty() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_structured_adapter(StructuredAdapter::Claude);
    let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile,
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    let launch = launch.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback);

    let out = launch.execute(launch_input(agent.id)).await.unwrap();

    assert!(out.structured.is_none());
    assert_eq!(pty.spawns().len(), 1, "human fallback keeps native CLI PTY");
}

#[tokio::test]
async fn non_structured_profile_uses_pty_even_when_structured_is_required() {
    let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    let launch = launch.with_structured_routing_mode(StructuredRoutingMode::RequireStructured);

    let out = launch.execute(launch_input(agent.id)).await.unwrap();

    assert!(out.structured.is_none());
    assert_eq!(pty.spawns().len(), 1);
}

#[tokio::test]
async fn launch_injects_shared_project_context_from_ideai_context_md() {
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    fs.set_project_context("# Shared project\n\nUse pnpm.");

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    let writes = fs.context_writes();
    assert_eq!(writes.len(), 1);
    let doc = String::from_utf8(writes[0].1.clone()).unwrap();
    assert!(doc.contains("# Contexte projet"));
    assert!(doc.contains("Use pnpm."));
    let project_context_at = doc.find("# Contexte projet").unwrap();
    let persona_at = doc.find("# ctx body").unwrap();
    assert!(
        project_context_at < persona_at,
        "project context must precede the agent persona"
    );
}

/// Inserts a live agent session into the registry, simulating an already-running
/// agent pinned on `node`. Returns the session id.
fn seed_live_agent_session(
    sessions: &TerminalSessions,
    agent_id: AgentId,
    node: domain::NodeId,
    session_id: SessionId,
) {
    let size = PtySize::new(24, 80).unwrap();
    let mut session = domain::TerminalSession::starting(
        session_id,
        node,
        ProjectPath::new("/home/me/proj/.ideai/run/x").unwrap(),
        domain::SessionKind::Agent { agent_id },
        size,
    );
    session.status = domain::SessionStatus::Running;
    sessions.insert_in_project(project().id, PtyHandle { session_id }, session);
}

fn nid(n: u128) -> domain::NodeId {
    domain::NodeId::from_uuid(Uuid::from_u128(n))
}

/// **Singleton invariant (R0a, décision « 1 agent = 1 employé »)**: a *fresh*
/// launch (no `conversation_id`) targeting **another** cell of an agent already
/// live in cell A is a genuine second launch ⇒ it is **refused** with
/// `AppError::AgentAlreadyRunning { node_id: A, agent_id }`. The session is NOT
/// silently moved, the PTY is never spawned, and the registry is unchanged.
#[tokio::test]
async fn launch_new_in_other_cell_refuses_when_agent_live_elsewhere() {
    let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    // The agent is already live in cell A.
    let host = nid(1);
    seed_live_agent_session(&sessions, agent.id, host, sid(42));
    let before = sessions.len();

    // Fresh launch (conversation_id: None) of the same running agent in a
    // *different* cell B ⇒ must be refused, not silently rebound.
    let mut input = launch_input(agent.id);
    let target = nid(2);
    input.node_id = Some(target);
    let err = launch
        .execute(input)
        .await
        .expect_err("fresh second launch elsewhere is refused");

    match err {
        application::AppError::AgentAlreadyRunning { agent_id, node_id } => {
            assert_eq!(agent_id, agent.id, "reports the live agent");
            assert_eq!(node_id, host, "reports the live HOST node A, not target B");
        }
        other => panic!("expected AgentAlreadyRunning, got {other:?}"),
    }

    // No silent move, no respawn, registry untouched.
    assert_eq!(
        sessions.node_for_agent_in_project(project().id, &agent.id),
        Some(host),
        "session stays pinned on its host node"
    );
    assert_eq!(sessions.len(), before, "registry size is unchanged");
    assert!(pty.spawns().is_empty(), "no PTY spawn on a refused launch");
}

/// A launch with an unspecified node (`node_id: None`) for an already-live agent
/// is a background/idempotent no-op: return the existing session without
/// respawning or changing its current host.
#[tokio::test]
async fn launch_existing_agent_with_no_node_is_background_noop() {
    let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));

    // node_id defaults to None in launch_input.
    let out = launch
        .execute(launch_input(agent.id))
        .await
        .expect("background relaunch is idempotent");
    assert_eq!(out.session.id, sid(42));
    assert_eq!(out.session.node_id, nid(1));
    assert!(pty.spawns().is_empty());
}

/// Idempotence on the SAME node: a double-launch of the very same cell returns
/// the already-registered session without respawning, and assigns no new
/// conversation id.
#[tokio::test]
async fn launch_same_node_is_idempotent_no_respawn() {
    let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    let host = nid(7);
    seed_live_agent_session(&sessions, agent.id, host, sid(42));
    let before = sessions.len();

    let mut input = launch_input(agent.id);
    input.node_id = Some(host);
    let out = launch.execute(input).await.expect("idempotent launch");

    assert_eq!(out.session.id, sid(42), "returns the existing session");
    assert!(
        out.assigned_conversation_id.is_none(),
        "nothing new assigned"
    );
    assert_eq!(sessions.len(), before, "no new session registered");
    assert!(pty.spawns().is_empty(), "no respawn on same-node relaunch");
}

/// **Legitimate explicit reattach (R0a)**: launching an agent that is live in
/// cell A onto a *different* cell B but carrying a `conversation_id` is an
/// explicit view reattach (the cell knows the agent was running) ⇒ rebind, no
/// error, no respawn, same session id.
#[tokio::test]
async fn launch_other_cell_with_conversation_id_rebinds_no_respawn() {
    let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    let host = nid(1);
    seed_live_agent_session(&sessions, agent.id, host, sid(42));
    let before = sessions.len();

    // Different cell B, but an explicit reattach signal (conversation_id present).
    let mut input = launch_input(agent.id);
    let target = nid(2);
    input.node_id = Some(target);
    input.conversation_id = Some("conv-live".to_owned());
    let out = launch
        .execute(input)
        .await
        .expect("explicit reattach succeeds");

    assert_eq!(out.session.id, sid(42), "returns the existing session");
    assert_eq!(out.session.node_id, target, "view rebound to target cell");
    assert_eq!(
        sessions.node_for_agent_in_project(project().id, &agent.id),
        Some(target)
    );
    assert_eq!(sessions.len(), before, "registry size is unchanged");
    assert!(pty.spawns().is_empty(), "no PTY spawn on explicit reattach");
}

/// After the live session is removed (cell closed / agent exited), a fresh launch
/// succeeds — the guard only blocks while the agent is actually live.
#[tokio::test]
async fn launch_succeeds_after_session_removed() {
    let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    // Live, then removed (close/exit).
    seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));
    sessions.remove(&sid(42));
    assert!(sessions
        .session_for_agent_in_project(project().id, &agent.id)
        .is_none());

    let mut input = launch_input(agent.id);
    input.node_id = Some(nid(2));
    let out = launch.execute(input).await.expect("relaunch must succeed");

    assert_eq!(out.session.id, sid(777), "spawned a fresh PTY session");
    assert_eq!(
        pty.spawns().len(),
        1,
        "exactly one spawn after the agent died"
    );
}

/// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same*
/// profile on the *same* project root must launch into two **distinct** cwd —
/// each its own `.ideai/run/<agent-id>/` — and each writes its convention file
/// inside its own run dir, never colliding at the project root.
#[tokio::test]
async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
    let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
    let plan = Some(ContextInjectionPlan::File {
        target: "CLAUDE.md".to_owned(),
    });

    // Two agents, same profile (pid(9)), same project root.
    let agent_a = scratch_agent(aid(1), "Alpha", "agents/alpha.md", pid(9));
    let agent_b = scratch_agent(aid(2), "Bravo", "agents/bravo.md", pid(9));

    let contexts = FakeContexts::with_agent(&agent_a, "# alpha");
    {
        let mut inner = contexts.0.lock().unwrap();
        inner
            .manifest
            .entries
            .push(ManifestEntry::from_agent(&agent_b));
        inner
            .contents
            .insert(agent_b.context_path.clone(), "# bravo".to_owned());
    }
    let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);

    let tr = trace();
    let fs = FakeFs::new(Arc::clone(&tr));
    let pty = FakePty::new(Arc::clone(&tr), sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills::default()),
        Arc::clone(&sessions),
        Arc::new(SpyBus::default()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall::default()),
        None,
    );

    launch.execute(launch_input(agent_a.id)).await.unwrap();
    launch.execute(launch_input(agent_b.id)).await.unwrap();

    let dir_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id);
    let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id);
    assert_ne!(
        dir_a, dir_b,
        "the two agents must map to different run dirs"
    );

    // Two distinct run dirs were created (ignoring each seed's `.claude` subdir).
    assert_eq!(fs.created_run_dirs(), vec![dir_a.clone(), dir_b.clone()]);

    // Two spawns at two distinct cwd — the core anti-collision guarantee.
    let spawns = pty.spawns();
    assert_eq!(spawns.len(), 2);
    assert_eq!(spawns[0].cwd.as_str(), dir_a);
    assert_eq!(spawns[1].cwd.as_str(), dir_b);
    assert_ne!(spawns[0].cwd, spawns[1].cwd);

    // Two convention files, each inside its own run dir (no shared root file).
    let writes = fs.context_writes();
    assert_eq!(writes.len(), 2);
    assert_eq!(writes[0].0, format!("{dir_a}/CLAUDE.md"));
    assert_eq!(writes[1].0, format!("{dir_b}/CLAUDE.md"));
    assert_ne!(writes[0].0, writes[1].0);
    // Neither writes to the project root.
    assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md"));

    // Each convention file carries its own persona.
    assert!(String::from_utf8(writes[0].1.clone())
        .unwrap()
        .contains("# alpha"));
    assert!(String::from_utf8(writes[1].1.clone())
        .unwrap()
        .contains("# bravo"));
}

#[tokio::test]
async fn launch_conventionfile_injects_assigned_skills_in_order() {
    // An agent with two assigned global skills; the generated convention file must
    // carry both skill bodies, after the persona, in assignment order (§14.2).
    let skill_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n));
    let skill = |n: u128, name: &str, body: &str| {
        Skill::new(
            skill_id(n),
            name,
            MarkdownDoc::new(body),
            SkillScope::Global,
        )
        .unwrap()
    };

    let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    agent.assign_skill(SkillRef::new(skill_id(1), SkillScope::Global));
    agent.assign_skill(SkillRef::new(skill_id(2), SkillScope::Global));

    let contexts = FakeContexts::with_agent(&agent, "# persona");
    let profiles = FakeProfiles::new(vec![profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )]);
    let tr = trace();
    let fs = FakeFs::new(Arc::clone(&tr));
    let pty = FakePty::new(Arc::clone(&tr), sid(777));
    let skills = FakeSkills::with(vec![
        skill(1, "refactor", "REFAC_BODY"),
        skill(2, "review", "REVIEW_BODY"),
    ]);
    let launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(FakeRuntime::new(
            Arc::clone(&tr),
            Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
        )),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(skills),
        Arc::new(TerminalSessions::new()),
        Arc::new(SpyBus::default()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall::default()),
        None,
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    let writes = fs.context_writes();
    assert_eq!(writes.len(), 1);
    let doc = String::from_utf8(writes[0].1.clone()).unwrap();
    assert!(doc.contains("# persona"), "persona present: {doc}");
    assert!(doc.contains("REFAC_BODY"), "first skill body present");
    assert!(doc.contains("REVIEW_BODY"), "second skill body present");
    // Deterministic order: persona before skills, skill 1 before skill 2.
    let persona_at = doc.find("# persona").unwrap();
    let refac_at = doc.find("REFAC_BODY").unwrap();
    let review_at = doc.find("REVIEW_BODY").unwrap();
    assert!(
        persona_at < refac_at && refac_at < review_at,
        "ordering: {doc}"
    );
}

#[tokio::test]
async fn launch_conventionfile_injects_project_memory_in_order() {
    // A non-empty recall ⇒ the generated convention file must carry a
    // `# Mémoire projet` section with one line per entry, in the recalled order
    // and the exact `- [Title](slug.md) — hook (type)` format (§14.5.4).
    let recall = FakeRecall::returning(vec![
        mem_entry(
            "git-optional",
            "Git optionnel",
            "git reste un simple tool",
            MemoryType::Project,
        ),
        mem_entry(
            "perm-archi",
            "Permissions",
            "sandbox OS + résumé injecté",
            MemoryType::Reference,
        ),
    ]);
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
        launch_fixture_with_profile_and_recall(
            profile(
                pid(9),
                ContextInjection::convention_file("CLAUDE.md").unwrap(),
            ),
            Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
            recall,
        );

    launch.execute(launch_input(agent.id)).await.unwrap();

    let writes = fs.context_writes();
    assert_eq!(writes.len(), 1);
    let doc = String::from_utf8(writes[0].1.clone()).unwrap();

    assert!(
        doc.contains("# Mémoire projet"),
        "memory section present: {doc}"
    );
    assert!(
        doc.contains(
            "- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)"
        ),
        "first memory line exact format: {doc}"
    );
    assert!(
        doc.contains(
            "- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"
        ),
        "second memory line exact format: {doc}"
    );
    // Recalled order preserved; section after the persona.
    let persona_at = doc.find("# ctx body").unwrap();
    let section_at = doc.find("# Mémoire projet").unwrap();
    let first_at = doc.find("[Git optionnel]").unwrap();
    let second_at = doc.find("[Permissions]").unwrap();
    assert!(persona_at < section_at, "memory after persona: {doc}");
    assert!(
        first_at < second_at,
        "entries kept in recalled order: {doc}"
    );
}

#[tokio::test]
async fn launch_conventionfile_without_memory_omits_section() {
    // The default empty recall ⇒ no `# Mémoire projet` section (behaviour unchanged).
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
    assert!(
        !doc.contains("# Mémoire projet"),
        "no memory ⇒ no section: {doc}"
    );
}

#[tokio::test]
async fn launch_recall_error_degrades_to_no_section_without_failing() {
    // A failing recall must NOT fail the launch (best-effort, §14.5.4): the launch
    // succeeds and the convention file simply carries no memory section.
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
        launch_fixture_with_profile_and_recall(
            profile(
                pid(9),
                ContextInjection::convention_file("CLAUDE.md").unwrap(),
            ),
            Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
            FakeRecall::failing(),
        );

    // Launch still succeeds despite the recall error.
    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch must succeed");

    let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
    assert!(
        !doc.contains("# Mémoire projet"),
        "recall error ⇒ no section, launch unaffected: {doc}"
    );
}

#[tokio::test]
async fn launch_env_strategy_injects_no_memory_section() {
    // For the `env` strategy IdeA writes no convention file, so no memory section is
    // injected — aligned with how skills are only injected for `conventionFile`.
    let recall = FakeRecall::returning(vec![mem_entry("note", "Note", "a hook", MemoryType::User)]);
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
        launch_fixture_with_profile_and_recall(
            profile(pid(9), ContextInjection::env("IDEA_CONTEXT").unwrap()),
            Some(ContextInjectionPlan::Env {
                var: "IDEA_CONTEXT".to_owned(),
            }),
            recall,
        );

    launch.execute(launch_input(agent.id)).await.unwrap();

    // The env strategy writes no convention file at all (only the run-dir seed).
    assert!(
        fs.context_writes().is_empty(),
        "env strategy must not write a convention file"
    );
}

#[tokio::test]
async fn launch_skips_dangling_skill_ref_without_failing() {
    // The agent references a skill that no longer exists in the store: launch must
    // still succeed and simply omit it (no Skills section for a sole dangling ref).
    let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    agent.assign_skill(SkillRef::new(
        SkillId::from_uuid(Uuid::from_u128(99)),
        SkillScope::Global,
    ));

    let contexts = FakeContexts::with_agent(&agent, "# persona");
    let profiles = FakeProfiles::new(vec![profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )]);
    let tr = trace();
    let fs = FakeFs::new(Arc::clone(&tr));
    let pty = FakePty::new(Arc::clone(&tr), sid(777));
    let launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(FakeRuntime::new(
            Arc::clone(&tr),
            Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
        )),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills::default()), // empty store ⇒ the ref is dangling
        Arc::new(TerminalSessions::new()),
        Arc::new(SpyBus::default()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall::default()),
        None,
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch must succeed");
    let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
    assert!(
        !doc.contains("# Skills"),
        "no Skills section for a dangling ref: {doc}"
    );
}

#[tokio::test]
async fn launch_non_claude_convention_does_not_seed_permissions() {
    // A CLI whose convention file is NOT CLAUDE.md (e.g. Codex → AGENTS.md) must
    // get its convention file but NO Claude permission seed (Bug #5 is per-CLI).
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("AGENTS.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    // The convention file was written, but no permission seed.
    assert_eq!(fs.context_writes().len(), 1);
    assert!(
        fs.seed_writes().is_empty(),
        "no Claude seed for a non-Claude CLI"
    );
    assert!(
        !fs.created_dirs().iter().any(|p| p.ends_with("/.claude")),
        "no .claude dir created for a non-Claude CLI"
    );
}

#[tokio::test]
async fn launch_stdin_strategy_pipes_context_after_spawn() {
    let (launch, agent, fs, pty, _bus, _sessions, tr, _session) =
        launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));

    launch.execute(launch_input(agent.id)).await.unwrap();

    // No file written for stdin; content is piped to the PTY post-spawn.
    assert!(fs.writes().is_empty(), "stdin must not write a file");
    assert_eq!(
        *tr.lock().unwrap(),
        vec!["prepare".to_owned(), "spawn".to_owned()]
    );
    let writes = pty.writes();
    assert_eq!(writes.len(), 1);
    assert_eq!(writes[0].0, sid(777));
    assert_eq!(writes[0].1, b"# ctx body");
}

#[tokio::test]
async fn launch_unknown_agent_is_not_found() {
    let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) =
        launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
    let err = launch.execute(launch_input(aid(404))).await.unwrap_err();
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
    assert!(pty.spawns().is_empty(), "no spawn for unknown agent");
}

#[tokio::test]
async fn launch_unknown_profile_is_not_found() {
    // The agent references pid(9) but the store only knows pid(1).
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&agent, "ctx");
    let profiles = FakeProfiles::new(vec![profile(pid(1), ContextInjection::stdin())]);
    let tr = trace();
    let pty = FakePty::new(Arc::clone(&tr), sid(777));
    let launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(FakeRuntime::new(
            Arc::clone(&tr),
            Some(ContextInjectionPlan::Stdin),
        )),
        Arc::new(FakeFs::new(Arc::clone(&tr))),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills::default()),
        Arc::new(TerminalSessions::new()),
        Arc::new(SpyBus::default()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall::default()),
        None,
    );

    let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
    assert!(pty.spawns().is_empty(), "no spawn when profile unresolved");
}

// ---------------------------------------------------------------------------
// LaunchAgent — MCP config injection (M1, cadrage v3 Décision 3)
// ---------------------------------------------------------------------------

/// Builds a CLAUDE.md-convention profile carrying an [`McpCapability`], so the
/// MCP injection path (`apply_mcp_config`) is exercised during a launch.
fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile {
    profile(id, ContextInjection::convention_file("CLAUDE.md").unwrap())
        .with_mcp(McpCapability::new(config, McpTransport::Stdio))
}

fn opencode_profile(id: ProfileId) -> AgentProfile {
    profile(id, ContextInjection::convention_file("AGENTS.md").unwrap())
        .with_structured_adapter(StructuredAdapter::OpenCode)
        .with_opencode(
            OpenCodeConfig::new(
                "http://localhost:8080/v1",
                Some("sk-no-key".to_owned()),
                "qwen3-coder-30b",
                None,
                None,
            )
            .unwrap(),
        )
        .with_mcp(McpCapability::new(
            McpConfigStrategy::open_code_config("opencode.json").unwrap(),
            McpTransport::Stdio,
        ))
}

/// Returns the writes whose path ends with the given suffix (e.g. `/.mcp.json`).
fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec<u8>)> {
    fs.writes()
        .into_iter()
        .filter(|(p, _)| p.ends_with(suffix))
        .collect()
}

#[tokio::test]
async fn launch_without_mcp_writes_no_mcp_config_and_leaves_spec_untouched() {
    // Test 1 — profile.mcp = None ⇒ no MCP file written, spec.args/env unchanged.
    // The profile has empty args and the runtime adds no env, so a clean launch
    // must leave the spawned spec's args/env exactly empty (no MCP flag/env).
    let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    // No MCP-style config file anywhere.
    assert!(
        writes_ending_with(&fs, "/.mcp.json").is_empty(),
        "no MCP config file when profile.mcp is None"
    );
    // Exactly the convention file write (+ the Claude seed), nothing MCP-related.
    assert_eq!(
        fs.context_writes().len(),
        1,
        "only the convention file is written"
    );

    // Spec args/env carry nothing MCP-related (here: empty).
    let spawns = pty.spawns();
    assert_eq!(spawns.len(), 1);
    assert!(spawns[0].args.is_empty(), "no MCP flag pushed to args");
    assert!(spawns[0].env.is_empty(), "no MCP var pushed to env");
}

#[tokio::test]
async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() {
    // Test 2 — ConfigFile { target: ".mcp.json" } ⇒ a file is written at
    // <run_dir>/.mcp.json with a non-empty, coherent MCP server declaration.
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    let mcp = writes_ending_with(&fs, "/.mcp.json");
    assert_eq!(mcp.len(), 1, "exactly one MCP config file written");
    assert_eq!(
        mcp[0].0,
        format!("{run_dir}/.mcp.json"),
        "written at run dir"
    );

    let body = String::from_utf8(mcp[0].1.clone()).unwrap();
    assert!(!body.trim().is_empty(), "MCP declaration is non-empty");
    // Coherent MCP server declaration (an `mcpServers`/`idea` server entry).
    assert!(
        body.contains("mcpServers") && body.contains("idea"),
        "MCP declaration must declare the IdeA MCP server, got: {body}"
    );
    // It is valid JSON.
    let _: serde_json::Value = serde_json::from_str(&body).expect("MCP declaration is valid JSON");
}

#[tokio::test]
async fn launch_mcp_config_file_is_non_clobbering() {
    // Test 3 — if <run_dir>/.mcp.json already exists, the launch must NOT overwrite
    // it: no write is recorded against that path.
    let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap());
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile,
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    // Pre-existing MCP config file on disk.
    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    fs.mark_existing(&format!("{run_dir}/.mcp.json"));

    launch.execute(launch_input(agent.id)).await.unwrap();

    assert!(
        writes_ending_with(&fs, "/.mcp.json").is_empty(),
        "an existing MCP config file must not be clobbered"
    );
}

#[tokio::test]
async fn launch_mcp_config_file_write_failure_is_best_effort() {
    // Test 4 — a write failure on the MCP config file must NOT fail the launch.
    let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap());
    let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile,
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    // Force the MCP config write to fail.
    fs.fail_write_suffix("/.mcp.json");

    // The launch still succeeds and spawns the CLI.
    let out = launch
        .execute(launch_input(agent.id))
        .await
        .expect("MCP write failure must not fail the launch");
    assert!(out.structured.is_none());
    assert_eq!(pty.spawns().len(), 1, "the CLI is still spawned");
}

#[tokio::test]
async fn launch_opencode_writes_isolated_config_and_env() {
    let profile = opencode_profile(pid(9));
    let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    let opencode = writes_ending_with(&fs, "/opencode.json");
    assert_eq!(opencode.len(), 1, "one OpenCode config is written");
    assert_eq!(opencode[0].0, format!("{run_dir}/opencode.json"));
    let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
    assert_eq!(body["model"], "llamacpp/qwen3-coder-30b");
    assert_eq!(
        body["provider"]["llamacpp"]["options"]["baseURL"],
        "http://localhost:8080/v1"
    );
    assert_eq!(
        body["provider"]["llamacpp"]["options"]["apiKey"],
        "sk-no-key"
    );
    assert_eq!(
        body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["name"],
        "llamacpp/qwen3-coder-30b"
    );
    assert_eq!(
        body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["tool_call"],
        true
    );
    assert_eq!(
        body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["reasoning"],
        true
    );
    assert_eq!(
        body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["attachment"],
        false
    );
    assert_eq!(
        body["disabled_providers"],
        serde_json::json!(["anthropic", "openai", "gemini", "ollama"])
    );
    assert_eq!(body["mcp"]["idea"]["type"], "local");
    assert_eq!(body["mcp"]["idea"]["command"][0], "idea");

    let spawn = pty
        .spawns()
        .pop()
        .expect("spawned via PTY fallback in this fixture");
    let env: std::collections::HashMap<_, _> = spawn.env.into_iter().collect();
    assert_eq!(
        env.get("OPENCODE_CONFIG").map(String::as_str),
        Some(format!("{run_dir}/opencode.json").as_str())
    );
    assert_eq!(
        env.get("HOME").map(String::as_str),
        Some(format!("{run_dir}/.opencode").as_str())
    );
    assert_eq!(
        env.get("XDG_CONFIG_HOME").map(String::as_str),
        Some(format!("{run_dir}/.opencode/config").as_str())
    );
    assert_eq!(
        env.get("XDG_DATA_HOME").map(String::as_str),
        Some(format!("{run_dir}/.opencode/data").as_str())
    );
    assert_eq!(
        env.get("XDG_CACHE_HOME").map(String::as_str),
        Some(format!("{run_dir}/.opencode/cache").as_str())
    );
    assert_eq!(
        env.get("OPENCODE_DISABLE_AUTOUPDATE").map(String::as_str),
        Some("1")
    );
}

#[tokio::test]
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
    // Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed
    // by the run-dir config path.
    let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    let spawns = pty.spawns();
    assert_eq!(spawns.len(), 1);
    let args = &spawns[0].args;
    let pos = args
        .iter()
        .position(|a| a == "--mcp-config")
        .expect("the MCP flag must be present in args");
    assert_eq!(
        args.get(pos + 1),
        Some(&run_dir),
        "the MCP flag is followed by the run-dir config path"
    );
}

#[tokio::test]
async fn launch_mcp_env_appends_var_and_path_to_env() {
    // Test 6 — Env { var: "IDEA_MCP" } ⇒ spec.env carries (IDEA_MCP, <run_dir>).
    let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    let spawns = pty.spawns();
    assert_eq!(spawns.len(), 1);
    assert!(
        spawns[0]
            .env
            .iter()
            .any(|(k, v)| k == "IDEA_MCP" && v == &run_dir),
        "spec.env must carry (IDEA_MCP, run_dir), got: {:?}",
        spawns[0].env
    );
}

// ---------------------------------------------------------------------------
// LaunchAgent — MCP runtime declaration (M5d, cadrage v5 §2)
// ---------------------------------------------------------------------------

/// A `LaunchAgentInput` carrying an injected [`McpRuntime`], otherwise identical
/// to [`launch_input`]. Lets the M5d tests drive the real declaration path while
/// reusing the M1 harness (`launch_fixture_with_profile`, `FakeFs` write trace).
fn launch_input_with_runtime(
    agent_id: AgentId,
    runtime: application::McpRuntime,
) -> LaunchAgentInput {
    LaunchAgentInput {
        mcp_runtime: Some(runtime),
        ..launch_input(agent_id)
    }
}

/// Reads the single `.mcp.json` written by a launch, parsing it as JSON. Panics
/// with a clear message if zero or several were written, so an unexpected write
/// shape is surfaced rather than silently picked.
fn read_single_mcp_json(fs: &FakeFs) -> serde_json::Value {
    let mcp = writes_ending_with(fs, "/.mcp.json");
    assert_eq!(mcp.len(), 1, "exactly one .mcp.json must be written");
    let body = String::from_utf8(mcp[0].1.clone()).unwrap();
    serde_json::from_str(&body)
        .unwrap_or_else(|e| panic!("written .mcp.json must be valid JSON ({e}): {body}"))
}

#[tokio::test]
async fn launch_mcp_runtime_writes_real_declaration_with_ordered_args() {
    // M5d-1 — ConfigFile{".mcp.json"} + an injected McpRuntime ⇒ the written
    // declaration is the REAL one: command == exe, and args == the ordered
    // ["mcp-server","--endpoint",endpoint,"--project",project_id,"--requester",
    // requester]. Structure-asserted via parsed JSON (not a fragile string match).
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    let exe = "/abs/idea";
    let endpoint = "/run/idea-mcp/abc.sock";
    // project_id in the hyphen-free 32-hex `simple` form consumed by the M5c guard
    // (`as_uuid().simple()` — see the coherence note / M5e smoke e2e below).
    let project_id = "0123456789abcdef0123456789abcdef";
    let requester = "agent-7";
    let runtime = application::McpRuntime {
        exe: exe.to_owned(),
        endpoint: endpoint.to_owned(),
        project_id: project_id.to_owned(),
        requester: requester.to_owned(),
    };

    launch
        .execute(launch_input_with_runtime(agent.id, runtime))
        .await
        .unwrap();

    let doc = read_single_mcp_json(&fs);
    let server = &doc["mcpServers"]["idea"];
    assert_eq!(
        server["command"].as_str(),
        Some(exe),
        "command must be the injected exe, got: {doc}"
    );
    let args: Vec<&str> = server["args"]
        .as_array()
        .expect("args must be a JSON array")
        .iter()
        .map(|v| v.as_str().expect("each arg is a JSON string"))
        .collect();
    assert_eq!(
        args,
        vec![
            "mcp-server",
            "--endpoint",
            endpoint,
            "--project",
            project_id,
            "--requester",
            requester,
        ],
        "args must carry the ordered mcp-server flags, got: {args:?}"
    );
}

#[tokio::test]
async fn launch_mcp_runtime_special_chars_stay_valid_json() {
    // M5d-2 — an exe/endpoint with a space and a backslash ⇒ the .mcp.json stays
    // valid JSON (parse OK, asserted by read_single_mcp_json) and the values are
    // faithfully preserved (correct escaping, no corruption).
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    let exe = r"C:\Program Files\IdeA\idea.exe";
    let endpoint = r"/tmp/idea mcp/a b\c.sock";
    let runtime = application::McpRuntime {
        exe: exe.to_owned(),
        endpoint: endpoint.to_owned(),
        project_id: "0123456789abcdef0123456789abcdef".to_owned(),
        requester: "agent X".to_owned(),
    };

    launch
        .execute(launch_input_with_runtime(agent.id, runtime))
        .await
        .unwrap();

    // Parsing already proves the JSON is valid despite the special chars.
    let doc = read_single_mcp_json(&fs);
    let server = &doc["mcpServers"]["idea"];
    assert_eq!(
        server["command"].as_str(),
        Some(exe),
        "exe with backslashes/spaces must round-trip faithfully"
    );
    let args: Vec<&str> = server["args"]
        .as_array()
        .unwrap()
        .iter()
        .map(|v| v.as_str().unwrap())
        .collect();
    assert_eq!(
        args.get(2).copied(),
        Some(endpoint),
        "endpoint with a space and a backslash must round-trip faithfully, got: {args:?}"
    );
}

#[tokio::test]
async fn launch_mcp_runtime_none_writes_minimal_declaration() {
    // M5d-3 — mcp_runtime = None + an MCP profile ⇒ the minimal declaration is
    // written: command == "idea", args == ["mcp-server"]. Still valid JSON.
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    // launch_input has mcp_runtime: None.
    launch.execute(launch_input(agent.id)).await.unwrap();

    let doc = read_single_mcp_json(&fs);
    let server = &doc["mcpServers"]["idea"];
    assert_eq!(
        server["command"].as_str(),
        Some("idea"),
        "the minimal declaration must use the `idea` command, got: {doc}"
    );
    let args: Vec<&str> = server["args"]
        .as_array()
        .expect("args must be a JSON array")
        .iter()
        .map(|v| v.as_str().unwrap())
        .collect();
    assert_eq!(
        args,
        vec!["mcp-server"],
        "the minimal declaration must carry only the mcp-server subcommand, got: {args:?}"
    );
}

#[tokio::test]
async fn launch_mcp_runtime_with_no_mcp_profile_writes_nothing() {
    // M5d-4 — a profile WITHOUT MCP ⇒ no .mcp.json is written, even though a
    // runtime is supplied (unchanged from M1: mcp == None short-circuits).
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );

    let runtime = application::McpRuntime {
        exe: "/abs/idea".to_owned(),
        endpoint: "/run/idea-mcp/abc.sock".to_owned(),
        project_id: "0123456789abcdef0123456789abcdef".to_owned(),
        requester: "agent-7".to_owned(),
    };

    launch
        .execute(launch_input_with_runtime(agent.id, runtime))
        .await
        .unwrap();

    assert!(
        writes_ending_with(&fs, "/.mcp.json").is_empty(),
        "no MCP config file when the profile carries no McpCapability"
    );
}

#[tokio::test]
async fn launch_mcp_runtime_clobbers_stale_config_with_fresh_declaration() {
    // M5d-5 — with a real injected runtime, a pre-existing .mcp.json MUST be
    // regenerated and CLOBBERED on every (re)launch: its `command` (the IdeA exe
    // path) and endpoint drift between runs (the AppImage internal mount path
    // changes at each remount/reboot), so a stale declaration would point the bridge
    // at a dead binary/socket. `.mcp.json` is IdeA-managed, so clobbering is safe.
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    // A stale file already on disk (e.g. written by a previous run pointing at a
    // now-dead AppImage mount).
    fs.mark_existing(&format!("{run_dir}/.mcp.json"));

    let exe = "/abs/idea";
    let endpoint = "/run/idea-mcp/abc.sock";
    let runtime = application::McpRuntime {
        exe: exe.to_owned(),
        endpoint: endpoint.to_owned(),
        project_id: "0123456789abcdef0123456789abcdef".to_owned(),
        requester: "agent-7".to_owned(),
    };

    launch
        .execute(launch_input_with_runtime(agent.id, runtime))
        .await
        .unwrap();

    // The fresh declaration is written despite the pre-existing file, and it carries
    // the current exe path / endpoint.
    let doc = read_single_mcp_json(&fs);
    let server = &doc["mcpServers"]["idea"];
    assert_eq!(
        server["command"].as_str(),
        Some(exe),
        "a stale .mcp.json must be clobbered with the fresh exe path, got: {doc}"
    );
    let args: Vec<&str> = server["args"]
        .as_array()
        .expect("args must be a JSON array")
        .iter()
        .map(|v| v.as_str().unwrap())
        .collect();
    assert_eq!(
        args.get(2).copied(),
        Some(endpoint),
        "the clobbered declaration must carry the fresh endpoint, got: {args:?}"
    );
}

#[tokio::test]
async fn launch_mcp_runtime_none_is_non_clobbering() {
    // M5d-5b — WITHOUT a runtime (orchestrator / hot-swap / tests) only the degraded
    // minimal declaration is available, so a pre-existing .mcp.json must NOT be
    // overwritten: we never replace a real declaration with the minimal one.
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
        Some(ContextInjectionPlan::File {
            target: "CLAUDE.md".to_owned(),
        }),
    );
    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    fs.mark_existing(&format!("{run_dir}/.mcp.json"));

    // launch_input has mcp_runtime: None.
    launch.execute(launch_input(agent.id)).await.unwrap();

    assert!(
        writes_ending_with(&fs, "/.mcp.json").is_empty(),
        "without a runtime, an existing .mcp.json must not be clobbered with the minimal decl"
    );
}

#[tokio::test]
async fn launch_mcp_runtime_remount_clobbers_with_run_specific_facts() {
    // M5d-5c (QA) — direct reproduction of the AppImage-remount bug: across two
    // independent app sessions (= two reboots, each with a *different* mount path
    // and a *different* loopback socket), each launch must clobber a pre-existing
    // stale `.mcp.json` with the facts of THAT run. This proves the file is rebuilt
    // per-launch from the live runtime — never frozen on a previous run's dead
    // binary/socket. Two fixtures are used on purpose: relaunching the *same* live
    // agent would short-circuit through the reattach guard (no respawn), so it
    // would not exercise a second `apply_mcp_config`.
    let launch_once = |exe: &'static str, endpoint: &'static str| async move {
        let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
            profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
            Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
        );
        let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
        // A stale declaration left by the previous boot is already on disk.
        fs.mark_existing(&format!("{run_dir}/.mcp.json"));

        let runtime = application::McpRuntime {
            exe: exe.to_owned(),
            endpoint: endpoint.to_owned(),
            project_id: "0123456789abcdef0123456789abcdef".to_owned(),
            requester: "agent-7".to_owned(),
        };
        launch
            .execute(launch_input_with_runtime(agent.id, runtime))
            .await
            .unwrap();

        let doc = read_single_mcp_json(&fs);
        let server = &doc["mcpServers"]["idea"];
        let command = server["command"].as_str().unwrap().to_owned();
        let args: Vec<String> = server["args"]
            .as_array()
            .expect("args must be a JSON array")
            .iter()
            .map(|v| v.as_str().unwrap().to_owned())
            .collect();
        (command, args)
    };

    // Boot #1: mount path A + socket A.
    let exe_a = "/tmp/.mount_IdeA_AAA/idea";
    let endpoint_a = "/run/idea-mcp/boot-a.sock";
    let (command_a, args_a) = launch_once(exe_a, endpoint_a).await;
    assert_eq!(command_a, exe_a, "boot #1 must carry mount path A");
    assert_eq!(args_a.get(2).map(String::as_str), Some(endpoint_a));

    // Boot #2: a *new* mount path + a *new* socket (the very drift that caused the
    // bug). The declaration must follow the live facts, not the previous boot's.
    let exe_b = "/tmp/.mount_IdeA_BBB/idea";
    let endpoint_b = "/run/idea-mcp/boot-b.sock";
    let (command_b, args_b) = launch_once(exe_b, endpoint_b).await;
    assert_eq!(command_b, exe_b, "boot #2 must carry the NEW mount path");
    assert_eq!(args_b.get(2).map(String::as_str), Some(endpoint_b));

    // And nothing from the stale boot #1 leaks into the boot #2 declaration.
    assert_ne!(
        command_b, exe_a,
        "boot #2 must not reuse boot #1's dead exe"
    );
    assert!(
        !args_b.iter().any(|a| a == endpoint_a),
        "boot #2 must not reuse boot #1's dead endpoint, got: {args_b:?}"
    );
}

#[test]
fn mcp_runtime_project_id_uses_simple_uuid_form() {
    // M5d-6 (coherence, pure) — app-tauri builds the McpRuntime.project_id as
    // `project.id.as_uuid().simple().to_string()` (commands.rs::launch_agent),
    // i.e. the hyphen-free 32-hex `simple` form the M5c handshake guard
    // (`serve_peer`) compares against. The McpRuntime construction is inlined in
    // the Tauri command (not an extractable pure fn), so we cannot unit-test the
    // wiring here without an AppState. Instead we PIN the format contract the M5d
    // tests above rely on, and flag that the end-to-end injection coherence
    // (--endpoint == mcp_endpoint(..).as_cli_arg() and --project == this form) is
    // exercised by the M5e smoke e2e.
    let uuid = Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
    let simple = uuid.as_simple().to_string();
    assert_eq!(simple, "0123456789abcdef0123456789abcdef");
    assert!(
        !simple.contains('-') && simple.len() == 32,
        "the `simple` form is hyphen-free 32-hex, matching the project_id used in the M5d tests"
    );
}

// ---------------------------------------------------------------------------
// LaunchAgent — session resume (T4)
// ---------------------------------------------------------------------------

/// Builds a stdin profile carrying a [`SessionStrategy`] (assign + resume flags).
fn profile_with_session(
    id: ProfileId,
    assign_flag: Option<&str>,
    resume_flag: &str,
) -> AgentProfile {
    let session =
        SessionStrategy::new(assign_flag.map(str::to_owned), resume_flag.to_owned()).unwrap();
    AgentProfile::new(
        id,
        "Claude Code",
        "claude",
        Vec::new(),
        ContextInjection::stdin(),
        Some("claude --version".to_owned()),
        "{agentRunDir}",
        Some(session),
    )
    .unwrap()
}

#[tokio::test]
async fn launch_first_time_with_assign_flag_mints_and_exposes_conversation_id() {
    // Fresh cell (conversation_id = None), profile WITH an assign_flag.
    let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
        profile_with_session(pid(9), Some("--session-id"), "--resume"),
        Some(ContextInjectionPlan::Stdin),
    );

    let out = launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    // SeqIds yields Uuid::from_u128(1) on its first call.
    let expected = Uuid::from_u128(1).to_string();

    // The use case exposes the assigned id (caller persists it on the leaf).
    assert_eq!(
        out.assigned_conversation_id.as_deref(),
        Some(expected.as_str())
    );

    // The runtime received an Assign plan carrying exactly that id.
    assert_eq!(
        *session.lock().unwrap(),
        Some(SessionPlan::Assign {
            conversation_id: expected
        }),
    );
}

#[tokio::test]
async fn launch_reopen_with_existing_conversation_id_resumes_without_new_id() {
    // The cell already carries a conversation id ⇒ Resume, no new id minted.
    let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
        profile_with_session(pid(9), Some("--session-id"), "--resume"),
        Some(ContextInjectionPlan::Stdin),
    );

    let mut input = launch_input(agent.id);
    input.conversation_id = Some("conv-existing".to_owned());
    let out = launch.execute(input).await.expect("launch");

    // Nothing newly assigned (the id pre-existed): caller has nothing to persist.
    assert_eq!(out.assigned_conversation_id, None);

    // Resume plan with the existing id; SeqIds was never consulted.
    assert_eq!(
        *session.lock().unwrap(),
        Some(SessionPlan::Resume {
            conversation_id: "conv-existing".to_owned()
        }),
    );
}

#[tokio::test]
async fn launch_profile_without_session_block_passes_none() {
    // Non-regression: a profile with no session block behaves exactly as before.
    let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) =
        launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));

    let out = launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    assert_eq!(out.assigned_conversation_id, None);
    assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
}

#[tokio::test]
async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() {
    // Profile HAS a session block but NO assign_flag (degraded): a first launch
    // (no cell id) must NOT mint an id and must pass SessionPlan::None.
    let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
        profile_with_session(pid(9), None, "--continue"),
        Some(ContextInjectionPlan::Stdin),
    );

    let out = launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    assert_eq!(
        out.assigned_conversation_id, None,
        "degraded mode mints no id"
    );
    assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
}

// ---------------------------------------------------------------------------
// LaunchAgent — handoff resume injection (lot P7)
// ---------------------------------------------------------------------------

use application::HandoffProvider;
use domain::{ConversationId, ConversationParty, Handoff, HandoffStore, TurnId};

/// In-memory [`HandoffStore`] for the P7 launch tests: holds at most one handoff
/// per [`ConversationId`]. `load` returns `Ok(None)` for an unknown conversation
/// (the store-without-handoff case), so the best-effort `resolve_handoff` degrades
/// to "no section".
#[derive(Clone, Default)]
struct FakeHandoffStore(Arc<Mutex<HashMap<ConversationId, Handoff>>>);

impl FakeHandoffStore {
    fn with(conv: ConversationId, handoff: Handoff) -> Self {
        let me = Self::default();
        me.0.lock().unwrap().insert(conv, handoff);
        me
    }
}

#[async_trait]
impl HandoffStore for FakeHandoffStore {
    async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
        Ok(self.0.lock().unwrap().get(&conversation).cloned())
    }
    async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
        self.0.lock().unwrap().insert(conversation, handoff);
        Ok(())
    }
}

/// [`HandoffProvider`] that hands back the same store for any root (the launch
/// tests use a single project root).
#[derive(Clone)]
struct FakeHandoffProvider(Arc<dyn HandoffStore>);

impl HandoffProvider for FakeHandoffProvider {
    fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
        Some(Arc::clone(&self.0))
    }
}

/// Builds a `conventionFile` LaunchAgent wired with an **optional** handoff
/// provider, returning the launcher and the recording fs (the only port the P7
/// resume assertions read). When `provider` is `None`, no handoff is wired —
/// exactly the legacy launch path.
fn launch_with_handoff(provider: Option<Arc<dyn HandoffProvider>>) -> (LaunchAgent, Agent, FakeFs) {
    let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&agent, "# ctx body");
    let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
    let tr = trace();
    let fs = FakeFs::new(Arc::clone(&tr));
    let pty = FakePty::new(Arc::clone(&tr), sid(777));
    let mut launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(FakeRuntime::new(
            Arc::clone(&tr),
            Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
        )),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills::default()),
        Arc::new(TerminalSessions::new()),
        Arc::new(SpyBus::default()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall::default()),
        None,
    );
    if let Some(p) = provider {
        launch = launch.with_handoff_provider(p);
    }
    (launch, agent, fs)
}

fn conv_id(n: u128) -> ConversationId {
    ConversationId::from_uuid(Uuid::from_u128(n))
}

fn handoff_for(summary: &str, objective: Option<&str>) -> Handoff {
    Handoff::new(
        summary,
        TurnId::from_uuid(Uuid::from_u128(7)),
        objective.map(ToOwned::to_owned),
    )
}

/// Reads the (single) convention file written by the launch.
fn convention_doc(fs: &FakeFs) -> String {
    let writes = fs.context_writes();
    assert_eq!(writes.len(), 1, "exactly one convention file");
    String::from_utf8(writes[0].1.clone()).unwrap()
}

#[tokio::test]
async fn launch_injects_handoff_resume_when_provider_and_conversation_match() {
    // Happy path: a wired provider, a cell conversation_id that parses to a known
    // ConversationId with a stored handoff ⇒ the convention file carries the
    // `# Reprise de la conversation` section (objective + summary).
    let conv = conv_id(123);
    let store = FakeHandoffStore::with(
        conv,
        handoff_for("On a terminé l'étape A.", Some("Finir le lot P7")),
    );
    let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
    let (launch, agent, fs) = launch_with_handoff(Some(provider));

    let mut input = launch_input(agent.id);
    // The cell carries the conversation id of the known pair (UUID string form).
    input.conversation_id = Some(Uuid::from_u128(123).to_string());

    launch.execute(input).await.expect("launch succeeds");

    let doc = convention_doc(&fs);
    assert!(
        doc.contains("# Reprise de la conversation"),
        "resume section materialised in the run dir: {doc}"
    );
    assert!(
        doc.contains("**Objectif :** Finir le lot P7"),
        "objective injected: {doc}"
    );
    assert!(
        doc.contains("On a terminé l'étape A."),
        "summary injected: {doc}"
    );
}

#[tokio::test]
async fn launch_without_handoff_provider_omits_resume_section() {
    // No provider wired (legacy path) ⇒ no resume section, even with a cell
    // conversation_id present.
    let (launch, agent, fs) = launch_with_handoff(None);
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(Uuid::from_u128(123).to_string());

    launch.execute(input).await.expect("launch succeeds");

    let doc = convention_doc(&fs);
    assert!(
        !doc.contains("# Reprise de la conversation"),
        "no provider ⇒ no resume section: {doc}"
    );
}

#[tokio::test]
async fn launch_with_no_conversation_id_omits_resume_section() {
    // Provider wired and a handoff stored, but the cell has NO conversation_id ⇒
    // nothing to resolve ⇒ no section, launch unaffected.
    let store = FakeHandoffStore::with(conv_id(123), handoff_for("x", Some("y")));
    let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
    let (launch, agent, fs) = launch_with_handoff(Some(provider));

    // launch_input leaves conversation_id = None.
    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch succeeds");

    let doc = convention_doc(&fs);
    assert!(
        !doc.contains("# Reprise de la conversation"),
        "no conversation_id ⇒ no resume section: {doc}"
    );
}

#[tokio::test]
async fn launch_with_invalid_uuid_conversation_id_omits_resume_section() {
    // A non-UUID conversation_id (legacy CLI id / corrupt data) must NOT crash the
    // launch and must inject no resume section (parse failure ⇒ best-effort None).
    let store = FakeHandoffStore::with(conv_id(123), handoff_for("x", Some("y")));
    let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
    let (launch, agent, fs) = launch_with_handoff(Some(provider));

    let mut input = launch_input(agent.id);
    input.conversation_id = Some("not-a-uuid".to_owned());

    launch
        .execute(input)
        .await
        .expect("invalid uuid must not fail the launch");

    let doc = convention_doc(&fs);
    assert!(
        !doc.contains("# Reprise de la conversation"),
        "invalid uuid ⇒ no resume section: {doc}"
    );
}

#[tokio::test]
async fn launch_opencode_omits_api_key_when_profile_has_none() {
    let profile = profile(
        pid(91),
        ContextInjection::convention_file("AGENTS.md").unwrap(),
    )
    .with_structured_adapter(StructuredAdapter::OpenCode)
    .with_opencode(
        OpenCodeConfig::new(
            "http://localhost:8080/v1",
            None,
            "qwen3-coder-30b",
            None,
            None,
        )
        .unwrap(),
    )
    .with_mcp(McpCapability::new(
        McpConfigStrategy::open_code_config("opencode.json").unwrap(),
        McpTransport::Stdio,
    ));
    let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
    );

    launch.execute(launch_input(agent.id)).await.unwrap();

    let opencode = writes_ending_with(&fs, "/opencode.json");
    assert_eq!(opencode.len(), 1, "one OpenCode config is written");
    let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
    assert_eq!(
        body["provider"]["llamacpp"]["options"]["baseURL"],
        "http://localhost:8080/v1"
    );
    assert!(
        body["provider"]["llamacpp"]["options"]
            .as_object()
            .expect("llamacpp options are an object")
            .get("apiKey")
            .is_none(),
        "apiKey must be omitted, not serialized as null"
    );
}

#[tokio::test]
async fn launch_with_store_without_handoff_omits_resume_section() {
    // Provider wired, valid conversation_id, but the store holds NO handoff for it
    // (load ⇒ Ok(None)) ⇒ no section, launch succeeds.
    let store = FakeHandoffStore::default(); // empty
    let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
    let (launch, agent, fs) = launch_with_handoff(Some(provider));

    let mut input = launch_input(agent.id);
    input.conversation_id = Some(Uuid::from_u128(999).to_string());

    launch.execute(input).await.expect("launch succeeds");

    let doc = convention_doc(&fs);
    assert!(
        !doc.contains("# Reprise de la conversation"),
        "store without handoff ⇒ no resume section: {doc}"
    );
}

/// LS5 — un `handoff.md` **legacy surdimensionné** (écrit avant LS5, non borné) chargé
/// au lancement ⇒ la borne défensive de `resolve_handoff` ramène le `summary_md` injecté
/// sous [`domain::HANDOFF_SUMMARY_MAX_CHARS`], sans migration de données ni crash. On
/// vérifie sur la section `# Reprise de la conversation` réellement écrite dans le run dir.
#[tokio::test]
async fn launch_bounds_oversize_legacy_handoff_resume_section() {
    // Handoff legacy géant : 500 lignes-tours bien formées, objectif absent (⇒ la section
    // injectée = le seul summary, comparable directement au plafond).
    let mut summary = String::new();
    for i in 0..500u32 {
        if i > 0 {
            summary.push('\n');
        }
        summary.push_str(&format!(
            "- **Response:** tour numero {i} {}",
            "x".repeat(30)
        ));
    }
    assert!(
        summary.chars().count() > domain::HANDOFF_SUMMARY_MAX_CHARS,
        "pré-condition : le handoff legacy dépasse le plafond ({})",
        summary.chars().count()
    );

    let conv = conv_id(123);
    let store = FakeHandoffStore::with(conv, handoff_for(&summary, None));
    let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
    let (launch, agent, fs) = launch_with_handoff(Some(provider));

    let mut input = launch_input(agent.id);
    input.conversation_id = Some(Uuid::from_u128(123).to_string());
    launch.execute(input).await.expect("launch succeeds");

    let doc = convention_doc(&fs);
    // La section de reprise est la DERNIÈRE du document (ordre composeur). Son contenu
    // (après l'entête) = le summary injecté, qui doit être borné.
    let section = doc
        .split("# Reprise de la conversation\n\n")
        .nth(1)
        .expect("resume section present")
        .trim_end();
    assert!(
        section.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS,
        "summary legacy injecté borné, obtenu {}",
        section.chars().count()
    );
    // Distillation : les lignes les plus récentes survivent, les plus anciennes droppées.
    assert!(section.contains("tour numero 499"), "le plus récent reste");
    assert!(
        !section.contains("tour numero 0 "),
        "le plus ancien droppé : {section}"
    );
    // Reparsable : chaque ligne conservée garde le préfixe de tour.
    for line in section.lines().filter(|l| !l.is_empty()) {
        assert!(line.starts_with("- **"), "ligne reparsable : {line}");
    }
}

/// LS5 — zéro régression : un handoff **normal** (sous le plafond) est injecté
/// **verbatim** (la borne est l'identité), objectif et summary intacts.
#[tokio::test]
async fn launch_normal_handoff_injects_summary_verbatim() {
    let summary = "**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b";
    assert!(summary.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS);
    let conv = conv_id(123);
    let store = FakeHandoffStore::with(conv, handoff_for(summary, Some("livrer LS5")));
    let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
    let (launch, agent, fs) = launch_with_handoff(Some(provider));

    let mut input = launch_input(agent.id);
    input.conversation_id = Some(Uuid::from_u128(123).to_string());
    launch.execute(input).await.expect("launch succeeds");

    let doc = convention_doc(&fs);
    // Le summary normal est injecté tel quel (la borne ne l'a pas touché).
    assert!(
        doc.contains(summary),
        "handoff normal injecté verbatim (borne = identité) : {doc}"
    );
    assert!(doc.contains("**Objectif :** livrer LS5"));
}

// ---------------------------------------------------------------------------
// Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité).
//
// Round-trip réel « sauve sous resolve / charge au lancement » :
//   1. SAUVEGARDE — côté orchestrateur, le handoff est rangé sous la clé que
//      `resolve_conversation(None, agent)` renvoie ; avec un registre câblé c'est
//      `reg.resolve(User, agent).id`, qui (scellé Bloc 1 :
//      `resolve_id_equals_for_pair_user_agent`) vaut `ConversationId::for_pair(User,
//      agent)`. On dérive donc la clé de SAUVEGARDE via ce **même** `for_pair` —
//      sans tirer `infrastructure` comme dépendance de la couche application (règle
//      hexagonale ; l'égalité registre↔for_pair est prouvée côté infra).
//   2. PERSISTANCE (P8a) — au 1er lancement d'une cellule neuve, IdeA **persiste**
//      `for_pair(User, agent)` sur `LeafCell.conversation_id`. Donc à la réouverture
//      la cellule **porte** cette clé.
//   3. CHARGEMENT (P7) — `resolve_handoff` charge le handoff sous
//      `LaunchAgentInput.conversation_id` (la clé persistée que la cellule porte).
//
// Ce test joue (2)+(3) fidèlement : la cellule porte la clé que P8a a persistée
// (`for_pair`), et on prouve qu'elle retrouve le handoff sauvé sous la clé de (1).
// C'est l'invariant que Main a rattrapé : si l'une des trois clés dérivait, la
// section de reprise serait absente et ce test échouerait.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() {
    let agent_party = ConversationParty::agent(aid(1)); // l'agent du harnais.
                                                        // (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé
                                                        //     == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure).
    let save_key = ConversationId::for_pair(ConversationParty::User, agent_party);
    // (2) Clé que P8a persiste sur la cellule neuve, donc portée à la réouverture.
    let persisted_on_leaf = ConversationId::for_pair(ConversationParty::User, agent_party);
    assert_eq!(
        save_key, persisted_on_leaf,
        "préalable end-to-end : clé sauvegarde (resolve) == clé persistée (P8a)"
    );

    let store = FakeHandoffStore::with(
        save_key,
        handoff_for("État au dernier tour : lot P8a câblé.", Some("Sceller §19")),
    );
    let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
    let (launch, agent, fs) = launch_with_handoff(Some(provider));

    // (3) Réouverture : la cellule porte la clé que P8a avait persistée.
    let mut input = launch_input(agent.id);
    input.conversation_id = Some(persisted_on_leaf.to_string());

    launch.execute(input).await.expect("launch succeeds");

    let doc = convention_doc(&fs);
    assert!(
        doc.contains("# Reprise de la conversation"),
        "clé de chargement (P7, clé persistée par P8a) == clé de sauvegarde (resolve) : \
         le handoff doit être retrouvé. doc: {doc}"
    );
    assert!(
        doc.contains("**Objectif :** Sceller §19"),
        "objectif du handoff sauvé sous la clé de résolution injecté: {doc}"
    );
    assert!(
        doc.contains("État au dernier tour : lot P8a câblé."),
        "résumé du handoff sauvé sous la clé de résolution injecté: {doc}"
    );
}

/// Contre-épreuve : un handoff rangé sous une **autre** clé (un agent différent) ne
/// doit PAS être chargé — garantit que la réussite du test ci-dessus tient à
/// l'**égalité** des clés, pas à un chargement inconditionnel.
#[tokio::test]
async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key() {
    let agent_party = ConversationParty::agent(aid(1)); // agent du harnais.
                                                        // La cellule porte SA clé de paire (celle que P8a aurait persistée)…
    let leaf_key = ConversationId::for_pair(ConversationParty::User, agent_party);
    // …mais le handoff est rangé sous la clé d'une AUTRE paire (autre agent).
    let other_key =
        ConversationId::for_pair(ConversationParty::User, ConversationParty::agent(aid(2)));
    assert_ne!(
        leaf_key, other_key,
        "préalable : deux clés de paire distinctes"
    );

    let store = FakeHandoffStore::with(other_key, handoff_for("ne doit pas fuiter", Some("X")));
    let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
    let (launch, agent, fs) = launch_with_handoff(Some(provider));

    let mut input = launch_input(agent.id);
    input.conversation_id = Some(leaf_key.to_string());

    launch.execute(input).await.expect("launch succeeds");

    let doc = convention_doc(&fs);
    assert!(
        !doc.contains("# Reprise de la conversation"),
        "handoff d'une autre paire ⇒ aucune reprise (pas de fuite de clé): {doc}"
    );
}

// ===========================================================================
// LP3-3 — permission projection wiring (apply_permission_projection + registry
// + MCP decoupling). FS-mocked, projectors are faithful test doubles (the real
// translation is covered by the infra LP3-2 tests; application must stay
// dependency-free of `infrastructure`).
// ===========================================================================

const CLAUDE_SEED_REL: &str = "/.claude/settings.local.json";
const CODEX_CONFIG_REL: &str = "/.codex/config.toml";

/// In-memory [`PermissionStore`] returning a fixed document (LP3-3 wiring tests).
struct FakePermissionStore(ProjectPermissions);

#[async_trait]
impl PermissionStore for FakePermissionStore {
    async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
        Ok(self.0.clone())
    }
    async fn save_permissions(
        &self,
        _project: &Project,
        _permissions: &ProjectPermissions,
    ) -> Result<(), StoreError> {
        Ok(())
    }
}

struct FakeSystemPermissionStore(ProjectSystemPermissions);

#[async_trait]
impl SystemPermissionStore for FakeSystemPermissionStore {
    async fn load_system_permissions(
        &self,
        _project: &Project,
    ) -> Result<ProjectSystemPermissions, StoreError> {
        Ok(self.0.clone())
    }

    async fn save_system_permissions(
        &self,
        _project: &Project,
        _permissions: &ProjectSystemPermissions,
    ) -> Result<(), StoreError> {
        Ok(())
    }
}

/// Faithful Claude projector double: emits a single owned `Replace` settings file
/// whose `defaultMode` mirrors the real posture→mode mapping (Allow→bypass,
/// Ask→acceptEdits, Deny→plan), and embeds the project root verbatim. `eff == None`
/// ⇒ empty projection.
struct FakeClaudeProjector;

impl PermissionProjector for FakeClaudeProjector {
    fn key(&self) -> ProjectorKey {
        ProjectorKey::Claude
    }
    fn project(
        &self,
        eff: Option<&EffectivePermissions>,
        _network: Option<NetworkPolicy>,
        ctx: &ProjectionContext,
    ) -> PermissionProjection {
        let Some(eff) = eff else {
            return PermissionProjection::empty();
        };
        let mode = match eff.fallback() {
            Posture::Deny => "plan",
            Posture::Ask => "acceptEdits",
            Posture::Allow => "bypassPermissions",
        };
        let contents = format!(
            "{{\"permissions\":{{\"defaultMode\":\"{mode}\",\"additionalDirectories\":[\"{}\"]}}}}\n",
            ctx.project_root
        );
        PermissionProjection {
            files: vec![ProjectedFile::Replace {
                rel_path: ".claude/settings.local.json".to_owned(),
                contents,
            }],
            args: Vec::new(),
            env: Vec::new(),
        }
    }
    fn owned_replace_paths(&self) -> Vec<String> {
        vec![".claude/settings.local.json".to_owned()]
    }
}

/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
/// from the posture exactly like the real projector. Workspace-write postures also
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒ no
/// filesystem/bash projection, but network env remains projected because system
/// permissions are orthogonal.
struct FakeCodexProjector;

impl FakeCodexProjector {
    const CODE_MODE_TOML: &'static str =
        "[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]\n";

    fn managed_tables() -> Vec<String> {
        vec![
            "sandbox_workspace_write".to_owned(),
            "features.code_mode".to_owned(),
        ]
    }

    fn modes(fallback: Posture) -> (&'static str, &'static str) {
        match fallback {
            Posture::Deny => ("read-only", "on-request"),
            Posture::Ask => ("workspace-write", "on-request"),
            Posture::Allow => ("workspace-write", "never"),
        }
    }
}

impl PermissionProjector for FakeCodexProjector {
    fn key(&self) -> ProjectorKey {
        ProjectorKey::Codex
    }
    fn project(
        &self,
        eff: Option<&EffectivePermissions>,
        network: Option<NetworkPolicy>,
        ctx: &ProjectionContext,
    ) -> PermissionProjection {
        let env = {
            let disabled = match network {
                Some(NetworkPolicy::Allow) => "0",
                Some(NetworkPolicy::Deny | NetworkPolicy::Ask) | None => "1",
            };
            vec![(
                "CODEX_SANDBOX_NETWORK_DISABLED".to_owned(),
                disabled.to_owned(),
            )]
        };
        let network_access = matches!(network, Some(NetworkPolicy::Allow));
        let network_file = ProjectedFile::MergeToml {
            rel_path: ".codex/config.toml".to_owned(),
            managed_tables: Self::managed_tables(),
            managed_keys: Vec::new(),
            contents: format!(
                "[sandbox_workspace_write]\nnetwork_access = {network_access}\n\n{}",
                Self::CODE_MODE_TOML
            ),
        };
        let Some(eff) = eff else {
            return PermissionProjection {
                files: vec![network_file],
                env,
                ..PermissionProjection::empty()
            };
        };
        let (sandbox, approval) = Self::modes(eff.fallback());
        let contents = format!(
            "sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n\n[sandbox_workspace_write]\nnetwork_access = {network_access}\n\n{}",
            Self::CODE_MODE_TOML
        );
        let mut args = vec![
            "--sandbox".to_owned(),
            sandbox.to_owned(),
            "--ask-for-approval".to_owned(),
            approval.to_owned(),
        ];
        if sandbox == "workspace-write" {
            args.push("--add-dir".to_owned());
            args.push(ctx.project_root.to_owned());
        }
        PermissionProjection {
            files: vec![ProjectedFile::MergeToml {
                rel_path: ".codex/config.toml".to_owned(),
                managed_tables: Self::managed_tables(),
                managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
                contents,
            }],
            args,
            env,
        }
    }
    fn owned_replace_paths(&self) -> Vec<String> {
        Vec::new()
    }
}

/// A registry carrying both faithful projector doubles.
fn full_registry() -> Arc<PermissionProjectorRegistry> {
    Arc::new(
        PermissionProjectorRegistry::new()
            .with(Arc::new(FakeClaudeProjector))
            .with(Arc::new(FakeCodexProjector)),
    )
}

/// A project document whose project-level fallback is `posture` (no agent
/// override) ⇒ `resolve_for` yields `Some(eff)` with that fallback.
fn perm_doc(posture: Posture) -> ProjectPermissions {
    ProjectPermissions::new(Some(PermissionSet::new(vec![], posture)), Vec::new())
}

/// Builds a `codex` profile (convention `AGENTS.md`) with the given customiser, so
/// the structured-adapter / projector / mcp variants can be exercised.
fn codex_profile() -> AgentProfile {
    AgentProfile::new(
        pid(9),
        "OpenAI Codex CLI",
        "codex",
        Vec::new(),
        ContextInjection::convention_file("AGENTS.md").unwrap(),
        Some("codex --version".to_owned()),
        "{agentRunDir}",
        None,
    )
    .unwrap()
}

/// Wires a `LaunchAgent` over the fakes with an optional projector registry and an
/// optional permission document. Returns the use case + the seeded agent + the
/// recording fs/pty so the projection writes and folded args can be asserted.
fn launch_with_projection(
    profile: AgentProfile,
    plan: Option<ContextInjectionPlan>,
    registry: Option<Arc<PermissionProjectorRegistry>>,
    perm_doc: Option<ProjectPermissions>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
    launch_with_projection_and_env(profile, plan, registry, perm_doc, Vec::new())
}

fn launch_with_projection_and_env(
    profile: AgentProfile,
    plan: Option<ContextInjectionPlan>,
    registry: Option<Arc<PermissionProjectorRegistry>>,
    perm_doc: Option<ProjectPermissions>,
    env: Vec<(String, String)>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
    launch_with_projection_agent_and_env(profile, agent, plan, registry, perm_doc, env)
}

fn launch_with_projection_agent_and_env(
    profile: AgentProfile,
    agent: Agent,
    plan: Option<ContextInjectionPlan>,
    registry: Option<Arc<PermissionProjectorRegistry>>,
    perm_doc: Option<ProjectPermissions>,
    env: Vec<(String, String)>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
    let contexts = FakeContexts::with_agent(&agent, "# ctx body");
    let profiles = FakeProfiles::new(vec![profile]);
    let tr = trace();
    let fs = FakeFs::new(Arc::clone(&tr));
    let pty = FakePty::new(Arc::clone(&tr), sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let mut launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(FakeRuntime::new(Arc::clone(&tr), plan).with_env(env)),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills::default()),
        Arc::clone(&sessions),
        Arc::new(SpyBus::default()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall::default()),
        None,
    );
    if let Some(doc) = perm_doc {
        launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc)));
    }
    if let Some(reg) = registry {
        launch = launch.with_permission_projectors(reg);
    }
    (launch, agent, fs, pty, sessions)
}

fn convention_file_plan() -> Option<ContextInjectionPlan> {
    Some(ContextInjectionPlan::File {
        target: "CLAUDE.md".to_owned(),
    })
}

// ---- (1) projector key selection -------------------------------------------

/// (1a) An explicit `projector = Some(Claude)` is honoured even when the heuristic
/// would not fire (here the convention file is GEMINI.md): the explicit field wins.
#[tokio::test]
async fn projection_selects_claude_from_explicit_projector_field() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("GEMINI.md").unwrap(),
    )
    .with_projector(ProjectorKey::Claude);
    let (launch, agent, fs, _pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "GEMINI.md".to_owned(),
        }),
        Some(full_registry()),
        Some(perm_doc(Posture::Allow)),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
    assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)");
    assert!(
        fs.writes_ending_with(CODEX_CONFIG_REL).is_empty(),
        "the Codex projector must NOT run"
    );
}

/// (1b) Legacy fallback: no `projector` field, but a `CLAUDE.md` convention file ⇒
/// Claude projector is selected.
#[tokio::test]
async fn projection_falls_back_to_claude_from_convention_file() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    );
    assert!(
        profile.projector.is_none(),
        "no explicit projector (legacy)"
    );
    let (launch, agent, fs, _pty, _s) = launch_with_projection(
        profile,
        convention_file_plan(),
        Some(full_registry()),
        Some(perm_doc(Posture::Allow)),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    assert_eq!(
        fs.writes_ending_with(CLAUDE_SEED_REL).len(),
        1,
        "CLAUDE.md heuristic selects the Claude projector"
    );
}

/// (1c) Legacy fallback: no `projector` field, but `StructuredAdapter::Codex` ⇒
/// Codex projector is selected.
#[tokio::test]
async fn projection_falls_back_to_codex_from_structured_adapter() {
    let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
    assert!(
        profile.projector.is_none(),
        "no explicit projector (legacy)"
    );
    let (launch, agent, fs, pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        Some(perm_doc(Posture::Allow)),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    assert_eq!(
        fs.writes_ending_with(CODEX_CONFIG_REL).len(),
        1,
        "Codex structured adapter selects the Codex projector"
    );
    assert!(
        fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
        "the Claude projector must NOT run"
    );
    // Args were folded into the spawned spec.
    assert!(
        pty.spawns()[0].args.contains(&"--sandbox".to_owned()),
        "sandbox args folded into spec"
    );
}

#[tokio::test]
async fn codex_projection_folds_network_env_into_pty_spawn() {
    for (policy, disabled) in [
        (Some(NetworkPolicy::Allow), "0"),
        (Some(NetworkPolicy::Deny), "1"),
        (Some(NetworkPolicy::Ask), "1"),
        (None, "1"),
    ] {
        let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
        let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
            profile,
            Some(ContextInjectionPlan::File {
                target: "AGENTS.md".to_owned(),
            }),
            Some(full_registry()),
            Some(perm_doc(Posture::Allow)),
        );
        let system_doc =
            ProjectSystemPermissions::new(Some(SystemPermissionSet::new(policy)), Vec::new());
        launch =
            launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));

        launch
            .execute(launch_input(agent.id))
            .await
            .expect("launch");

        assert_eq!(
            pty.spawns()[0]
                .env
                .iter()
                .find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
                .map(|(_, value)| value.as_str()),
            Some(disabled),
            "network policy {policy:?} must be projected into the final child env"
        );
    }
}

#[tokio::test]
async fn codex_network_allow_overrides_stale_disabled_env_in_pty_spawn() {
    let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
    let (mut launch, agent, _fs, pty, _s) = launch_with_projection_and_env(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        Some(perm_doc(Posture::Allow)),
        vec![("CODEX_SANDBOX_NETWORK_DISABLED".to_owned(), "1".to_owned())],
    );
    let system_doc = ProjectSystemPermissions::new(
        Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
        Vec::new(),
    );
    launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    let spawns = pty.spawns();
    let values = spawns[0]
        .env
        .iter()
        .filter(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
        .map(|(_, value)| value.as_str())
        .collect::<Vec<_>>();
    assert_eq!(
        values,
        vec!["0"],
        "network Allow must replace stale disabled env instead of appending a duplicate"
    );
}

#[tokio::test]
async fn codex_network_env_is_projected_without_filesystem_permission_policy() {
    let profile = codex_profile().with_projector(ProjectorKey::Codex);
    let (mut launch, agent, fs, pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        None,
    );
    let system_doc = ProjectSystemPermissions::new(
        Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
        Vec::new(),
    );
    launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    assert!(
        fs.writes_ending_with(CODEX_CONFIG_REL)
            .iter()
            .any(|(_, contents)| std::str::from_utf8(contents)
                .unwrap()
                .contains("[sandbox_workspace_write]\nnetwork_access = true")),
        "network policy must still update Codex config without filesystem/bash policy"
    );
    assert!(
        !pty.spawns()[0].args.contains(&"--sandbox".to_owned()),
        "no filesystem/bash policy means no Codex sandbox args are folded"
    );
    assert_eq!(
        pty.spawns()[0]
            .env
            .iter()
            .find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
            .map(|(_, value)| value.as_str()),
        Some("0"),
        "network Allow must still reach the final child env"
    );
}

#[tokio::test]
async fn codex_projection_forwards_network_env_to_structured_session_factory() {
    let profile = codex_profile()
        .with_projector(ProjectorKey::Codex)
        .with_structured_adapter(StructuredAdapter::Codex);
    let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        None,
    );
    let system_doc = ProjectSystemPermissions::new(
        Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
        Vec::new(),
    );
    launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));

    let factory = FakeStructuredFactory::new(trace(), sid(888));
    let structured = Arc::new(StructuredSessions::new());
    launch = launch
        .with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
        .with_structured(Arc::new(factory.clone()), Arc::clone(&structured));

    let out = launch
        .execute(launch_input(agent.id))
        .await
        .expect("structured launch");

    assert!(
        pty.spawns().is_empty(),
        "structured route must not spawn PTY"
    );
    assert!(
        out.structured.is_some(),
        "structured launch output is populated"
    );
    assert_eq!(factory.envs().len(), 1, "exactly one structured launch");
    assert_eq!(
        factory.envs()[0]
            .iter()
            .find(|(key, _)| key == "CODEX_SANDBOX_NETWORK_DISABLED")
            .map(|(_, value)| value.as_str()),
        Some("0"),
        "network Allow must reach the structured session factory env"
    );
}

#[tokio::test]
async fn codex_structured_policy_carries_exec_safe_sandbox_roots_and_network() {
    let profile = codex_profile()
        .with_projector(ProjectorKey::Codex)
        .with_structured_adapter(StructuredAdapter::Codex);
    let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        Some(perm_doc(Posture::Deny)),
    );
    let system_doc = ProjectSystemPermissions::new(
        Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
        Vec::new(),
    );
    launch = launch.with_system_permission_store(Arc::new(FakeSystemPermissionStore(system_doc)));

    let factory = FakeStructuredFactory::new(trace(), sid(889));
    let structured = Arc::new(StructuredSessions::new());
    launch = launch
        .with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
        .with_structured(Arc::new(factory.clone()), Arc::clone(&structured));

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("structured launch");

    assert!(
        pty.spawns().is_empty(),
        "structured route must not spawn PTY"
    );
    assert_eq!(
        factory.policies(),
        vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
            sandbox_mode: "read-only".to_owned(),
            writable_roots: Vec::new(),
            network_access: true,
        })],
        "write-denied Codex structured policy must be exec-safe and omit writable roots"
    );
}

/// (1d) A non-projectable profile (no projector, no CLAUDE.md, no Codex signal) ⇒
/// no projection at all, even with a full registry and a posed policy.
#[tokio::test]
async fn projection_noop_for_unprojectable_profile() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("GEMINI.md").unwrap(),
    );
    let (launch, agent, fs, pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "GEMINI.md".to_owned(),
        }),
        Some(full_registry()),
        Some(perm_doc(Posture::Allow)),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty());
    assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty());
    assert!(
        !pty.spawns()[0].args.contains(&"--sandbox".to_owned()),
        "no projected args either"
    );
}

// ---- (2) Replace clobber ---------------------------------------------------

/// (2) A `Replace` file is **clobbered** (rewritten) on every (re)launch: launching
/// the same Claude agent twice (with the session removed in between to clear the
/// singleton guard) writes the owned seed twice to the SAME path — proving the
/// inversion vs. the MCP non-clobbering regime (which skips when the file exists).
#[tokio::test]
async fn claude_replace_seed_is_clobbered_on_relaunch() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_projector(ProjectorKey::Claude);
    let (launch, agent, fs, _pty, sessions) = launch_with_projection(
        profile,
        convention_file_plan(),
        Some(full_registry()),
        Some(perm_doc(Posture::Allow)),
    );

    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    let seed_path = format!("{run_dir}{CLAUDE_SEED_REL}");
    // Pre-mark the seed as already existing: a Replace must write anyway (clobber).
    fs.mark_existing(&seed_path);

    // First launch, then simulate the agent exiting so a fresh relaunch is allowed.
    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch 1");
    sessions.remove(&sid(777));
    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch 2");

    let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
    assert_eq!(
        seeds.len(),
        2,
        "the owned Replace seed is rewritten on each launch (clobber), got {seeds:?}"
    );
    assert!(
        seeds.iter().all(|(p, _)| p == &seed_path),
        "both writes target the same seed path"
    );
}

// ---- (3) MergeToml: upsert managed keys, preserve unmanaged, idempotent -----

/// (3) Projecting onto a pre-existing `.codex/config.toml` upserts the two managed
/// keys while preserving an unmanaged user key; a second projection does not
/// duplicate the managed keys (idempotence).
#[tokio::test]
async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
    let profile = codex_profile().with_projector(ProjectorKey::Codex);
    let (launch, agent, fs, _pty, sessions) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        Some(perm_doc(Posture::Allow)),
    );

    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    let cfg_path = format!("{run_dir}{CODEX_CONFIG_REL}");
    // Pre-existing config with an unmanaged top-level key + an unmanaged table.
    fs.seed_read(
        &cfg_path,
        "user_key = \"keep-me\"\n[mcp_servers.idea]\ncommand = \"idea\"\n\n[features.preview]\nenabled = true\n",
    );

    // First projection.
    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch 1");
    let first = String::from_utf8(
        fs.writes_ending_with(CODEX_CONFIG_REL)
            .last()
            .expect("a codex config write")
            .1
            .clone(),
    )
    .unwrap();
    assert!(
        first.contains("user_key = \"keep-me\""),
        "unmanaged key preserved: {first}"
    );
    assert!(
        first.contains("[mcp_servers.idea]"),
        "unmanaged table preserved: {first}"
    );
    assert!(
        first.contains("[features.preview]\nenabled = true"),
        "unmanaged sibling features table preserved: {first}"
    );
    assert!(
        first.contains("sandbox_mode = \"workspace-write\""),
        "managed sandbox_mode upserted: {first}"
    );
    assert!(
        first.contains("approval_policy = \"never\""),
        "managed approval_policy upserted: {first}"
    );
    assert!(
        first.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"),
        "managed Code Mode table upserted: {first}"
    );

    // Second projection (relaunch): managed keys are replaced in place, not dup'd.
    sessions.remove(&sid(777));
    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch 2");
    let second = String::from_utf8(
        fs.writes_ending_with(CODEX_CONFIG_REL)
            .last()
            .unwrap()
            .1
            .clone(),
    )
    .unwrap();
    assert_eq!(
        second.matches("sandbox_mode =").count(),
        1,
        "idempotent: no duplicate sandbox_mode after a second projection: {second}"
    );
    assert_eq!(
        second.matches("approval_policy =").count(),
        1,
        "idempotent: no duplicate approval_policy: {second}"
    );
    assert_eq!(
        second.matches("[features.code_mode]").count(),
        1,
        "idempotent: no duplicate code mode feature table: {second}"
    );
    assert!(
        second.contains("user_key = \"keep-me\""),
        "unmanaged key still preserved"
    );
    assert!(
        second.contains("[features.preview]\nenabled = true"),
        "unmanaged sibling features table still preserved: {second}"
    );
}

// ---- (4) args/env fold into the spawned spec --------------------------------

/// (4) The projection's launch args are folded into the spec inherited by the
/// (PTY) spawn, in order, alongside the projected config file.
#[tokio::test]
async fn codex_projection_folds_args_into_spawn_spec() {
    let profile = codex_profile().with_projector(ProjectorKey::Codex);
    let (launch, agent, _fs, pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        Some(perm_doc(Posture::Ask)),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    let args = &pty.spawns()[0].args;
    // Ask ⇒ workspace-write / on-request, in CLI order.
    let pos = args
        .windows(2)
        .position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]);
    assert!(
        pos.is_some(),
        "expected --sandbox workspace-write in {args:?}"
    );
    let pos2 = args
        .windows(2)
        .position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]);
    assert!(
        pos2.is_some(),
        "expected --ask-for-approval on-request in {args:?}"
    );
    let add_dir = args
        .windows(2)
        .position(|w| w == ["--add-dir".to_owned(), "/home/me/proj".to_owned()]);
    assert!(
        add_dir.is_some(),
        "expected --add-dir project root in {args:?}"
    );
}

#[tokio::test]
async fn codex_pty_launch_forwards_profile_model_as_config_override() {
    let profile = codex_profile()
        .with_projector(ProjectorKey::Codex)
        .with_model("gpt-5.4")
        .with_model_reasoning_effort("medium");
    let (launch, agent, fs, pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        None,
    );
    let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
    let cfg_path = format!("{run_dir}{CODEX_CONFIG_REL}");
    fs.seed_read(
        &cfg_path,
        "model = \"gpt-5.6-sol\"\n[mcp_servers.idea]\ncommand = \"idea\"\n",
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    let args = &pty.spawns()[0].args;
    assert!(
        args.windows(2)
            .any(|w| w == ["-c".to_owned(), "model=\"gpt-5.4\"".to_owned()]),
        "PTY Codex launch must override stale TUI model from config.toml, got {args:?}"
    );
    assert!(
        args.windows(2).any(|w| w
            == [
                "-c".to_owned(),
                "model_reasoning_effort=\"medium\"".to_owned()
            ]),
        "PTY Codex launch must also forward reasoning effort, got {args:?}"
    );
    let toml = String::from_utf8(
        fs.writes_ending_with(CODEX_CONFIG_REL)
            .last()
            .expect("a codex config write")
            .1
            .clone(),
    )
    .unwrap();
    assert!(
        toml.contains("model = \"gpt-5.6-sol\""),
        "the unmanaged stale TUI model may remain in config.toml; argv must take precedence: {toml}"
    );
}

#[tokio::test]
async fn launch_agent_pty_codex_overrides_use_resolved_effort() {
    let profile = codex_profile()
        .with_projector(ProjectorKey::Codex)
        .with_model_reasoning_effort("low");
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
        .with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
    let (launch, agent, _fs, pty, _s) = launch_with_projection_agent_and_env(
        profile,
        agent,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        None,
        Vec::new(),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    let args = &pty.spawns()[0].args;
    assert!(
        args.windows(2).any(|w| w
            == [
                "-c".to_owned(),
                "model_reasoning_effort=\"x-deep\"".to_owned()
            ]),
        "PTY Codex launch must forward the resolved per-agent effort, got {args:?}"
    );
}

// ---- (5) MCP decoupling — THE key case of the lot ---------------------------

/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected
/// (config file + args). This proves the projection no longer depends on
/// `apply_mcp_config`: the MCP table is absent, yet the permission plan is applied.
#[tokio::test]
async fn codex_sandbox_projected_without_any_mcp_capability() {
    let profile = codex_profile().with_projector(ProjectorKey::Codex);
    assert!(profile.mcp.is_none(), "precondition: no MCP capability");
    let (launch, agent, fs, pty, _s) = launch_with_projection(
        profile,
        Some(ContextInjectionPlan::File {
            target: "AGENTS.md".to_owned(),
        }),
        Some(full_registry()),
        Some(perm_doc(Posture::Deny)),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    // The sandbox config WAS written despite the absent MCP capability.
    let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
    assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
    let toml = String::from_utf8(cfg[0].1.clone()).unwrap();
    assert!(
        toml.contains("sandbox_mode = \"read-only\""),
        "Deny ⇒ read-only: {toml}"
    );
    // And the args were folded too.
    assert!(
        pty.spawns()[0]
            .args
            .windows(2)
            .any(|w| w == ["--sandbox".to_owned(), "read-only".to_owned()]),
        "sandbox args projected without MCP: {:?}",
        pty.spawns()[0].args
    );
}

// ---- (6) no-op regimes ------------------------------------------------------

/// (6a) Registry absent (builder never called) ⇒ no permission file is written,
/// even with a posed policy.
#[tokio::test]
async fn no_registry_means_no_projection() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_projector(ProjectorKey::Claude);
    let (launch, agent, fs, _pty, _s) = launch_with_projection(
        profile,
        convention_file_plan(),
        None, // no registry wired
        Some(perm_doc(Posture::Deny)),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    assert!(
        fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
        "no registry ⇒ no permission seed written (legacy behaviour)"
    );
}

/// (6b) `eff == None` (no project/agent policy posed) ⇒ empty projection: nothing
/// written, even though the registry IS wired.
#[tokio::test]
async fn no_policy_posed_means_empty_projection() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_projector(ProjectorKey::Claude);
    let (launch, agent, fs, _pty, _s) = launch_with_projection(
        profile,
        convention_file_plan(),
        Some(full_registry()),
        Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    assert!(
        fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
        "eff == None ⇒ empty projection ⇒ no seed written"
    );
}

// ---- (7) resolved eff reflected in the projected file -----------------------

/// (7) Smoke: a posed `Deny` project posture flows through `resolve_for` into the
/// projector and is reflected in the produced Claude settings (`defaultMode=plan`).
#[tokio::test]
async fn resolved_deny_posture_reflected_as_plan_mode() {
    let profile = profile(
        pid(9),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
    )
    .with_projector(ProjectorKey::Claude);
    let (launch, agent, fs, _pty, _s) = launch_with_projection(
        profile,
        convention_file_plan(),
        Some(full_registry()),
        Some(perm_doc(Posture::Deny)),
    );

    launch
        .execute(launch_input(agent.id))
        .await
        .expect("launch");

    let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap();
    let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON");
    assert_eq!(
        json["permissions"]["defaultMode"], "plan",
        "Deny posture ⇒ plan mode: {seed}"
    );
    assert_eq!(
        json["permissions"]["additionalDirectories"][0], "/home/me/proj",
        "project root flowed into the projection ctx"
    );
}

// ---------------------------------------------------------------------------
// État du projet injection at launch (lot LS4) — `resolve_live_state` owns the
// manifest join + self-exclusion + ordering + cap; the composer renders it.
// ---------------------------------------------------------------------------

/// In-memory [`LiveStateStore`] seeded with fixed rows (no I/O), to drive the
/// live-state preview injected into the convention file at launch.
struct SeededLiveStore {
    state: Mutex<domain::live_state::LiveState>,
}
#[async_trait]
impl domain::ports::LiveStateStore for SeededLiveStore {
    async fn load(&self) -> Result<domain::live_state::LiveState, domain::ports::StoreError> {
        Ok(self.state.lock().unwrap().clone())
    }
    async fn upsert(
        &self,
        entry: domain::live_state::LiveEntry,
    ) -> Result<(), domain::ports::StoreError> {
        self.state.lock().unwrap().upsert(entry);
        Ok(())
    }
    async fn prune(
        &self,
        now_ms: u64,
        ttl_ms: u64,
        max_n: usize,
    ) -> Result<(), domain::ports::StoreError> {
        self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
        Ok(())
    }
}

/// Fixed clock so the seeded rows are never pruned for age at read time.
struct InjectClock(i64);
impl domain::ports::Clock for InjectClock {
    fn now_millis(&self) -> i64 {
        self.0
    }
}

/// A [`LiveStateLeanProvider`] yielding the same [`GetLiveStateLean`] over a shared
/// seeded store (root-agnostic for the test).
struct SeededLeanProvider {
    getter: Arc<application::GetLiveStateLean>,
}
impl application::LiveStateLeanProvider for SeededLeanProvider {
    fn live_state_lean_for(
        &self,
        _root: &ProjectPath,
    ) -> Option<Arc<application::GetLiveStateLean>> {
        Some(Arc::clone(&self.getter))
    }
}

#[tokio::test]
async fn launch_injects_live_state_excluding_self_capped_in_manifest_order() {
    use domain::live_state::{LiveEntry, LiveState, WorkStatus};

    let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
    let plan = Some(ContextInjectionPlan::File {
        target: "CLAUDE.md".to_owned(),
    });

    // The launched agent (self) — must be EXCLUDED from its own preview.
    let me = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
    let contexts = FakeContexts::with_agent(&me, "# ctx body");

    // 18 OTHER agents in the manifest, in a deterministic order (aid 2..=19).
    {
        let mut inner = contexts.0.lock().unwrap();
        for n in 2..=19u128 {
            let other = scratch_agent(
                aid(n),
                &format!("A{n:02}"),
                &format!("agents/a{n}.md"),
                pid(9),
            );
            inner
                .manifest
                .entries
                .push(ManifestEntry::from_agent(&other));
        }
    }

    // Seed the live-state store: a row for self (must not surface), a row for each
    // of the 18 manifest peers, plus one OFF-manifest row (aid 99) that must drop.
    let now = 1_000_000_i64;
    let mut state = LiveState::default();
    state.upsert(
        LiveEntry::new(
            aid(1),
            None,
            "my own work",
            WorkStatus::Working,
            None,
            None,
            now as u64,
        )
        .unwrap(),
    );
    for n in 2..=19u128 {
        state.upsert(
            LiveEntry::new(
                aid(n),
                None,
                format!("intent {n}"),
                WorkStatus::Working,
                None,
                None,
                now as u64,
            )
            .unwrap(),
        );
    }
    state.upsert(
        LiveEntry::new(
            aid(99),
            None,
            "ghost",
            WorkStatus::Idle,
            None,
            None,
            now as u64,
        )
        .unwrap(),
    );

    let store = Arc::new(SeededLiveStore {
        state: Mutex::new(state),
    });
    let getter = Arc::new(application::GetLiveStateLean::new(
        store as Arc<dyn domain::ports::LiveStateStore>,
        Arc::new(InjectClock(now)),
    ));
    let provider = Arc::new(SeededLeanProvider { getter });

    let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
    let tr = trace();
    let fs = FakeFs::new(Arc::clone(&tr));
    let pty = FakePty::new(Arc::clone(&tr), sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let bus = SpyBus::default();
    let launch = LaunchAgent::new(
        Arc::new(contexts),
        Arc::new(profiles),
        Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills::default()),
        Arc::clone(&sessions),
        Arc::new(bus.clone()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall::default()),
        None,
    )
    .with_live_state_lean(provider as Arc<dyn application::LiveStateLeanProvider>);

    launch.execute(launch_input(me.id)).await.expect("launch");

    let writes = fs.context_writes();
    assert_eq!(writes.len(), 1, "exactly one convention file written");
    let doc = String::from_utf8(writes[0].1.clone()).unwrap();

    // The section is present.
    assert!(
        doc.contains("# État du projet"),
        "live-state section present: {doc}"
    );

    // Self is EXCLUDED: the launched agent never sees its own row.
    assert!(
        !doc.contains("- **Backend**"),
        "the launched agent must not see its own live row: {doc}"
    );
    // The off-manifest row never surfaces (its name is unknown to the manifest).
    assert!(
        !doc.contains("ghost"),
        "off-manifest rows are dropped: {doc}"
    );

    // Cap: exactly LIVE_STATE_INJECT_MAX (16) peer rows are injected, in manifest
    // order ⇒ A02..=A17 present, A18/A19 dropped by the cap. The only `- **` bullets
    // in this composition are the live rows (no skills/memory bullets here).
    assert_eq!(
        doc.matches("- **").count(),
        16,
        "capped to LIVE_STATE_INJECT_MAX peer rows: {doc}"
    );
    assert!(
        doc.contains("- **A02** — working · intent 2"),
        "first peer row: {doc}"
    );
    assert!(
        doc.contains("- **A17** — working · intent 17"),
        "16th peer row: {doc}"
    );
    assert!(
        !doc.contains("- **A18**"),
        "18th agent dropped by the cap: {doc}"
    );
    assert!(
        !doc.contains("- **A19**"),
        "19th agent dropped by the cap: {doc}"
    );

    // Manifest order preserved across the kept rows.
    let a02 = doc.find("**A02**").unwrap();
    let a17 = doc.find("**A17**").unwrap();
    assert!(a02 < a17, "rows follow manifest order: {doc}");
}
