//! A1 tests for [`ChangeAgentProfile`] (ARCHITECTURE §15.1, §15.4 line A1).
//!
//! The hot-swap of an agent's runtime profile is a *composed* use case: it mutates
//! the manifest, cleans the (now foreign) `conversation_id` / `agent_was_running`
//! on every persisted layout cell hosting the agent, and — when the agent is live —
//! kills its PTY and relaunches it in the same cell via [`LaunchAgent`].
//!
//! Every port is faked in-memory (100 % without real I/O):
//! - [`FakeContexts`] — [`AgentContextStore`] (manifest + `md_path → content`),
//! - [`FakeProfiles`] — [`ProfileStore`] returning a fixed profile list,
//! - [`FakeStore`] — [`ProjectStore`] holding the project,
//! - [`FakeFs`] — [`FileSystem`] serving/recording files (the `layouts.json` the
//!   conversation-cleanup walks, plus the run-dir/convention writes of a relaunch),
//! - [`FakeRuntime`] / [`FakePty`] — the runtime + PTY, the PTY recording **kills**
//!   so we can assert a live session is torn down before the relaunch,
//! - [`SpyBus`] / [`SeqIds`] / [`FakeSkills`] / [`FakeRecall`] — event spy, ids,
//!   empty skill store and empty memory recall (behaviour unchanged).
//!
//! The cleanup helpers ([`seed_layouts`], [`leaf_state`]) are borrowed from the
//! `snapshot_running_agents` style: the FakeFs serves a real serialized
//! `LayoutsDoc`, so the use case's `resolve_doc → walk → persist_doc` round-trips
//! through genuine serde, exactly as in production.

use std::collections::{HashMap, HashSet};
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::layout::Workspace;
use domain::markdown::MarkdownDoc;
use domain::permission::{
    EffectivePermissions, PermissionProjection, PermissionProjector, ProjectedFile,
    ProjectionContext, ProjectorKey,
};
use domain::ports::{
    AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
    ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
    OutputStream, PermissionStore, PreparedContext, ProfileStore, ProjectStore, PtyError,
    PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{
    LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PermissionSet, Posture,
    ProjectPermissions, PtySize, SessionId, SessionKind, SkillId,
};
use uuid::Uuid;

use application::{
    ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, PermissionProjectorRegistry,
    TerminalSessions,
};

// ---------------------------------------------------------------------------
// FakeContexts (AgentContextStore) — manifest + md_path → content
// ---------------------------------------------------------------------------

#[derive(Default)]
struct ContextsInner {
    manifest: AgentManifest,
    contents: HashMap<String, String>,
    /// Number of `save_manifest` calls observed.
    saves: usize,
}

#[derive(Clone)]
struct FakeContexts(Arc<Mutex<ContextsInner>>);

impl FakeContexts {
    fn with_agent(agent: &Agent, content: &str) -> Self {
        let me = Self(Arc::new(Mutex::new(ContextsInner {
            manifest: AgentManifest {
                version: 1,
                entries: Vec::new(),
                orchestrator: None,
            },
            contents: HashMap::new(),
            saves: 0,
        })));
        {
            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
    }
    /// The persisted profile id currently recorded for `agent` in the manifest.
    fn profile_of(&self, agent: &AgentId) -> Option<ProfileId> {
        self.0
            .lock()
            .unwrap()
            .manifest
            .entries
            .iter()
            .find(|e| &e.agent_id == agent)
            .map(|e| e.profile_id)
    }
    fn md_path_of(&self, agent: &AgentId) -> Option<String> {
        self.0
            .lock()
            .unwrap()
            .manifest
            .entries
            .iter()
            .find(|e| &e.agent_id == agent)
            .map(|e| e.md_path.clone())
    }
    /// Count of `save_manifest` calls — proves a no-op path leaves the manifest
    /// untouched (no mutating write).
    fn manifest_saves(&self) -> usize {
        self.0.lock().unwrap().saves
    }
}

#[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.0
            .lock()
            .unwrap()
            .contents
            .get(&md_path)
            .cloned()
            .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.0.lock().unwrap().manifest.clone())
    }
    async fn save_manifest(
        &self,
        _project: &Project,
        manifest: &AgentManifest,
    ) -> Result<(), StoreError> {
        let mut inner = self.0.lock().unwrap();
        inner.manifest = manifest.clone();
        inner.saves += 1;
        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(())
    }
}

// ---------------------------------------------------------------------------
// FakeStore (ProjectStore)
// ---------------------------------------------------------------------------

#[derive(Default, Clone)]
struct FakeStore(Arc<Mutex<Vec<Project>>>);

impl FakeStore {
    async fn save(&self, project: &Project) {
        self.0.lock().unwrap().push(project.clone());
    }
}

#[async_trait]
impl ProjectStore for FakeStore {
    async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
        Ok(self.0.lock().unwrap().clone())
    }
    async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
        self.0
            .lock()
            .unwrap()
            .iter()
            .find(|p| p.id == id)
            .cloned()
            .ok_or(StoreError::NotFound)
    }
    async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
        self.0.lock().unwrap().push(project.clone());
        Ok(())
    }
    async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
        Ok(())
    }
    async fn load_workspace(&self) -> Result<Workspace, StoreError> {
        Ok(Workspace::default())
    }
}

// ---------------------------------------------------------------------------
// FakeFs (FileSystem) — HashMap-backed: serves layouts.json + records writes
// ---------------------------------------------------------------------------

#[derive(Default)]
struct FakeFsInner {
    files: HashMap<String, Vec<u8>>,
    dirs: HashSet<String>,
    write_count: usize,
    /// Paths passed to `remove_file`, in order (lot LP3-4 cleanup assertions).
    removed: Vec<String>,
}

#[derive(Default, Clone)]
struct FakeFs(Arc<Mutex<FakeFsInner>>);

impl FakeFs {
    fn put(&self, path: &str, data: &[u8]) {
        self.0
            .lock()
            .unwrap()
            .files
            .insert(path.to_owned(), data.to_vec());
    }
    fn read_file(&self, path: &str) -> Option<Vec<u8>> {
        self.0.lock().unwrap().files.get(path).cloned()
    }
    /// Number of `write` calls observed (used to assert a no-op path writes
    /// nothing through the filesystem).
    fn write_count(&self) -> usize {
        self.0.lock().unwrap().write_count
    }
    /// Whether a file is currently present in the fake's state.
    fn has_file(&self, path: &str) -> bool {
        self.0.lock().unwrap().files.contains_key(path)
    }
    /// The ordered list of paths passed to `remove_file` (lot LP3-4).
    fn removed(&self) -> Vec<String> {
        self.0.lock().unwrap().removed.clone()
    }
}

#[async_trait]
impl FileSystem for FakeFs {
    async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
        self.0
            .lock()
            .unwrap()
            .files
            .get(path.as_str())
            .cloned()
            .ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
    }
    async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
        let mut inner = self.0.lock().unwrap();
        inner.write_count += 1;
        inner.files.insert(path.as_str().to_owned(), data.to_vec());
        Ok(())
    }
    async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
        let inner = self.0.lock().unwrap();
        Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
    }
    async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
        self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
        Ok(())
    }
    /// Overrides the no-op default (lot LP3-4): records the path and actually
    /// removes it from the fake's state, so a deletion is assertable. Idempotent —
    /// removing an absent file still succeeds (best-effort cleanup contract).
    async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
        let mut inner = self.0.lock().unwrap();
        inner.removed.push(path.as_str().to_owned());
        inner.files.remove(path.as_str());
        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(())
    }
}

// ---------------------------------------------------------------------------
// FakeRuntime (AgentRuntime) — minimal; only exercised on a relaunch
// ---------------------------------------------------------------------------

/// Records the [`SessionPlan`] handed to `prepare_invocation` on the (re)launch,
/// so a swap test can prove the relaunch routes a fresh `SessionPlan::None`
/// (the foreign engine resumable is **never** replayed) rather than a `Resume`.
#[derive(Clone, Default)]
struct FakeRuntime {
    plans: Arc<Mutex<Vec<SessionPlan>>>,
}

impl FakeRuntime {
    fn new() -> Self {
        Self::default()
    }
    /// The last `SessionPlan` the launcher built for a (re)launch, if any.
    fn last_plan(&self) -> Option<SessionPlan> {
        self.plans.lock().unwrap().last().cloned()
    }
}

#[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.plans.lock().unwrap().push(session.clone());
        Ok(SpawnSpec {
            command: profile.command.clone(),
            args: profile.args.clone(),
            cwd: cwd.clone(),
            env: Vec::new(),
            context_plan: Some(ContextInjectionPlan::File {
                target: "CLAUDE.md".to_owned(),
            }),
            sandbox: None,
        })
    }
}

// ---------------------------------------------------------------------------
// FakePty (PtyPort) — records spawns and kills
// ---------------------------------------------------------------------------

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

impl FakePty {
    fn new(next_id: SessionId) -> Self {
        Self {
            next_id,
            spawns: Arc::new(Mutex::new(Vec::new())),
            kills: Arc::new(Mutex::new(Vec::new())),
        }
    }
    fn spawn_count(&self) -> usize {
        self.spawns.lock().unwrap().len()
    }
    fn kills(&self) -> Vec<SessionId> {
        self.kills.lock().unwrap().clone()
    }
    /// Args of the most recent spawn (used to assert folded projection args on a
    /// relaunch, lot LP3-4).
    fn last_spawn_args(&self) -> Vec<String> {
        self.spawns
            .lock()
            .unwrap()
            .last()
            .map(|s| s.args.clone())
            .unwrap_or_default()
    }
}

#[async_trait]
impl PtyPort for FakePty {
    async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
        self.spawns.lock().unwrap().push(spec);
        Ok(PtyHandle {
            session_id: self.next_id,
        })
    }
    fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
        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> {
        self.kills.lock().unwrap().push(handle.session_id);
        Ok(ExitStatus { code: Some(0) })
    }
}

// ---------------------------------------------------------------------------
// FakeSkills / FakeRecall / SpyBus / SeqIds
// ---------------------------------------------------------------------------

#[derive(Clone, Default)]
struct FakeSkills;

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

#[derive(Clone, Default)]
struct FakeRecall;

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

#[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
// ---------------------------------------------------------------------------

const ROOT: &str = "/home/me/proj";
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";

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 nid(n: u128) -> NodeId {
    NodeId::from_uuid(Uuid::from_u128(n))
}
fn lid(n: u128) -> LayoutId {
    LayoutId::from_uuid(Uuid::from_u128(n))
}
fn proj_id(n: u128) -> ProjectId {
    ProjectId::from_uuid(Uuid::from_u128(n))
}

fn project() -> Project {
    Project::new(
        proj_id(1000),
        "demo",
        ProjectPath::new(ROOT).unwrap(),
        RemoteRef::local(),
        1_700_000_000_000,
    )
    .unwrap()
}

fn profile(id: ProfileId) -> AgentProfile {
    AgentProfile::new(
        id,
        "Some CLI",
        "claude",
        Vec::new(),
        ContextInjection::convention_file("CLAUDE.md").unwrap(),
        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()
}

/// A leaf cell hosting `agent`, optionally carrying a conversation + running flag.
fn agent_leaf(
    node: NodeId,
    agent: Option<AgentId>,
    conversation_id: Option<&str>,
    agent_was_running: bool,
) -> LeafCell {
    LeafCell {
        id: node,
        session: None,
        agent,
        conversation_id: conversation_id.map(str::to_owned),
        engine_session_id: None,
        agent_was_running,
    }
}

/// Like [`agent_leaf`], additionally seeding an `engine_session_id` (the engine
/// resumable cache) so we can assert it is invalidated on a profile swap.
fn agent_leaf_with_engine(
    node: NodeId,
    agent: Option<AgentId>,
    conversation_id: Option<&str>,
    engine_session_id: Option<&str>,
    agent_was_running: bool,
) -> LeafCell {
    LeafCell {
        engine_session_id: engine_session_id.map(str::to_owned),
        ..agent_leaf(node, agent, conversation_id, agent_was_running)
    }
}

/// Seeds a valid `layouts.json` (a single active layout holding `tree`).
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
    let doc = serde_json::json!({
        "version": 1,
        "activeId": id.to_string(),
        "layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ],
    });
    fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap());
}

/// Reads back the persisted `(conversation_id, agent_was_running)` of leaf `node`.
fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option<String>, bool)> {
    let bytes = fs.read_file(LAYOUTS_PATH)?;
    let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).unwrap();
    fn find(n: &LayoutNode, target: NodeId) -> Option<(Option<String>, bool)> {
        match n {
            LayoutNode::Leaf(l) if l.id == target => {
                Some((l.conversation_id.clone(), l.agent_was_running))
            }
            LayoutNode::Leaf(_) => None,
            LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
            LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
        }
    }
    find(&tree.root, node)
}

/// Reads back the persisted `engine_session_id` of leaf `node`.
fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option<Option<String>> {
    let bytes = fs.read_file(LAYOUTS_PATH)?;
    let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).unwrap();
    fn find(n: &LayoutNode, target: NodeId) -> Option<Option<String>> {
        match n {
            LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()),
            LayoutNode::Leaf(_) => None,
            LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
            LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
        }
    }
    find(&tree.root, node)
}

/// Seeds a live agent session pinned on `node` into the registry.
fn seed_live_agent_session(
    sessions: &TerminalSessions,
    agent_id: AgentId,
    node: 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(),
        SessionKind::Agent { agent_id },
        size,
    );
    session.status = domain::SessionStatus::Running;
    sessions.insert(PtyHandle { session_id }, session);
}

// ---------------------------------------------------------------------------
// FakeHandoffs (HandoffProvider) — root-scoped store seeded with one handoff
// ---------------------------------------------------------------------------

/// A [`HandoffProvider`] backed by an in-memory [`HandoffStore`] keyed by pair id.
/// Wiring this into the relaunch's `LaunchAgent` lets a test prove the **threaded
/// pair id** end-to-end: the `# Reprise de la conversation` section appears in the
/// relaunch's convention file **only if** the relaunch carried the exact
/// `conversation_id` under which the handoff was seeded.
#[derive(Clone, Default)]
struct FakeHandoffs(Arc<Mutex<HashMap<Uuid, domain::Handoff>>>);

impl FakeHandoffs {
    /// Seeds a handoff under conversation id `conv` (a UUID-shaped pair id string).
    fn seed(&self, conv: &str, summary: &str, objective: Option<&str>) {
        let uuid = Uuid::parse_str(conv).expect("seed conv id must be a UUID");
        let handoff = domain::Handoff::new(
            summary.to_owned(),
            domain::TurnId::from_uuid(Uuid::from_u128(0xABCD)),
            objective.map(str::to_owned),
        );
        self.0.lock().unwrap().insert(uuid, handoff);
    }
}

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

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

// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------

/// Everything a change-profile test needs.
struct Fixture {
    swap: ChangeAgentProfile,
    contexts: FakeContexts,
    fs: FakeFs,
    pty: FakePty,
    bus: SpyBus,
    sessions: Arc<TerminalSessions>,
    /// The runtime used by the composed relaunch — records the `SessionPlan`.
    runtime: FakeRuntime,
    /// The handoff store wired into the relaunch (seed via [`FakeHandoffs::seed`]).
    handoffs: FakeHandoffs,
}

/// Wires a [`ChangeAgentProfile`] over fakes. The agent starts on `pid(1)`; the
/// profile store knows both `pid(1)` and `pid(2)` (the valid swap target).
async fn fixture(agent: &Agent) -> Fixture {
    fixture_with_profiles(agent, vec![profile(pid(1)), profile(pid(2))]).await
}

async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fixture {
    let contexts = FakeContexts::with_agent(agent, "# persona");
    let profiles = FakeProfiles::new(profiles);
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let pty = FakePty::new(sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let bus = SpyBus::default();
    let runtime = FakeRuntime::new();
    let handoffs = FakeHandoffs::default();

    // Register the project so ProjectStore::load_project resolves.
    store.save(&project()).await;

    let launch = LaunchAgent::new(
        Arc::new(contexts.clone()),
        Arc::new(profiles.clone()),
        Arc::new(runtime.clone()),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills),
        Arc::clone(&sessions),
        Arc::new(bus.clone()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall),
        None,
    )
    // Wire the handoff provider so a relaunch re-injects the (pair-keyed) handoff
    // into the new engine's convention file — the end-to-end proof of P7+P8d.
    .with_handoff_provider(Arc::new(handoffs.clone()));

    let swap = ChangeAgentProfile::new(
        Arc::new(contexts.clone()),
        Arc::new(profiles),
        Arc::new(store),
        Arc::new(fs.clone()),
        Arc::clone(&sessions),
        Arc::new(pty.clone()),
        Arc::new(launch),
        Arc::new(bus.clone()),
    );

    Fixture {
        swap,
        contexts,
        fs,
        pty,
        bus,
        sessions,
        runtime,
        handoffs,
    }
}

fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput {
    ChangeAgentProfileInput {
        project: project(),
        agent_id,
        profile_id,
        rows: 24,
        cols: 80,
    }
}

// ---------------------------------------------------------------------------
// LP3-4 — permission-file cleanup at swap: fakes, projectors, fixture
// ---------------------------------------------------------------------------

/// In-memory [`PermissionStore`] returning a fixed document (so the relaunch's
/// projection resolves a non-empty `eff` and actually writes the new CLI config).
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(())
    }
}

/// Faithful Claude projector double (LP3-2 mapping): owns the `Replace` settings
/// seed `.claude/settings.local.json`.
struct FakeClaudeProjector;

impl PermissionProjector for FakeClaudeProjector {
    fn key(&self) -> ProjectorKey {
        ProjectorKey::Claude
    }
    fn project(
        &self,
        eff: Option<&EffectivePermissions>,
        ctx: &ProjectionContext,
    ) -> PermissionProjection {
        if eff.is_none() {
            return PermissionProjection::empty();
        }
        let contents = format!(
            "{{\"permissions\":{{\"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 (LP3-2 mapping): a co-owned `MergeToml` + the
/// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file.
struct FakeCodexProjector;

impl PermissionProjector for FakeCodexProjector {
    fn key(&self) -> ProjectorKey {
        ProjectorKey::Codex
    }
    fn project(
        &self,
        eff: Option<&EffectivePermissions>,
        _ctx: &ProjectionContext,
    ) -> PermissionProjection {
        if eff.is_none() {
            return PermissionProjection::empty();
        }
        PermissionProjection {
            files: vec![ProjectedFile::MergeToml {
                rel_path: ".codex/config.toml".to_owned(),
                managed_tables: Vec::new(),
                managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
                contents: "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n"
                    .to_owned(),
            }],
            args: vec![
                "--sandbox".to_owned(),
                "workspace-write".to_owned(),
                "--ask-for-approval".to_owned(),
                "never".to_owned(),
            ],
            env: Vec::new(),
        }
    }
    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 Claude profile (convention `CLAUDE.md` ⇒ legacy Claude selection).
fn claude_profile(id: ProfileId) -> AgentProfile {
    profile(id)
}

/// A Codex profile carrying an explicit `ProjectorKey::Codex` (and the Codex
/// structured adapter, so the legacy fallback would also select Codex).
fn codex_profile(id: ProfileId) -> AgentProfile {
    AgentProfile::new(
        id,
        "OpenAI Codex CLI",
        "codex",
        Vec::new(),
        ContextInjection::convention_file("AGENTS.md").unwrap(),
        Some("codex --version".to_owned()),
        "{agentRunDir}",
        None,
    )
    .unwrap()
    .with_structured_adapter(StructuredAdapter::Codex)
    .with_projector(ProjectorKey::Codex)
}

/// The (stable) run dir of `agent` under the test project root.
fn run_dir_of(agent: &AgentId) -> String {
    format!("{ROOT}/.ideai/run/{agent}")
}

/// Wires a swap fixture with a projector registry on BOTH the swap and the
/// composed relaunch, plus an optional permission document on the relaunch (so
/// the relaunch's projection is non-empty). Mirrors [`fixture_with_profiles`].
async fn fixture_with_projection(
    agent: &Agent,
    profiles: Vec<AgentProfile>,
    registry: Arc<PermissionProjectorRegistry>,
    perm_doc: Option<ProjectPermissions>,
) -> Fixture {
    let contexts = FakeContexts::with_agent(agent, "# persona");
    let profiles = FakeProfiles::new(profiles);
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let pty = FakePty::new(sid(777));
    let sessions = Arc::new(TerminalSessions::new());
    let bus = SpyBus::default();
    let runtime = FakeRuntime::new();
    let handoffs = FakeHandoffs::default();

    store.save(&project()).await;

    let mut launch = LaunchAgent::new(
        Arc::new(contexts.clone()),
        Arc::new(profiles.clone()),
        Arc::new(runtime.clone()),
        Arc::new(fs.clone()),
        Arc::new(pty.clone()),
        Arc::new(FakeSkills),
        Arc::clone(&sessions),
        Arc::new(bus.clone()),
        Arc::new(SeqIds::new()),
        Arc::new(FakeRecall),
        None,
    )
    .with_handoff_provider(Arc::new(handoffs.clone()))
    .with_permission_projectors(Arc::clone(&registry));
    if let Some(doc) = perm_doc {
        launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc)));
    }

    let swap = ChangeAgentProfile::new(
        Arc::new(contexts.clone()),
        Arc::new(profiles),
        Arc::new(store),
        Arc::new(fs.clone()),
        Arc::clone(&sessions),
        Arc::new(pty.clone()),
        Arc::new(launch),
        Arc::new(bus.clone()),
    )
    .with_permission_projectors(Arc::clone(&registry));

    Fixture {
        swap,
        contexts,
        fs,
        pty,
        bus,
        sessions,
        runtime,
        handoffs,
    }
}

/// The full `permissions.json` posing a project-level `Allow` policy ⇒ a relaunch
/// resolves `Some(eff)` and the projector writes the new CLI config.
fn allow_perm_doc() -> ProjectPermissions {
    ProjectPermissions::new(Some(PermissionSet::new(vec![], Posture::Allow)), Vec::new())
}

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

/// Seeds a live agent session + a persisted layout cell hosting it, so the swap
/// reaches its relaunch step (kill → relaunch in the same cell).
fn seed_live_for_relaunch(f: &Fixture, agent: &AgentId) {
    let host = nid(1);
    seed_live_agent_session(&f.sessions, *agent, host, sid(42));
    let tree = LayoutTree::single(agent_leaf(host, Some(*agent), Some(&pair_uuid()), true));
    seed_layouts(&f.fs, lid(1), &tree);
}

/// A stable UUID-shaped pair id used by the relaunch scenarios.
fn pair_uuid() -> String {
    Uuid::from_u128(0xABCD).to_string()
}

// ===========================================================================
// LP3-4 — cleanup of orphan permission files at a cross-profile swap
// ===========================================================================

/// (1) **Claude→Codex (phare)**: the pre-existing `.claude/settings.local.json`
/// is removed at the swap, and the relaunch projects the Codex sandbox config +
/// args. The orphan Replace file of the old projector is cleaned up; the new
/// projector (Codex) owns no Replace file, so nothing protects it from deletion.
#[tokio::test]
async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture_with_projection(
        &agent,
        vec![claude_profile(pid(1)), codex_profile(pid(2))],
        full_registry(),
        Some(allow_perm_doc()),
    )
    .await;

    let run_dir = run_dir_of(&agent.id);
    let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
    let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
    // The old Claude seed exists in the (stable) run dir before the swap.
    f.fs.put(&seed_path, b"{\"permissions\":{}}");

    seed_live_for_relaunch(&f, &agent.id);
    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    // The orphan Claude seed was deleted (recorded AND gone from the FS state).
    assert!(
        f.fs.removed().iter().any(|p| p == &seed_path),
        "the orphan .claude seed must be removed; removed={:?}",
        f.fs.removed()
    );
    assert!(
        !f.fs.has_file(&seed_path),
        "the seed is gone from the FS state"
    );

    // The relaunch projected the Codex sandbox config + args.
    assert!(
        f.fs.has_file(&codex_path),
        "the relaunch must project the Codex config"
    );
    let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
    assert!(
        toml.contains("sandbox_mode = \"workspace-write\""),
        "{toml}"
    );
    assert!(
        f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
        "sandbox args folded into the relaunch spawn: {:?}",
        f.pty.last_spawn_args()
    );
}

/// (2) **Claude→Claude**: `owned(old) − owned(new)` is empty, so the seed is NOT
/// removed (it is re-clobbered by the relaunch, never deleted).
#[tokio::test]
async fn swap_claude_to_claude_does_not_remove_seed() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture_with_projection(
        &agent,
        vec![claude_profile(pid(1)), claude_profile(pid(2))],
        full_registry(),
        Some(allow_perm_doc()),
    )
    .await;

    let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
    f.fs.put(&seed_path, b"{\"permissions\":{}}");

    seed_live_for_relaunch(&f, &agent.id);
    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    assert!(
        !f.fs.removed().iter().any(|p| p == &seed_path),
        "same-family swap must NOT delete the shared seed; removed={:?}",
        f.fs.removed()
    );
    // It is still present (re-clobbered by the relaunch's projection).
    assert!(
        f.fs.has_file(&seed_path),
        "the seed survives the same-family swap"
    );
}

/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the
/// cleanup side; the relaunch writes the Claude seed; the co-owned
/// `.codex/config.toml` is never deleted.
#[tokio::test]
async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture_with_projection(
        &agent,
        vec![codex_profile(pid(1)), claude_profile(pid(2))],
        full_registry(),
        Some(allow_perm_doc()),
    )
    .await;

    let run_dir = run_dir_of(&agent.id);
    let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
    let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
    // A pre-existing co-owned Codex config that must survive the swap.
    f.fs.put(&codex_path, b"sandbox_mode = \"read-only\"\n");

    seed_live_for_relaunch(&f, &agent.id);
    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    // Nothing removed (Codex has no Replace-owned path).
    assert!(
        f.fs.removed().is_empty(),
        "Codex→Claude removes no permission file; removed={:?}",
        f.fs.removed()
    );
    // The co-owned Codex config is untouched by cleanup…
    assert!(
        f.fs.has_file(&codex_path),
        "the .codex/config.toml is never deleted"
    );
    assert!(
        !f.fs.removed().iter().any(|p| p == &codex_path),
        "the .codex/config.toml is not in the removed list"
    );
    // …and the relaunch projected the new Claude seed.
    assert!(
        f.fs.has_file(&seed_path),
        "the relaunch writes the Claude seed"
    );
}

/// (4a) **No-op (no registry)**: without a projector registry wired on the swap,
/// no cleanup runs at all — even on a Claude→Codex swap with the seed present.
#[tokio::test]
async fn swap_without_registry_removes_nothing() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    // The default fixture wires NO projector registry on the swap.
    let f =
        fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;

    let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
    f.fs.put(&seed_path, b"{\"permissions\":{}}");

    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    assert!(
        f.fs.removed().is_empty(),
        "no registry ⇒ no cleanup; removed={:?}",
        f.fs.removed()
    );
    assert!(f.fs.has_file(&seed_path), "the seed is left untouched");
}

/// (4b) **No-op (previous profile deleted)**: if the old profile id is no longer
/// in the store, the cleanup is skipped (the old projector can't be resolved) and
/// the swap still succeeds.
#[tokio::test]
async fn swap_with_unknown_previous_profile_skips_cleanup() {
    // Agent records pid(1), but the store only knows pid(2) (the target) and pid(3):
    // pid(1) was deleted between launch and swap.
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture_with_projection(
        &agent,
        vec![codex_profile(pid(2)), claude_profile(pid(3))],
        full_registry(),
        Some(allow_perm_doc()),
    )
    .await;

    let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
    f.fs.put(&seed_path, b"{\"permissions\":{}}");

    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds even with an unknown previous profile");

    assert!(
        f.fs.removed().is_empty(),
        "unknown previous profile ⇒ cleanup skipped; removed={:?}",
        f.fs.removed()
    );
}

/// (5) **Best-effort**: when the orphan seed file does NOT exist on disk, the
/// delete is still attempted (idempotent) and the swap succeeds without error.
#[tokio::test]
async fn swap_claude_to_codex_succeeds_when_seed_absent() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture_with_projection(
        &agent,
        vec![claude_profile(pid(1)), codex_profile(pid(2))],
        full_registry(),
        Some(allow_perm_doc()),
    )
    .await;

    // Intentionally do NOT seed `.claude/settings.local.json`.
    let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));

    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds even when the orphan file is already gone");

    // The delete was attempted (best-effort, idempotent) on the missing path.
    assert!(
        f.fs.removed().iter().any(|p| p == &seed_path),
        "a remove was still attempted on the absent seed; removed={:?}",
        f.fs.removed()
    );
}

/// (6) **Non-regression P8d**: the cleanup must not disturb the preserved pair id.
/// A live Claude→Codex swap with a handoff seeded under the leaf's pair id still
/// re-injects that handoff into the new engine's convention file (proving the
/// pair `conversation_id` survived the cleanup step unchanged).
#[tokio::test]
async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture_with_projection(
        &agent,
        vec![claude_profile(pid(1)), codex_profile(pid(2))],
        full_registry(),
        Some(allow_perm_doc()),
    )
    .await;

    // Seed the orphan Claude seed (so cleanup actually fires) and a handoff under
    // the leaf's pair id.
    let run_dir = run_dir_of(&agent.id);
    f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
    let pair = pair_uuid();
    f.handoffs
        .seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));

    let host = nid(1);
    seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
    let tree = LayoutTree::single(agent_leaf(host, Some(agent.id), Some(&pair), true));
    seed_layouts(&f.fs, lid(1), &tree);

    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    // The cleanup fired…
    assert!(
        f.fs.removed()
            .iter()
            .any(|p| p == &format!("{run_dir}/{CLAUDE_SEED_REL}")),
        "the orphan seed was cleaned up"
    );
    // …and the pair id was preserved on the persisted leaf (P8d invariant).
    let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
    assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
    assert!(
        !running,
        "engine running flag reset by invalidate_engine_link"
    );

    // …and the handoff (keyed by that pair id) was re-injected into the new engine's
    // convention file — proving the relaunch threaded the preserved pair id. (The
    // shared FakeRuntime always materialises the convention file as `CLAUDE.md`.)
    let conv_md = String::from_utf8(
        f.fs.read_file(&format!("{run_dir}/CLAUDE.md"))
            .expect("the relaunch wrote the new engine's convention file"),
    )
    .unwrap();
    assert!(
        conv_md.contains("LP3-4"),
        "handoff re-injected under the preserved pair id: {conv_md}"
    );
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

/// No-op: swapping to the *same* profile leaves the agent unchanged, relaunches
/// nothing, publishes no event, kills nothing, and writes no manifest/layout.
#[tokio::test]
async fn same_profile_is_noop() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    let out = f
        .swap
        .execute(change_input(agent.id, pid(1)))
        .await
        .expect("no-op succeeds");

    // Agent returned unchanged, no relaunch.
    assert_eq!(out.agent.profile_id, pid(1));
    assert!(out.relaunched.is_none(), "no relaunch on a no-op");
    // No event published.
    assert!(f.bus.events().is_empty(), "no-op publishes no event");
    // No kill, no spawn.
    assert!(f.pty.kills().is_empty(), "no-op kills nothing");
    assert_eq!(f.pty.spawn_count(), 0, "no-op spawns nothing");
    // Manifest never re-saved (no observable mutation).
    assert_eq!(
        f.contexts.manifest_saves(),
        0,
        "no-op must not rewrite the manifest"
    );
    // No filesystem write at all.
    assert_eq!(f.fs.write_count(), 0, "no-op must not write any layout");
    // Persisted profile is still the original.
    assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(1)));
}

/// Unknown target profile ⇒ NotFound, and the agent is **not** mutated (the
/// manifest keeps the original profile id).
#[tokio::test]
async fn unknown_profile_is_not_found_and_does_not_mutate() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    // pid(99) is not in the store (only pid(1), pid(2)).
    let err = f
        .swap
        .execute(change_input(agent.id, pid(99)))
        .await
        .unwrap_err();

    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
    // Manifest unchanged.
    assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(1)));
    assert_eq!(f.contexts.manifest_saves(), 0);
    assert!(f.pty.kills().is_empty());
    assert!(f.bus.events().is_empty());
}

/// Unknown agent ⇒ NotFound.
#[tokio::test]
async fn unknown_agent_is_not_found() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    let err = f
        .swap
        .execute(change_input(aid(404), pid(2)))
        .await
        .unwrap_err();

    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
    assert_eq!(f.contexts.manifest_saves(), 0);
    assert!(f.bus.events().is_empty());
}

/// Success: the persisted manifest carries the **new** profile id, and the
/// returned agent reflects it.
#[tokio::test]
async fn success_mutates_manifest_to_new_profile() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    let out = f
        .swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    assert_eq!(
        out.agent.profile_id,
        pid(2),
        "returned agent carries new profile"
    );
    assert_eq!(
        f.contexts.profile_of(&agent.id),
        Some(pid(2)),
        "manifest persisted with the new profile"
    );
    assert_eq!(f.contexts.manifest_saves(), 1, "exactly one manifest save");
    // Dead agent (no live session) ⇒ no relaunch.
    assert!(out.relaunched.is_none());
}

/// Engine-link invalidation (P8d new contract): a layout cell hosting the agent
/// **preserves** its stable pair `conversation_id`, **clears** the foreign engine
/// resumable cache (`engine_session_id`) and resets `agent_was_running` on the
/// persisted layouts after the swap.
#[tokio::test]
async fn invalidates_engine_link_preserving_pair_id_on_persisted_layouts() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    // Seed a layout: leaf nid(10) hosts the agent with a pair id + engine cache.
    let leaf = nid(10);
    seed_layouts(
        &f.fs,
        lid(1),
        &LayoutTree::single(agent_leaf_with_engine(
            leaf,
            Some(agent.id),
            Some("pair-stable"),
            Some("engine-old"),
            true,
        )),
    );

    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    // P8d: the stable pair id is PRESERVED; only the running flag is reset.
    assert_eq!(
        leaf_state(&f.fs, leaf),
        Some((Some("pair-stable".to_owned()), false)),
        "pair conversation_id must be preserved; agent_was_running reset"
    );
    // The foreign engine resumable cache is invalidated.
    assert_eq!(
        leaf_engine_session(&f.fs, leaf),
        Some(None),
        "engine_session_id must be cleared"
    );
}

/// Cleanup leaves foreign agents untouched: a second agent's cell keeps its own
/// conversation id.
#[tokio::test]
async fn cleanup_leaves_other_agents_untouched() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    let mine = nid(10);
    let other = nid(11);
    let other_agent = aid(2);
    let tree = LayoutTree::new(LayoutNode::Split(domain::SplitContainer {
        id: nid(1),
        direction: domain::Direction::Row,
        children: vec![
            domain::WeightedChild {
                node: LayoutNode::Leaf(agent_leaf_with_engine(
                    mine,
                    Some(agent.id),
                    Some("conv-mine"),
                    Some("engine-mine"),
                    true,
                )),
                weight: 1.0,
            },
            domain::WeightedChild {
                node: LayoutNode::Leaf(agent_leaf_with_engine(
                    other,
                    Some(other_agent),
                    Some("conv-other"),
                    Some("engine-other"),
                    true,
                )),
                weight: 1.0,
            },
        ],
    }));
    seed_layouts(&f.fs, lid(1), &tree);

    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    // P8d: my pair id is preserved; running reset; engine cache cleared.
    assert_eq!(
        leaf_state(&f.fs, mine),
        Some((Some("conv-mine".to_owned()), false)),
        "mine: pair id preserved, running reset"
    );
    assert_eq!(
        leaf_engine_session(&f.fs, mine),
        Some(None),
        "mine: engine_session_id cleared"
    );
    // The other agent's cell is entirely untouched (pair id, engine, running).
    assert_eq!(
        leaf_state(&f.fs, other),
        Some((Some("conv-other".to_owned()), true)),
        "the other agent's cell is untouched"
    );
    assert_eq!(
        leaf_engine_session(&f.fs, other),
        Some(Some("engine-other".to_owned())),
        "the other agent's engine cache is untouched"
    );
}

/// Live agent ⇒ the PTY is killed and the session is relaunched in the SAME cell
/// (node N). Post-P8d the stable pair id is **preserved** and threaded into the
/// relaunch (`LaunchAgent` invoked at node N); the engine cache is invalidated.
#[tokio::test]
async fn live_agent_is_killed_and_relaunched_in_same_cell() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    // The agent is live in cell N, session sid(42).
    let host = nid(5);
    seed_live_agent_session(&f.sessions, agent.id, host, sid(42));

    // Seed a layout cell for that node carrying the stable pair id + engine cache.
    seed_layouts(
        &f.fs,
        lid(1),
        &LayoutTree::single(agent_leaf_with_engine(
            host,
            Some(agent.id),
            Some("pair-stable"),
            Some("engine-old"),
            true,
        )),
    );

    let out = f
        .swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("hot swap succeeds");

    // The old PTY was killed.
    assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
    // Exactly one relaunch spawn.
    assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
    // The relaunched session is returned and pinned on the SAME cell N.
    let relaunched = out.relaunched.expect("a live agent is relaunched");
    assert_eq!(
        relaunched.node_id, host,
        "relaunch reopens in the same cell"
    );
    assert_eq!(relaunched.id, sid(777), "relaunch adopts the new PTY id");
    // The relaunched session is registered and tagged for this agent.
    assert!(matches!(
        relaunched.kind,
        SessionKind::Agent { agent_id } if agent_id == agent.id
    ));
    assert_eq!(
        f.sessions.session_for_agent(&agent.id),
        Some(sid(777)),
        "the registry now holds the relaunched session"
    );
    // Manifest carries the new profile.
    assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
}

/// Dead agent (no live session) ⇒ no kill, no relaunch; the manifest is still
/// mutated to the new profile.
#[tokio::test]
async fn dead_agent_mutates_without_relaunch() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    // No live session seeded.
    let out = f
        .swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    assert!(out.relaunched.is_none(), "no relaunch for a dead agent");
    assert!(f.pty.kills().is_empty(), "nothing to kill");
    assert_eq!(f.pty.spawn_count(), 0, "no spawn for a dead agent");
    // Manifest still mutated.
    assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
}

/// Event: a successful mutating swap publishes `AgentProfileChanged` exactly once,
/// carrying the agent id and the NEW profile id.
#[tokio::test]
async fn publishes_agent_profile_changed_once_on_success() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    let profile_changed: Vec<_> = f
        .bus
        .events()
        .into_iter()
        .filter(|e| {
            matches!(
                e,
                DomainEvent::AgentProfileChanged { agent_id, profile_id }
                    if *agent_id == agent.id && *profile_id == pid(2)
            )
        })
        .collect();

    assert_eq!(
        profile_changed.len(),
        1,
        "AgentProfileChanged published exactly once with the new profile"
    );
}

// ---------------------------------------------------------------------------
// P8d capstone — cross-profile swap threads the PRESERVED pair id into the
// relaunch, never replays the old engine resumable, and re-injects the handoff.
// ---------------------------------------------------------------------------

/// The relaunch's convention file path: `<root>/.ideai/run/<agent-id>/CLAUDE.md`
/// (the `FakeRuntime` plan targets `CLAUDE.md`, written into the agent run dir).
fn relaunch_convention_path(agent: &AgentId) -> String {
    format!("{ROOT}/.ideai/run/{agent}/CLAUDE.md")
}

/// Case 2 — live agent, swap relaunches threading the **preserved pair id** and a
/// **fresh** `SessionPlan::None` (the old engine resumable is NEVER replayed).
///
/// The pair id on the leaf is a real UUID under which a handoff is seeded. After
/// the swap the new engine's convention file carries `# Reprise de la conversation`
/// — provable only if the relaunch's `conversation_id` equalled that exact pair id.
/// And the `SessionPlan` built for the relaunch is `None`, never `Resume{engine}`.
#[tokio::test]
async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    // A UUID-shaped pair id (so resolve_handoff can parse it) and a seeded handoff.
    let pair = "11111111-1111-1111-1111-111111111111";
    f.handoffs.seed(
        pair,
        "Résumé : on a fini l'étape 2.",
        Some("Livrer le lot P8d"),
    );

    // Live agent on cell N with a foreign engine resumable cache.
    let host = nid(5);
    seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
    seed_layouts(
        &f.fs,
        lid(1),
        &LayoutTree::single(agent_leaf_with_engine(
            host,
            Some(agent.id),
            Some(pair),
            Some("engine-old"),
            true,
        )),
    );

    let out = f
        .swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("hot swap succeeds");

    // The old PTY was killed and a single relaunch happened in the SAME cell.
    assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
    let relaunched = out.relaunched.expect("a live agent is relaunched");
    assert_eq!(
        relaunched.node_id, host,
        "relaunch reopens in the same cell"
    );

    // The pair id is preserved on the persisted leaf; the engine cache is cleared.
    assert_eq!(
        leaf_state(&f.fs, host),
        Some((Some(pair.to_owned()), false)),
        "pair id preserved, running reset"
    );
    assert_eq!(
        leaf_engine_session(&f.fs, host),
        Some(None),
        "engine_session_id cleared"
    );

    // The relaunch threaded the PRESERVED pair id: the handoff seeded under that
    // exact id is re-injected into the new engine's convention file.
    let conv = String::from_utf8(
        f.fs.read_file(&relaunch_convention_path(&agent.id))
            .expect("relaunch wrote a convention file"),
    )
    .unwrap();
    assert!(
        conv.contains("# Reprise de la conversation"),
        "relaunch must re-inject the handoff under the preserved pair id: {conv}"
    );
    assert!(
        conv.contains("Résumé : on a fini l'étape 2."),
        "handoff summary present in the new engine's convention file: {conv}"
    );

    // The old engine resumable is NEVER replayed: the relaunch's SessionPlan is
    // None (providers.json[new provider] is empty ⇒ fresh engine, fidelity carried
    // by the handoff). It is emphatically NOT Resume{"engine-old"}.
    let plan = f
        .runtime
        .last_plan()
        .expect("the relaunch prepared an invocation");
    assert_eq!(
        plan,
        SessionPlan::None,
        "the foreign engine resumable must never be replayed on a swap"
    );
}

/// Case 3 — live agent with NO hosting cell (background session ⇒ step 5 returns
/// `None`). The relaunch must derive the pair id deterministically via
/// `ConversationId::for_pair(User, agent)` (== the agent's own UUID).
///
/// Proven end-to-end: a handoff seeded under `for_pair(User, agent)` is re-injected
/// into the relaunch's convention file, which can only happen if the relaunch
/// threaded that derived id as its `conversation_id`.
#[tokio::test]
async fn live_swap_without_cell_relaunches_with_for_pair_id() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    // The deterministic User↔agent pair id equals the agent's own UUID.
    let derived = domain::ConversationId::for_pair(
        domain::ConversationParty::User,
        domain::ConversationParty::agent(agent.id),
    )
    .to_string();
    assert_eq!(
        derived,
        agent.id.to_string(),
        "for_pair(User, agent) == agent uuid (sanity)"
    );
    f.handoffs
        .seed(&derived, "Repris depuis une session de fond.", None);

    // Live agent but NO seeded layout cell ⇒ node_for_agent yields None
    // (background session) ⇒ step 5 finds no hosting leaf ⇒ pair_id None.
    let host = nid(5);
    seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
    // Intentionally NO seed_layouts: there is no persisted hosting cell.

    let out = f
        .swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("hot swap succeeds");

    assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
    assert!(out.relaunched.is_some(), "a live agent is relaunched");

    // The relaunch derived the pair id via for_pair: the handoff seeded under it is
    // re-injected into the convention file (proves the threaded conversation id).
    let conv = String::from_utf8(
        f.fs.read_file(&relaunch_convention_path(&agent.id))
            .expect("relaunch wrote a convention file"),
    )
    .unwrap();
    assert!(
        conv.contains("# Reprise de la conversation"),
        "relaunch must use for_pair(User, agent) as the conversation id: {conv}"
    );
    assert!(
        conv.contains("Repris depuis une session de fond."),
        "handoff (keyed by for_pair) re-injected: {conv}"
    );

    // Still no engine resume: a swap never replays a foreign resumable.
    assert_eq!(
        f.runtime.last_plan(),
        Some(SessionPlan::None),
        "no engine resume on a swap (for_pair path)"
    );
}

/// Case 1 (completeness) — preservation + invalidation read straight off the
/// persisted leaf with a **UUID** pair id (the realistic post-P8a shape), pairing
/// the existing `pair-stable` opaque-string test. The pair id survives untouched;
/// the engine cache is cleared; the running flag is reset.
#[tokio::test]
async fn swap_preserves_uuid_pair_id_and_clears_engine_cache() {
    let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
    let f = fixture(&agent).await;

    let leaf = nid(10);
    let pair = "22222222-2222-2222-2222-222222222222";
    seed_layouts(
        &f.fs,
        lid(1),
        &LayoutTree::single(agent_leaf_with_engine(
            leaf,
            Some(agent.id),
            Some(pair),
            Some("engine-old"),
            true,
        )),
    );

    f.swap
        .execute(change_input(agent.id, pid(2)))
        .await
        .expect("swap succeeds");

    assert_eq!(
        leaf_state(&f.fs, leaf),
        Some((Some(pair.to_owned()), false)),
        "UUID pair id preserved; agent_was_running reset"
    );
    assert_eq!(
        leaf_engine_session(&f.fs, leaf),
        Some(None),
        "engine_session_id cleared on swap"
    );
}
